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:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user