fix: gate deploy desired-state promotion drift
fix: gate deploy desired-state promotion drift
This commit is contained in:
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user