feat: add read-only dev cd status surface
This commit is contained in:
@@ -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()),
|
||||
|
||||
Reference in New Issue
Block a user