feat: add read-only dev cd status surface

This commit is contained in:
Code Queue Review
2026-05-23 10:43:52 +00:00
parent 3de0b62eec
commit 4c12985068
3 changed files with 281 additions and 267 deletions
+185 -72
View File
@@ -10,7 +10,6 @@ import {
buildLockAnnotations,
classifyDeployLock,
deployLockHeldFailure,
findDevCdApplyProcessCandidates,
parseDeployLock,
runDevCdApply,
verifyDevLive
@@ -99,7 +98,7 @@ function makeHttpGetJson() {
};
}
function makeRunCommand({ heldLock = null, commandLog = [], psOutput = "", killLog = [] } = {}) {
function makeRunCommand({ heldLock = null, commandLog = [] } = {}) {
let lease = heldLock ? leaseFromLock(heldLock) : null;
const assertLeaseMicroTime = (value) => {
assert.match(value, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}Z$/u);
@@ -115,13 +114,6 @@ function makeRunCommand({ heldLock = null, commandLog = [], psOutput = "", killL
if (command === "which" && args[0] === "kubectl") {
return { code: 0, stdout: "/usr/local/bin/kubectl\n", stderr: "" };
}
if (command === "ps") {
return { code: 0, stdout: psOutput, stderr: "" };
}
if (command === "kill") {
killLog.push({ signal: args[0], pid: args[1] });
return { code: 0, stdout: "", stderr: "" };
}
if (command.includes("kubectl") && args.includes("get") && args.includes("lease")) {
if (!lease) return { code: 1, stdout: "", stderr: "Error from server (NotFound): leases.coordination.k8s.io \"hwlab-dev-cd-lock\" not found" };
return { code: 0, stdout: `${JSON.stringify(lease)}\n`, stderr: "" };
@@ -191,6 +183,18 @@ function makeRunCommand({ heldLock = null, commandLog = [], psOutput = "", killL
};
}
function assertNoReadOnlySideEffects(commandLog) {
const mutatingKubectlVerbs = new Set(["apply", "create", "replace", "patch", "delete", "rollout", "wait", "logs"]);
assert.equal(commandLog.some((entry) => entry.command === "ps"), false);
assert.equal(commandLog.some((entry) => entry.command === "kill"), false);
assert.equal(commandLog.some((entry) =>
entry.command.includes("kubectl") && entry.args.some((arg) => mutatingKubectlVerbs.has(arg))
), false);
assert.equal(commandLog.some((entry) => entry.args.includes("scripts/dev-artifact-publish.mjs")), false);
assert.equal(commandLog.some((entry) => entry.args.includes("scripts/dev-deploy-apply.mjs")), false);
assert.equal(commandLog.some((entry) => entry.args.includes("scripts/refresh-artifact-catalog.mjs")), false);
}
test("lock classifier reports held and stale states with retryAfterSeconds", () => {
const lock = {
ownerTaskId: "task-a",
@@ -222,7 +226,8 @@ test("dev-cd status and dry-run are read-only compact JSON", async () => {
const code = await runDevCdApply([
"--dry-run",
"--kubeconfig",
"/tmp/kubeconfig"
"/tmp/kubeconfig",
"--write-report"
], {
repoRoot,
env: {},
@@ -239,22 +244,179 @@ test("dev-cd status and dry-run are read-only compact JSON", async () => {
assert.equal(status.prodTouched, false);
assert.equal(status.lock.status, "absent");
assert.equal(status.deployJson.manifest, undefined);
assert.equal(status.deployJson.matchesTarget, true);
assert.equal(status.live.summary.checked, 2);
assert.equal(commandLog.some((entry) => entry.args.includes("create")), false);
assert.equal(commandLog.some((entry) => entry.args.includes("replace")), false);
assert.equal(commandLog.some((entry) => entry.args.includes("scripts/dev-artifact-publish.mjs")), false);
assert.equal(commandLog.some((entry) => entry.args.includes("scripts/dev-deploy-apply.mjs")), false);
assertNoReadOnlySideEffects(commandLog);
await assert.rejects(
readFile(path.join(repoRoot, "reports/dev-gate/dev-cd-apply.json"), "utf8"),
{ code: "ENOENT" }
);
});
test("process candidate discovery only returns dev-cd apply processes", () => {
const psOutput = [
"101 node scripts/dev-cd-apply.mjs --apply --confirm-dev",
"202 node scripts/dev-cd-apply.mjs --status",
"303 node scripts/dev-deploy-apply.mjs --apply",
"404 node scripts/dev-cd-apply.mjs --apply --confirm-dev"
].join("\n");
const candidates = findDevCdApplyProcessCandidates(psOutput, 404);
assert.deepEqual(candidates.map((candidate) => candidate.pid), [101]);
test("dev-cd default status is read-only compact JSON", async () => {
const repoRoot = await makeRepo();
const commandLog = [];
let output = "";
const code = await runDevCdApply([], {
repoRoot,
env: {},
runCommand: makeRunCommand({ commandLog }),
httpGetJson: makeHttpGetJson(),
now: () => new Date(iso(100000)),
stdout: { write: (chunk) => { output += chunk; } }
});
const status = JSON.parse(output);
assert.equal(code, 0);
assert.equal(status.mode, "status");
assert.equal(status.mutationAttempted, false);
assert.equal(status.prodTouched, false);
assert.equal(status.target.shortCommitId, "abc1234");
assert.equal(status.deployJson.hash.startsWith("sha256:"), true);
assert.equal(status.deployJson.matchesTarget, true);
assert.equal(status.lock.status, "absent");
assertNoReadOnlySideEffects(commandLog);
});
test("dev-cd status summarizes held, released, and stale locks without unsafe takeover hints", async () => {
const cases = [
{
name: "held",
phase: "applying",
now: iso(100000),
expectedStatus: "held",
expectedRetryAfterSeconds: 200
},
{
name: "released",
phase: "released",
releasedAt: iso(50000),
releaseStatus: "pass",
now: iso(100000),
expectedStatus: "released",
expectedRetryAfterSeconds: 0
},
{
name: "stale",
phase: "verifying",
now: iso(301000),
expectedStatus: "stale",
expectedRetryAfterSeconds: 0
}
];
for (const item of cases) {
const repoRoot = await makeRepo();
const commandLog = [];
let output = "";
const code = await runDevCdApply(["--status", "--kubeconfig", "/tmp/kubeconfig"], {
repoRoot,
env: {},
runCommand: makeRunCommand({
commandLog,
heldLock: {
promotionCommit: "abc1234",
deployJsonHash: "sha256:a",
ownerTaskId: `task-${item.name}`,
transactionId: `tx-${item.name}`,
phase: item.phase,
startedAt: iso(0),
updatedAt: iso(0),
ttlSeconds: 300,
liveBefore: { status: "pass" },
targetNamespace: "hwlab-dev",
targetRef: "origin/main",
lockBackend: "Lease",
...(item.releasedAt ? { releasedAt: item.releasedAt } : {}),
...(item.releaseStatus ? { releaseStatus: item.releaseStatus } : {})
}
}),
httpGetJson: makeHttpGetJson(),
now: () => new Date(item.now),
stdout: { write: (chunk) => { output += chunk; } }
});
const status = JSON.parse(output);
assert.equal(code, 0);
assert.equal(status.lock.status, item.expectedStatus);
assert.equal(status.lock.lockName, "hwlab-dev-cd-lock");
assert.equal(status.lock.current.ownerTaskId, `task-${item.name}`);
assert.equal(status.lock.retryAfterSeconds, item.expectedRetryAfterSeconds);
assert.equal(JSON.stringify(status).includes("force-interrupt"), false);
assert.equal(JSON.stringify(status.nextActions).includes("force"), false);
if (item.expectedStatus === "stale") {
assert.match(status.lock.allowedLockAction.breakStale, /--break-stale-lock/u);
}
assertNoReadOnlySideEffects(commandLog);
}
});
test("dev-cd status reports unavailable kubectl with redacted reason", async () => {
const repoRoot = await makeRepo();
const commandLog = [];
let output = "";
const runCommand = async (command, args, options = {}) => {
commandLog.push({ command, args, env: options.env ?? {}, input: options.input ?? "" });
if (command === "git" && args[0] === "rev-parse" && args.includes("origin/main^{commit}")) {
return { code: 0, stdout: "abc1234abc1234abc1234abc1234abc1234abc1\n", stderr: "" };
}
if (command === "git" && args[0] === "rev-parse" && args.includes("HEAD^{commit}")) {
return { code: 0, stdout: "abc1234abc1234abc1234abc1234abc1234abc1\n", stderr: "" };
}
if (command === "which" && args[0] === "kubectl") {
return { code: 1, stdout: "", stderr: "kubectl missing" };
}
if (command === "kubectl") {
return {
code: 1,
stdout: "",
stderr: "connection failed token=SECRET_TOKEN password=SECRET_PASSWORD postgresql://user:SECRET_DB@db"
};
}
return { code: 0, stdout: "", stderr: "" };
};
const code = await runDevCdApply(["--status"], {
repoRoot,
env: {},
runCommand,
httpGetJson: makeHttpGetJson(),
now: () => new Date(iso(100000)),
stdout: { write: (chunk) => { output += chunk; } }
});
const status = JSON.parse(output);
const text = JSON.stringify(status);
assert.equal(code, 0);
assert.equal(status.status, "degraded");
assert.equal(status.lock.status, "unavailable");
assert.equal(status.lock.reason, "kubectl or DEV kubeconfig unavailable; command output is redacted");
assert.equal(text.includes("SECRET_TOKEN"), false);
assert.equal(text.includes("SECRET_PASSWORD"), false);
assert.equal(text.includes("SECRET_DB"), false);
assert.match(status.lock.redactedReason, /<redacted>/u);
assertNoReadOnlySideEffects(commandLog);
});
test("force-interrupt option is rejected before process discovery or lease mutation", async () => {
const repoRoot = await makeRepo();
const commandLog = [];
let output = "";
const code = await runDevCdApply(["--force-interrupt-lock"], {
repoRoot,
env: {},
runCommand: makeRunCommand({ commandLog }),
httpGetJson: makeHttpGetJson(),
now: () => new Date(iso(100000)),
stdout: { write: (chunk) => { output += chunk; } }
});
const failure = JSON.parse(output);
assert.equal(code, 2);
assert.equal(failure.error, "unknown-argument");
assert.equal(failure.mutationAttempted, false);
assert.equal(failure.prodTouched, false);
assert.deepEqual(commandLog, []);
});
test("deploy-lock-held failure is structured before publish/apply", () => {
@@ -489,55 +651,6 @@ test("apply stdout is concise by default while write-report keeps the full repor
assert.equal(writtenReport.devCdApply.deployJson.before.manifest.environment, "dev");
});
test("force-interrupt-lock terminates local apply candidate and takes a held lock", async () => {
const repoRoot = await makeRepo();
const killLog = [];
let output = "";
const code = await runDevCdApply([
"--apply",
"--confirm-dev",
"--confirmed-non-production",
"--force-interrupt-lock",
"--owner-task-id",
"task-force",
"--kubeconfig",
"/tmp/kubeconfig",
"--full-output"
], {
repoRoot,
env: {},
runCommand: makeRunCommand({
killLog,
psOutput: "555 node scripts/dev-cd-apply.mjs --apply --confirm-dev\n666 node scripts/dev-cd-apply.mjs --status\n",
heldLock: {
promotionCommit: "abc1234",
deployJsonHash: "sha256:a",
ownerTaskId: "task-a",
transactionId: "tx-a",
phase: "applying",
startedAt: iso(0),
updatedAt: iso(0),
ttlSeconds: 300,
liveBefore: { status: "pass" },
targetNamespace: "hwlab-dev",
targetRef: "origin/main",
lockBackend: "Lease"
}
}),
httpGetJson: makeHttpGetJson(),
now: () => new Date(iso(100000)),
stdout: { write: (chunk) => { output += chunk; } }
});
const report = JSON.parse(output);
assert.equal(code, 0);
assert.deepEqual(killLog, [{ signal: "-TERM", pid: "555" }]);
assert.equal(report.devCdApply.lock.forceInterrupt.status, "attempted");
assert.equal(report.devCdApply.lock.forceInterrupt.processCandidates[0].pid, 555);
assert.equal(report.devCdApply.lock.current.forceInterruptStatus, "attempted");
assert.equal(report.devCdApply.lock.current.forceInterruptedBy, "task-force");
});
test("live verify summarizes 16666 and 16667 health identity", async () => {
const result = await verifyDevLive({
now: () => new Date(iso()),