fix: gate deploy desired-state promotion drift

fix: gate deploy desired-state promotion drift
This commit is contained in:
Lyon
2026-05-22 23:36:02 +08:00
committed by GitHub
11 changed files with 329 additions and 175 deletions
+76 -14
View File
@@ -6,11 +6,26 @@ import test from "node:test";
import { buildDesiredStatePlan } from "./src/deploy-desired-state-plan.mjs";
async function makeFixture({ commitId = "abc1234", workloadTag = commitId, workloadEnvTag = commitId } = {}) {
async function makeFixture({
commitId = "abc1234",
catalogCommitId = commitId,
catalogServiceCommitId = catalogCommitId,
catalogImageTag = catalogServiceCommitId.slice(0, 7),
deployEnvCommitId = commitId,
deployEnvImageTag = commitId,
deployEnvImage = null,
deploySkillsCommitId = null,
workloadTag = commitId,
workloadEnvCommitId = commitId,
workloadEnvImageTag = commitId,
workloadEnvImage = null,
workloadSkillsCommitId = null
} = {}) {
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-desired-state-"));
await mkdir(path.join(root, "deploy/k8s/base"), { recursive: true });
await mkdir(path.join(root, "reports/dev-gate"), { recursive: true });
const image = `127.0.0.1:5000/hwlab/hwlab-cloud-api:${commitId}`;
const catalogImage = `127.0.0.1:5000/hwlab/hwlab-cloud-api:${catalogImageTag}`;
const workloadImage = `127.0.0.1:5000/hwlab/hwlab-cloud-api:${workloadTag}`;
const deploy = {
manifestVersion: "v1",
@@ -25,9 +40,10 @@ async function makeFixture({ commitId = "abc1234", workloadTag = commitId, workl
profile: "dev",
replicas: 1,
env: {
HWLAB_COMMIT_ID: commitId,
HWLAB_IMAGE: image,
HWLAB_IMAGE_TAG: commitId
HWLAB_COMMIT_ID: deployEnvCommitId,
HWLAB_IMAGE: deployEnvImage ?? image,
HWLAB_IMAGE_TAG: deployEnvImageTag,
...(deploySkillsCommitId ? { HWLAB_SKILLS_COMMIT_ID: deploySkillsCommitId } : {})
}
}
]
@@ -39,7 +55,7 @@ async function makeFixture({ commitId = "abc1234", workloadTag = commitId, workl
profile: "dev",
namespace: "hwlab-dev",
endpoint: "http://74.48.78.17:16667",
commitId,
commitId: catalogCommitId,
artifactState: "contract-skeleton",
publish: {
ciPublished: false,
@@ -49,9 +65,9 @@ async function makeFixture({ commitId = "abc1234", workloadTag = commitId, workl
services: [
{
serviceId: "hwlab-cloud-api",
commitId,
image,
imageTag: commitId,
commitId: catalogServiceCommitId,
image: catalogImage,
imageTag: catalogImageTag,
digest: "not_published",
publishState: "skeleton-only",
artifactRequired: true
@@ -80,9 +96,10 @@ async function makeFixture({ commitId = "abc1234", workloadTag = commitId, workl
name: "hwlab-cloud-api",
image: workloadImage,
env: [
{ name: "HWLAB_COMMIT_ID", value: commitId },
{ name: "HWLAB_IMAGE", value: image },
{ name: "HWLAB_IMAGE_TAG", value: workloadEnvTag }
{ name: "HWLAB_COMMIT_ID", value: workloadEnvCommitId },
{ name: "HWLAB_IMAGE", value: workloadEnvImage ?? image },
{ name: "HWLAB_IMAGE_TAG", value: workloadEnvImageTag },
...(workloadSkillsCommitId ? [{ name: "HWLAB_SKILLS_COMMIT_ID", value: workloadSkillsCommitId }] : [])
]
}
]
@@ -116,8 +133,35 @@ test("target tag review is read-only promotion_pending when current state is uni
assert.match(plan.services[0].promotion.deployImage, /:def5678$/u);
});
test("promotion commit check blocks when current desired-state is uniformly older", async () => {
const repoRoot = await makeFixture();
const plan = await buildDesiredStatePlan({ repoRoot, promotionCommit: "def5678" });
assert.equal(plan.status, "blocked");
assert.equal(plan.target.convergence.state, "promotion_mismatch");
assert.ok(plan.diagnostics.some((diagnostic) => diagnostic.code === "promotion_commit_mismatch"));
});
test("promotion commit check passes when every desired-state field is converged", async () => {
const repoRoot = await makeFixture({ commitId: "def5678" });
const plan = await buildDesiredStatePlan({ repoRoot, promotionCommit: "def5678" });
assert.equal(plan.status, "pass");
assert.equal(plan.target.convergence.state, "already_promoted");
assert.deepEqual(plan.diagnostics, []);
});
test("promotion commit check blocks an explicit non-matching target tag", async () => {
const repoRoot = await makeFixture({ commitId: "def5678" });
const plan = await buildDesiredStatePlan({
repoRoot,
promotionCommit: "def5678",
targetTag: "abc1234"
});
assert.equal(plan.status, "blocked");
assert.ok(plan.diagnostics.some((diagnostic) => diagnostic.code === "promotion_tag_mismatch"));
});
test("blocks on mirror drift", async () => {
const repoRoot = await makeFixture({ workloadEnvTag: "badcafe" });
const repoRoot = await makeFixture({ workloadEnvImageTag: "badcafe" });
const plan = await buildDesiredStatePlan({ repoRoot });
assert.equal(plan.status, "blocked");
assert.equal(plan.summary.blockers, 1);
@@ -125,6 +169,16 @@ test("blocks on mirror drift", async () => {
assert.match(plan.diagnostics[0].path, /HWLAB_IMAGE_TAG/u);
});
test("blocks on skills commit mirror drift", async () => {
const repoRoot = await makeFixture({ deploySkillsCommitId: "badcafe" });
const plan = await buildDesiredStatePlan({ repoRoot });
assert.equal(plan.status, "blocked");
assert.ok(plan.diagnostics.some((diagnostic) =>
diagnostic.code === "mirror_mismatch" &&
diagnostic.path.endsWith("HWLAB_SKILLS_COMMIT_ID")
));
});
test("blocks on workload image drift", async () => {
const repoRoot = await makeFixture({ workloadTag: "badcafe" });
const plan = await buildDesiredStatePlan({ repoRoot });
@@ -133,8 +187,16 @@ test("blocks on workload image drift", async () => {
assert.ok(plan.diagnostics.some((diagnostic) => diagnostic.code === "image_mismatch"));
});
test("reports partial target drift as a blocker", async () => {
const repoRoot = await makeFixture({ workloadTag: "def5678", workloadEnvTag: "def5678" });
test("reports partial target drift when catalog top-level commit moves but services stay old", async () => {
const repoRoot = await makeFixture({ catalogCommitId: "def5678" });
const plan = await buildDesiredStatePlan({ repoRoot, targetTag: "def5678" });
assert.equal(plan.status, "blocked");
assert.equal(plan.target.convergence.state, "partial_drift");
assert.ok(plan.diagnostics.some((diagnostic) => diagnostic.code === "partial_target_drift"));
});
test("reports partial target drift as a blocker", async () => {
const repoRoot = await makeFixture({ workloadTag: "def5678", workloadEnvImageTag: "def5678" });
const plan = await buildDesiredStatePlan({ repoRoot, targetTag: "def5678" });
assert.equal(plan.status, "blocked");
assert.equal(plan.target.convergence.state, "partial_drift");
+79 -17
View File
@@ -27,11 +27,21 @@ const tagPattern = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/;
const commitPattern = /^[a-f0-9]{7,40}$/;
function parseArgs(argv) {
const args = { check: false, pretty: false, targetRef: null, targetTag: null, help: false };
const args = {
check: false,
plan: false,
pretty: false,
targetRef: null,
targetTag: null,
promotionCommit: null,
help: false
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--check") {
args.check = true;
} else if (arg === "--plan") {
args.plan = true;
} else if (arg === "--pretty") {
args.pretty = true;
} else if (arg === "--target-ref") {
@@ -40,6 +50,9 @@ function parseArgs(argv) {
} else if (arg === "--target-tag") {
args.targetTag = requireValue(argv, index, arg);
index += 1;
} else if (arg === "--promotion-commit") {
args.promotionCommit = requireValue(argv, index, arg);
index += 1;
} else if (arg === "--help" || arg === "-h") {
args.help = true;
} else {
@@ -59,15 +72,18 @@ function requireValue(argv, index, arg) {
function usage() {
return [
"usage: node scripts/deploy-desired-state-plan.mjs [--check] [--pretty] [--target-ref REF] [--target-tag TAG]",
"usage: node scripts/deploy-desired-state-plan.mjs [--plan] [--check] [--pretty] [--target-ref REF] [--target-tag TAG] [--promotion-commit SHA]",
"",
"Read-only DEV desired-state commit/image planner.",
"",
"Options:",
" --check exit non-zero only when desired-state sources are missing, invalid, or partially drifted",
" --plan explicitly request the default read-only JSON plan",
" --check exit non-zero when desired-state sources are invalid, drifted, or do not match --promotion-commit",
" --pretty print indented JSON",
" --target-ref REF resolve REF with git and review promotion to its short commit tag",
" --target-tag TAG review promotion to an explicit image tag without proving registry existence",
" --promotion-commit SHA",
" require every authoritative desired-state commit/image/tag field to match this promotion commit",
"",
"This command reads repository files only. It does not build, pull, push, kubectl apply, restart services, or touch PROD."
].join("\n");
@@ -285,8 +301,8 @@ function assertTargetTag(ctx, tag, pathName) {
}
}
async function resolveTarget(repoRoot, { targetRef, targetTag }, ctx) {
if (!targetRef && !targetTag) return null;
async function resolveTarget(repoRoot, { targetRef, targetTag, promotionCommit }, ctx) {
if (!targetRef && !targetTag && !promotionCommit) return null;
let commitId = null;
let shortCommitId = null;
if (targetRef) {
@@ -304,14 +320,31 @@ async function resolveTarget(repoRoot, { targetRef, targetTag }, ctx) {
});
}
}
if (promotionCommit) {
if (!commitPattern.test(String(promotionCommit))) {
addMismatch(ctx, "invalid_promotion_commit", "--promotion-commit", "promotion commit must be a short or full lowercase Git SHA", "7-40 lowercase hex", promotionCommit);
} else {
const promotionShortCommitId = promotionCommit.slice(0, 7);
if (commitId && !commitEquivalent(promotionCommit, commitId)) {
addMismatch(ctx, "promotion_commit_ref_mismatch", "--promotion-commit", "promotion commit must match the resolved target ref", commitId, promotionCommit);
}
commitId = commitId ?? promotionCommit;
shortCommitId = shortCommitId ?? promotionShortCommitId;
}
}
const tag = targetTag ?? shortCommitId;
if (tag) assertTargetTag(ctx, tag, "--target-tag");
if (promotionCommit && commitPattern.test(String(promotionCommit)) && targetTag && targetTag !== promotionCommit.slice(0, 7)) {
addMismatch(ctx, "promotion_tag_mismatch", "--target-tag", "promotion target tag must be the short promotion commit", promotionCommit.slice(0, 7), targetTag);
}
return {
targetRef,
promotionCommit,
commitId,
shortCommitId,
tag,
tagSource: targetTag ? "target-tag" : "target-ref-short-commit"
tagSource: targetTag ? "target-tag" : promotionCommit ? "promotion-commit-short" : "target-ref-short-commit",
requireConvergence: Boolean(promotionCommit)
};
}
@@ -350,6 +383,7 @@ function buildTargetConvergence(ctx, target) {
const matching = comparable.filter((observation) => observation.matchesTarget === true);
const pending = comparable.filter((observation) => observation.matchesTarget === false);
const partial = matching.length > 0 && pending.length > 0;
const promotionRequired = target.requireConvergence === true;
if (partial) {
addDiagnostic(ctx, {
@@ -362,13 +396,28 @@ function buildTargetConvergence(ctx, target) {
pending: pending.length
}
});
} else if (promotionRequired && pending.length > 0) {
addDiagnostic(ctx, {
code: "promotion_commit_mismatch",
path: "desired-state",
message: "desired-state fields do not match the required promotion commit/tag",
expected: {
commitId: target.commitId,
imageTag: target.tag
},
actual: {
pending: pending.length
}
});
}
const state = partial
? "partial_drift"
: pending.length === 0
? "already_promoted"
: "promotion_pending";
: promotionRequired
? "promotion_mismatch"
: "promotion_pending";
return {
state,
comparableFields: comparable.length,
@@ -379,7 +428,7 @@ function buildTargetConvergence(ctx, target) {
};
}
function reportHints(report) {
function reportHints(report, desiredCommitId) {
if (!report) {
return {
path: artifactReportPath,
@@ -389,15 +438,19 @@ function reportHints(report) {
};
}
const artifactPublish = report.artifactPublish ?? {};
const reportCommitId = report.commitId ?? null;
const artifactSourceCommitId = artifactPublish.sourceCommitId ?? null;
return {
path: artifactReportPath,
present: true,
authoritative: false,
note: "report snapshots are contextual evidence only; deploy desired-state remains authoritative in deploy/, not reports/",
commitId: report.commitId ?? null,
commitId: reportCommitId,
matchesDesiredState: reportCommitId ? commitEquivalent(reportCommitId, desiredCommitId) : null,
artifactPublish: {
status: artifactPublish.status ?? null,
sourceCommitId: artifactPublish.sourceCommitId ?? null,
sourceCommitId: artifactSourceCommitId,
sourceMatchesDesiredState: artifactSourceCommitId ? commitEquivalent(artifactSourceCommitId, desiredCommitId) : null,
publishedCount: artifactPublish.publishedCount ?? null,
serviceCount: artifactPublish.serviceCount ?? null,
registryVerified: artifactPublish.registryVerified ?? null,
@@ -422,12 +475,19 @@ function servicePromotion(service, target) {
}
function targetCommands(target) {
if (!target?.targetRef) return [];
return [
if (!target?.targetRef && !target?.promotionCommit) return [];
const commands = [];
if (target.promotionCommit) {
commands.push(`node scripts/deploy-desired-state-plan.mjs --promotion-commit ${target.promotionCommit} --check --pretty`);
}
if (target.targetRef) {
commands.push(
`node scripts/deploy-desired-state-plan.mjs --target-ref ${target.targetRef} --pretty`,
`node scripts/refresh-artifact-catalog.mjs --target-ref ${target.targetRef} --blocked`,
`node scripts/refresh-artifact-catalog.mjs --target-ref ${target.targetRef} --publish-report ${artifactReportPath}`
];
);
}
return commands;
}
export async function buildDesiredStatePlan(options = {}) {
@@ -441,7 +501,8 @@ export async function buildDesiredStatePlan(options = {}) {
]);
const target = await resolveTarget(repoRoot, {
targetRef: options.targetRef ?? null,
targetTag: options.targetTag ?? null
targetTag: options.targetTag ?? null,
promotionCommit: options.promotionCommit ?? null
}, ctx);
const deployServices = deploy.services ?? [];
@@ -674,7 +735,7 @@ export async function buildDesiredStatePlan(options = {}) {
prod: false,
devLiveClaim: false
},
checkSemantics: "--check exits non-zero only for missing/invalid desired-state sources, internal commit/image mirror drift, invalid target tags, or partial target drift. A uniform older desired-state under --target-ref is a read-only promotion plan, not a check failure.",
checkSemantics: "--check exits non-zero for missing/invalid desired-state sources, internal commit/image mirror drift, invalid target tags, partial target drift, or any mismatch with --promotion-commit. A uniform older desired-state under --target-ref alone is a read-only promotion plan, not a check failure.",
summary: {
desiredCommitId,
desiredImageTag,
@@ -699,7 +760,7 @@ export async function buildDesiredStatePlan(options = {}) {
nonAuthoritativeEvidence: [artifactReportPath],
note: "This planner reviews source desired-state only. It does not prove image existence, registry reachability, a real DEV apply, or M3 DEV-LIVE hardware-loop evidence."
},
reportHints: reportHints(artifactReport),
reportHints: reportHints(artifactReport, desiredCommitId),
services,
diagnostics: ctx.diagnostics
};
@@ -714,7 +775,8 @@ export async function runDeployDesiredStatePlanCli(argv = process.argv.slice(2),
const plan = await buildDesiredStatePlan({
repoRoot: options.repoRoot,
targetRef: args.targetRef,
targetTag: args.targetTag
targetTag: args.targetTag,
promotionCommit: args.promotionCommit
});
process.stdout.write(`${JSON.stringify(plan, null, args.pretty ? 2 : 0)}\n`);
if (args.check && plan.status === "blocked") {