Formalize Cloud Web DEV CD contract

Merge PR #152 after Code Queue rebase and validation. This lands the DEV Cloud Web publish/apply contract, dist freshness checks, artifact identity evidence, and read-only rollout observation. No PROD changes and no live deploy claim.
This commit is contained in:
Lyon
2026-05-23 00:20:19 +08:00
committed by GitHub
parent eb710860dd
commit a44438779f
11 changed files with 509 additions and 85 deletions
+219 -38
View File
@@ -17,6 +17,7 @@ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../
const reportPath = "reports/dev-gate/dev-deploy-report.json";
const namespace = "hwlab-dev";
const healthPath = "/health/live";
const d601KubeconfigPath = "/etc/rancher/k3s/k3s.yaml";
const devTemplateJobReplacementPolicy = {
status: "active",
namespace,
@@ -94,7 +95,11 @@ async function readText(relativePath) {
async function commandResult(command, args, timeoutMs = 15000, options = {}) {
return new Promise((resolve) => {
const child = spawn(command, args, { cwd: repoRoot, stdio: ["pipe", "pipe", "pipe"] });
const child = spawn(command, args, {
cwd: repoRoot,
env: options.env ?? process.env,
stdio: ["pipe", "pipe", "pipe"]
});
const timer = setTimeout(() => child.kill("SIGTERM"), timeoutMs);
let stdout = "";
let stderr = "";
@@ -129,6 +134,64 @@ async function findExecutable(name) {
return null;
}
async function resolveD601Kubectl() {
const kubectlPath = await findExecutable("kubectl");
const commandPrefix = `KUBECONFIG=${d601KubeconfigPath} kubectl`;
const base = {
executor: kubectlPath,
kubeconfig: d601KubeconfigPath,
commandPrefix,
env: {
...process.env,
KUBECONFIG: d601KubeconfigPath
}
};
if (!kubectlPath) {
return {
...base,
status: "blocked",
reason: "kubectl is not installed in the runner"
};
}
try {
await access(d601KubeconfigPath, fsConstants.R_OK);
} catch {
return {
...base,
status: "blocked",
reason: `D601 k3s kubeconfig is not readable at ${d601KubeconfigPath}`
};
}
return {
...base,
status: "ready",
reason: "D601 k3s kubeconfig is readable"
};
}
function kubectlCommand(kubectl, args) {
return `${kubectl.commandPrefix} ${args.join(" ")}`;
}
async function kubectlResult(kubectl, args, timeoutMs = 15000, options = {}) {
if (kubectl.status !== "ready") {
return {
ok: false,
code: null,
stdout: "",
stderr: kubectl.reason,
blocked: true
};
}
return commandResult(kubectl.executor, args, timeoutMs, {
...options,
env: kubectl.env
});
}
function listItems(document) {
if (!document) return [];
return document.kind === "List" ? document.items ?? [] : [document];
@@ -322,6 +385,103 @@ function buildArtifactPlan(deploy, catalog, sourceCommitId, artifactEvidence) {
};
}
function artifactIdentityForService(deploy, catalog, serviceId) {
const deployService = deploy?.services?.find((service) => service.serviceId === serviceId) ?? null;
const catalogService = catalog?.services?.find((service) => service.serviceId === serviceId) ?? null;
return {
serviceId,
sourceCommitId: catalogService?.commitId ?? deploy?.commitId ?? "unknown",
image: deployService?.image ?? catalogService?.image ?? null,
imageTag: catalogService?.imageTag ?? parseImageTag(deployService?.image),
digest: catalogService?.digest ?? "not_published",
repositoryDigest: catalogService?.repositoryDigest ?? null,
publishState: catalogService?.publishState ?? "unknown"
};
}
function parseImageTag(image) {
if (typeof image !== "string") return null;
const slashIndex = image.lastIndexOf("/");
const colonIndex = image.lastIndexOf(":");
if (colonIndex <= slashIndex) return null;
return image.slice(colonIndex + 1);
}
function deploymentRevision(deployment) {
return deployment?.metadata?.annotations?.["deployment.kubernetes.io/revision"] ?? null;
}
function containerImageForDeployment(deployment, containerName) {
const containers = deployment?.spec?.template?.spec?.containers ?? [];
return containers.find((container) => container.name === containerName)?.image ?? null;
}
function buildRolloutVerificationCommand(serviceId) {
return `KUBECONFIG=${d601KubeconfigPath} kubectl -n hwlab-dev rollout status deployment/${serviceId} --timeout=180s`;
}
async function observeDeploymentRollout(kubectl, deploy, catalog, serviceId, blockers) {
const artifact = artifactIdentityForService(deploy, catalog, serviceId);
const commandArgs = ["-n", namespace, "get", "deployment", serviceId, "-o", "json"];
const base = {
serviceId,
namespace,
status: "not_evaluated",
sourceCommitId: artifact.sourceCommitId,
image: artifact.image ?? "unknown",
imageTag: artifact.imageTag ?? "unknown",
digest: artifact.digest,
repositoryDigest: artifact.repositoryDigest,
publishState: artifact.publishState,
rolloutRevision: null,
liveImage: null,
imageMatchesDesired: false,
verificationCommand: buildRolloutVerificationCommand(serviceId),
readCommand: kubectlCommand(kubectl, commandArgs),
kubeconfig: kubectl.kubeconfig
};
if (kubectl.status !== "ready") {
addBlocker(blockers, "environment_blocker", `rollout-read-${serviceId}`, kubectl.reason);
return {
...base,
status: "blocked",
blocker: kubectl.reason
};
}
const result = await kubectlResult(kubectl, commandArgs, 15000);
if (!result.ok) {
const summary = oneLine(result.stderr || result.stdout);
addBlocker(blockers, "environment_blocker", `rollout-read-${serviceId}`, `Cannot read DEV Deployment ${serviceId} rollout revision: ${summary}`);
return {
...base,
status: "blocked",
blocker: summary
};
}
try {
const deployment = JSON.parse(result.stdout);
const liveImage = containerImageForDeployment(deployment, serviceId);
return {
...base,
status: "observed",
rolloutRevision: deploymentRevision(deployment),
liveImage,
imageMatchesDesired: Boolean(artifact.image && liveImage === artifact.image)
};
} catch (error) {
const summary = oneLine(error.message);
addBlocker(blockers, "contract_blocker", `rollout-read-${serviceId}`, `Cannot parse DEV Deployment ${serviceId} rollout revision: ${summary}`);
return {
...base,
status: "blocked",
blocker: summary
};
}
}
function blockerHint(blocker) {
if (blocker.scope === "artifact-publish") {
return "Publish DEV images from the intended commit and update the catalog with registry digests and verification evidence.";
@@ -329,12 +489,15 @@ function blockerHint(blocker) {
if (blocker.scope === "artifact-source-commit") {
return "Regenerate deploy/deploy.json and deploy/artifact-catalog.dev.json for the source commit that will be promoted.";
}
if (blocker.scope === "kubectl" || blocker.scope === "kubectl-version") {
return "Provide a kubectl client configured for the D601 DEV k3s context without exposing token or secret values.";
if (blocker.scope === "kubectl" || blocker.scope === "d601-kubeconfig" || blocker.scope === "kubectl-version") {
return `Provide kubectl with readable D601 k3s kubeconfig at ${d601KubeconfigPath} without exposing token or secret values.`;
}
if (blocker.scope === "cluster-read") {
return "Restore read-only visibility for pods, services, configmaps, deployments, and jobs in hwlab-dev.";
}
if (blocker.scope.startsWith("rollout-read-")) {
return "Restore read-only Deployment visibility in hwlab-dev so rollout revision, image tag, digest, and source commit can be reported after apply.";
}
if (blocker.scope === "dev-health" || blocker.scope === "dev-health-identity") {
return "Restore the public DEV health path and confirm it identifies HWLAB dev, not a substitute runtime.";
}
@@ -366,13 +529,13 @@ function buildRollbackHint(workloads) {
namespace,
strategy: "DEV-only Kubernetes rollback using deployment revision history; do not touch PROD or UniDesk services.",
captureBeforeApply: [
"kubectl -n hwlab-dev get pods,services,configmaps,deployments,jobs -o wide",
"kubectl -n hwlab-dev get deployments -o json"
`KUBECONFIG=${d601KubeconfigPath} kubectl -n hwlab-dev get pods,services,configmaps,deployments,jobs -o wide`,
`KUBECONFIG=${d601KubeconfigPath} kubectl -n hwlab-dev get deployments -o json`
],
deploymentRollbackCommands: deploymentNames.map((name) => `kubectl -n hwlab-dev rollout undo deployment/${name}`),
jobCleanupCommands: jobNames.map((name) => `kubectl -n hwlab-dev delete job ${name} --ignore-not-found`),
deploymentRollbackCommands: deploymentNames.map((name) => `KUBECONFIG=${d601KubeconfigPath} kubectl -n hwlab-dev rollout undo deployment/${name}`),
jobCleanupCommands: jobNames.map((name) => `KUBECONFIG=${d601KubeconfigPath} kubectl -n hwlab-dev delete job ${name} --ignore-not-found`),
postRollbackChecks: [
"kubectl -n hwlab-dev rollout status deployment/hwlab-cloud-api --timeout=120s",
`KUBECONFIG=${d601KubeconfigPath} kubectl -n hwlab-dev rollout status deployment/hwlab-cloud-api --timeout=120s`,
`curl -fsS ${new URL(healthPath, DEV_ENDPOINT).href}`
]
};
@@ -403,8 +566,8 @@ function buildApplyBoundary(args, status, applyStep) {
mutationAttempted: applyStep.mutationAttempted === true && args.apply,
mutationAllowed: status === "pass" && args.apply,
applyRequiresFlags: ["--apply", "--confirm-dev", "--confirmed-non-production"],
writeScope: "kubectl apply -k deploy/k8s/dev, namespace hwlab-dev only; delete/recreate is allowed only for explicitly allowlisted suspended DEV template Jobs",
noWriteScope: "source/catalog/k8s validation, optional DEV health probe, kubectl read, replacement planning, and server-side dry-run only",
writeScope: `KUBECONFIG=${d601KubeconfigPath} kubectl apply -k deploy/k8s/dev, namespace hwlab-dev only; delete/recreate is allowed only for explicitly allowlisted suspended DEV template Jobs`,
noWriteScope: `source/catalog/k8s validation, optional DEV health probe, KUBECONFIG=${d601KubeconfigPath} kubectl read, replacement planning, and server-side dry-run only`,
forbiddenActions
};
}
@@ -751,25 +914,40 @@ function httpGet(url, timeoutMs) {
});
}
async function observeCluster(kubectlPath, blockers) {
if (!kubectlPath) {
addBlocker(blockers, "environment_blocker", "kubectl", "kubectl is not installed in the runner");
return { status: "blocked", executor: "missing", resources: [] };
async function observeCluster(kubectl, blockers) {
const blockedBase = {
status: "blocked",
executor: kubectl.executor ?? "missing",
kubeconfig: kubectl.kubeconfig,
commandPrefix: kubectl.commandPrefix,
resources: []
};
if (!kubectl.executor) {
addBlocker(blockers, "environment_blocker", "kubectl", kubectl.reason);
return blockedBase;
}
const version = await commandResult(kubectlPath, ["version", "--client=true", "--output=yaml"]);
if (kubectl.status !== "ready") {
addBlocker(blockers, "environment_blocker", "d601-kubeconfig", kubectl.reason);
return blockedBase;
}
const version = await kubectlResult(kubectl, ["version", "--client=true", "--output=yaml"]);
if (!version.ok) {
addBlocker(blockers, "environment_blocker", "kubectl-version", `kubectl client failed: ${version.stderr || version.stdout}`);
return { status: "blocked", executor: kubectlPath, resources: [] };
return blockedBase;
}
const get = await commandResult(kubectlPath, ["get", "pods,services,configmaps,deployments,jobs", "-n", namespace, "-o", "json"], 20000);
const getArgs = ["get", "pods,services,configmaps,deployments,jobs", "-n", namespace, "-o", "json"];
const get = await kubectlResult(kubectl, getArgs, 20000);
if (!get.ok) {
addBlocker(blockers, "environment_blocker", "cluster-read", `Cannot read hwlab-dev resources: ${get.stderr || get.stdout}`);
return { status: "blocked", executor: kubectlPath, version: version.stdout, resources: [] };
return { ...blockedBase, version: version.stdout, readCommand: kubectlCommand(kubectl, getArgs) };
}
const parsed = JSON.parse(get.stdout);
return {
status: "pass",
executor: kubectlPath,
executor: kubectl.executor,
kubeconfig: kubectl.kubeconfig,
commandPrefix: kubectl.commandPrefix,
readCommand: kubectlCommand(kubectl, getArgs),
version: version.stdout,
resources: (parsed.items ?? []).map((item) => ({
kind: item.kind,
@@ -785,11 +963,11 @@ function isNotFoundOutput(value) {
return /\bnot\s+found\b|notfound/iu.test(value);
}
async function readLiveTemplateJob(kubectlPath, jobName, blockers) {
if (!kubectlPath) {
return { status: "not_evaluated", reason: "kubectl is missing" };
async function readLiveTemplateJob(kubectl, jobName, blockers) {
if (kubectl.status !== "ready") {
return { status: "not_evaluated", reason: kubectl.reason };
}
const get = await commandResult(kubectlPath, ["-n", namespace, "get", "job", jobName, "-o", "json"], 15000);
const get = await kubectlResult(kubectl, ["-n", namespace, "get", "job", jobName, "-o", "json"], 15000);
if (!get.ok) {
const output = get.stderr || get.stdout;
if (isNotFoundOutput(output)) {
@@ -806,12 +984,12 @@ async function readLiveTemplateJob(kubectlPath, jobName, blockers) {
}
}
async function buildDevTemplateJobReplacementPlan(kubectlPath, workloads, blockers) {
async function buildDevTemplateJobReplacementPlan(kubectl, workloads, blockers) {
const desiredJobs = listItems(workloads).filter(isAllowedDesiredTemplateJob);
const replacements = [];
for (const desiredJob of desiredJobs) {
const jobName = desiredJob.metadata.name;
const live = await readLiveTemplateJob(kubectlPath, jobName, blockers);
const live = await readLiveTemplateJob(kubectl, jobName, blockers);
if (live.status === "not_evaluated") {
replacements.push({
namespace,
@@ -866,7 +1044,7 @@ async function buildDevTemplateJobReplacementPlan(kubectlPath, workloads, blocke
return replacements;
}
async function executeDevTemplateJobReplacements(kubectlPath, replacements, blockers) {
async function executeDevTemplateJobReplacements(kubectl, replacements, blockers) {
const planned = replacements.filter((replacement) => replacement.replace && replacement.result === "planned");
for (const replacement of planned) {
const commandArgs = [
@@ -877,8 +1055,8 @@ async function executeDevTemplateJobReplacements(kubectlPath, replacements, bloc
replacement.jobName,
"--ignore-not-found=true"
];
const result = await commandResult(kubectlPath, commandArgs, 20000);
replacement.deleteCommand = `kubectl ${commandArgs.join(" ")}`;
const result = await kubectlResult(kubectl, commandArgs, 20000);
replacement.deleteCommand = kubectlCommand(kubectl, commandArgs);
replacement.deleteStdout = result.stdout;
replacement.deleteStderr = result.stderr;
if (!result.ok) {
@@ -913,7 +1091,7 @@ function isExpectedTemplateJobImmutableFailure(result, replacementsNeeded) {
);
}
async function runApplyStep(args, kubectlPath, blockers, templateJobReplacements) {
async function runApplyStep(args, kubectl, blockers, templateJobReplacements) {
if (blockers.length > 0) {
return { status: "not_run", summary: "Skipped because preflight blockers are open" };
}
@@ -921,12 +1099,12 @@ async function runApplyStep(args, kubectlPath, blockers, templateJobReplacements
const replacementsNeeded = templateJobReplacements.filter((replacement) => replacement.replace);
if (args.apply) {
const replaceCount = await executeDevTemplateJobReplacements(kubectlPath, templateJobReplacements, blockers);
const replaceCount = await executeDevTemplateJobReplacements(kubectl, templateJobReplacements, blockers);
mutationAttempted = replaceCount > 0;
if (blockers.length > 0) {
return {
status: "blocked",
command: "kubectl apply -k deploy/k8s/dev",
command: kubectlCommand(kubectl, ["apply", "-k", "deploy/k8s/dev"]),
mutationAttempted,
summary: "Skipped kubectl apply because DEV template Job replacement failed"
};
@@ -936,7 +1114,7 @@ async function runApplyStep(args, kubectlPath, blockers, templateJobReplacements
const commandArgs = args.apply
? ["apply", "-k", "deploy/k8s/dev"]
: ["apply", "--dry-run=server", "-k", "deploy/k8s/dev"];
const result = await commandResult(kubectlPath, commandArgs, 30000);
const result = await kubectlResult(kubectl, commandArgs, 30000);
const expectedImmutableDryRun = !args.apply && isExpectedTemplateJobImmutableFailure(result, replacementsNeeded);
mutationAttempted = mutationAttempted || args.apply;
if (!result.ok && !expectedImmutableDryRun) {
@@ -952,7 +1130,7 @@ async function runApplyStep(args, kubectlPath, blockers, templateJobReplacements
}
return {
status: result.ok || expectedImmutableDryRun ? "pass" : "blocked",
command: `kubectl ${commandArgs.join(" ")}`,
command: kubectlCommand(kubectl, commandArgs),
stdout: result.stdout,
stderr: result.stderr,
mutationAttempted,
@@ -1007,13 +1185,14 @@ export async function runDevDeployApply(argv, io = {}) {
addBlocker(blockers, "contract_blocker", "cloud-api-health-path", "cloud-api does not implement the k8s /health/live path");
}
const kubectlPath = await findExecutable("kubectl");
const clusterObservation = await observeCluster(kubectlPath, blockers);
const kubectl = await resolveD601Kubectl();
const clusterObservation = await observeCluster(kubectl, blockers);
const cloudWebRollout = await observeDeploymentRollout(kubectl, deploy, catalog, "hwlab-cloud-web", blockers);
const liveProbe = args.skipLiveProbe ? { status: "not_run", reason: "--skip-live-probe" } : await probeDevEndpoint(blockers);
const templateJobReplacements = await buildDevTemplateJobReplacementPlan(kubectlPath, workloads, blockers);
const templateJobReplacements = await buildDevTemplateJobReplacementPlan(kubectl, workloads, blockers);
const applyStep = await runApplyStep(
args,
kubectlPath,
kubectl,
blockers,
templateJobReplacements
);
@@ -1070,7 +1249,8 @@ export async function runDevDeployApply(argv, io = {}) {
`expected artifact commit: ${artifactPlan.expectedArtifactCommit}`,
`namespace: ${namespace}`,
`workloads planned: ${workloadPlan.length}`,
`kubectl executor: ${kubectlPath ?? "missing"}`,
`kubectl executor: ${kubectl.executor ?? "missing"}`,
`kubectl kubeconfig: ${kubectl.kubeconfig}`,
`live health: ${liveProbe.status}`,
`template job replacements: ${templateJobReplacements.filter((replacement) => replacement.replace).length}`
],
@@ -1102,6 +1282,7 @@ export async function runDevDeployApply(argv, io = {}) {
servicePlan,
templateJobReplacementPolicy: devTemplateJobReplacementPolicy,
templateJobReplacements,
cloudWebRollout,
applyStep,
manualCommands,
rollbackHint: buildRollbackHint(workloads),