diff --git a/docs/reference/deployment-publish.md b/docs/reference/deployment-publish.md index 6b4c0920..5c6068e9 100644 --- a/docs/reference/deployment-publish.md +++ b/docs/reference/deployment-publish.md @@ -42,6 +42,16 @@ state、apply 到 `hwlab-dev`、并复验公开 `16666/16667` 的正式写路径 获取 `Lease/hwlab-dev/hwlab-dev-cd-lock`,串行执行内部步骤,按需写入 `reports/dev-gate/dev-cd-apply.json`,最后释放 Lease。 +`deploy/deploy.json` 的 `commitId` 是运行时 promotion commit。正常 +latest-main 发布时它可以等于当前 target ref;artifact merge commit 或后续 +控制面提交存在时,它也可以指向一个已经发布并在 catalog/workloads 中完全收敛 +的早期 promotion commit。`scripts/dev-cd-apply.mjs` 必须先用 +`deploy-desired-state-plan --promotion-commit ` 验证该 desired-state +已经发布且内部一致;验证通过后,`--apply` 消费这个 promotion commit 的镜像 +执行 apply/verify,不重新 publish 或刷新当前 HEAD 的 artifact。这样 host +commander 可以始终从最新 `origin/main` 控制入口执行正式 DEV CD,而不需要 +checkout promotion parent 或手工拼装发布环境。 + Lease 只是发布平面的互斥锁,不是 desired-state 来源。Lease annotations 至少记录 `promotionCommit`、`deployJsonHash`、`ownerTaskId`、 `transactionId`、`phase`、`startedAt`、`updatedAt`、`ttlSeconds`、 diff --git a/scripts/src/dev-cd-apply.mjs b/scripts/src/dev-cd-apply.mjs index 3cab1228..3a5cf30b 100644 --- a/scripts/src/dev-cd-apply.mjs +++ b/scripts/src/dev-cd-apply.mjs @@ -325,6 +325,107 @@ async function resolveTargetRef(ctx, targetRef) { }; } +async function resolveCommitRef(ctx, ref) { + const result = await ctx.runCommand("git", ["rev-parse", "--verify", `${ref}^{commit}`], { + cwd: ctx.repoRoot, + timeoutMs: 10000 + }); + if (result.code !== 0) { + throw new DevCdApplyError(`promotion commit ${ref} could not be resolved`, { + code: "promotion-commit-unresolved", + targetRef: ref, + stderr: redactSensitiveText(result.stderr) + }); + } + const commitId = result.stdout.trim(); + return { + commitId, + shortCommitId: shortCommit(commitId) + }; +} + +function compactDesiredStateCheck(result) { + const parsed = parseJsonMaybe(result.stdout ?? ""); + return { + status: result.code === 0 ? "pass" : "blocked", + code: result.code, + summary: parsed?.summary ?? null, + convergence: parsed?.target?.convergence?.state ?? null, + diagnostics: Array.isArray(parsed?.diagnostics) + ? parsed.diagnostics.slice(0, 5).map((item) => ({ + code: item.code ?? null, + path: item.path ?? null, + message: item.message ?? null + })) + : [], + stderrTail: redactSensitiveText(result.stderr ?? "").trim().slice(-1000) + }; +} + +async function checkPromotionDesiredState(ctx, promotionCommit) { + const result = await ctx.runCommand(process.execPath, [ + "scripts/deploy-desired-state-plan.mjs", + "--promotion-commit", + promotionCommit, + "--check", + "--pretty" + ], { + cwd: ctx.repoRoot, + timeoutMs: 120000 + }); + return compactDesiredStateCheck(result); +} + +async function resolveEffectiveTarget(ctx, args, deployBefore) { + const control = await resolveTargetRef(ctx, args.targetRef); + if (deployJsonMatchesTarget(deployBefore, control)) { + return { + ...control, + controlRef: { + ref: control.ref, + commitId: control.commitId, + shortCommitId: control.shortCommitId + }, + promotionSource: "target-ref", + publishRequired: true, + desiredStateCheck: null + }; + } + + const promotion = await resolveCommitRef(ctx, deployBefore.commitId); + const desiredStateCheck = await checkPromotionDesiredState(ctx, promotion.commitId); + if (desiredStateCheck.status !== "pass") { + return { + ...control, + controlRef: { + ref: control.ref, + commitId: control.commitId, + shortCommitId: control.shortCommitId + }, + promotionSource: "target-ref", + publishRequired: true, + desiredStateCheck + }; + } + + return { + ref: control.ref, + commitId: promotion.commitId, + shortCommitId: promotion.shortCommitId, + headCommitId: control.headCommitId, + headShortCommitId: control.headShortCommitId, + headMatchesTarget: true, + controlRef: { + ref: control.ref, + commitId: control.commitId, + shortCommitId: control.shortCommitId + }, + promotionSource: "deploy-json", + publishRequired: false, + desiredStateCheck + }; +} + function validateArgs(args) { const blockers = []; if (!args.apply && !args.status) { @@ -1402,6 +1503,9 @@ function summarizeLockStatus(lockRead, now) { function buildStatusNextActions({ target, deployBefore, lock, liveVerify }) { const actions = []; + if (target.desiredStateCheck?.status === "blocked") { + actions.push("deploy/deploy.json does not match the target ref and the deploy-json promotion desired-state check failed; refresh artifacts/catalog or choose an explicit published promotion."); + } if (!target.headMatchesTarget) { actions.push(`Checkout or fast-forward to ${target.ref} ${target.shortCommitId} before apply.`); } @@ -1419,15 +1523,17 @@ function buildStatusNextActions({ target, deployBefore, lock, liveVerify }) { actions.push("Inspect live endpoint identity/health before treating DEV as converged."); } if (actions.length === 0) { - actions.push("Run --apply --confirm-dev --confirmed-non-production --write-report when ready to publish/apply DEV."); + actions.push(target.publishRequired === false + ? "Run --apply --confirm-dev --confirmed-non-production --write-report when ready to apply the published deploy.json promotion without republishing HEAD." + : "Run --apply --confirm-dev --confirmed-non-production --write-report when ready to publish/apply DEV."); } return actions; } async function runDevCdStatus(args, ctx, stdout) { const generatedAt = ctx.now().toISOString(); - const target = await resolveTargetRef(ctx, args.targetRef); const deployBefore = await readDeployJson(ctx.repoRoot); + const target = await resolveEffectiveTarget(ctx, args, deployBefore); const kubectlContext = await resolveKubectlContext(args, ctx.env, ctx.runCommand); const lockRead = await readDeployLockStatus({ ctx, args, kubectlContext }); const lock = summarizeLockStatus(lockRead, ctx.now()); @@ -1453,8 +1559,12 @@ async function runDevCdStatus(args, ctx, stdout) { ref: target.ref, promotionCommit: target.commitId, shortCommitId: target.shortCommitId, + promotionSource: target.promotionSource, + publishRequired: target.publishRequired, + controlRef: target.controlRef, headCommitId: target.headCommitId, headMatchesTarget: target.headMatchesTarget, + desiredStateCheck: target.desiredStateCheck, namespace: args.targetNamespace }, deployJson: { @@ -1595,6 +1705,7 @@ function buildReport({ "Legacy publish/apply side-effect scripts must run with HWLAB_CD_TRANSACTION_ID.", "DEV DB role/database provisioning and runtime migration must run through repo-owned commands before workload apply.", "deploy/deploy.json, artifact catalog, and workloads must converge before apply.", + "When deploy/deploy.json already points to a published promotion commit, HEAD may be a later control commit and the transaction must consume that promotion without republishing HEAD.", "Public 16666 and 16667 live health plus M3 durable postflight must be recorded in this report." ], summary: blockers.length ? "One or more DEV CD transaction preconditions failed." : "DEV CD transaction preconditions passed." @@ -1617,8 +1728,12 @@ function buildReport({ ref: target.ref, promotionCommit: target.commitId, shortCommitId: target.shortCommitId, + promotionSource: target.promotionSource, + publishRequired: target.publishRequired, + controlRef: target.controlRef, headCommitId: target.headCommitId, headMatchesTarget: target.headMatchesTarget, + desiredStateCheck: target.desiredStateCheck, namespace: args.targetNamespace, apiEndpoint: DEV_ENDPOINT, browserEndpoint: "http://74.48.78.17:16666/" @@ -1740,8 +1855,19 @@ export async function runDevCdApply(argv, io = {}) { let status = "blocked"; try { - target = await resolveTargetRef(ctx, args.targetRef); deployBefore = await readDeployJson(ctx.repoRoot); + target = await resolveEffectiveTarget(ctx, args, deployBefore); + if (target.desiredStateCheck?.status === "blocked") { + throw new DevCdApplyError("deploy desired-state does not match the target ref or a published deploy.json promotion", { + code: "desired-state-promotion-check-failed", + blockers: [{ + type: "contract_blocker", + scope: "desired-state-promotion", + status: "open", + summary: "deploy/deploy.json is not aligned with the target ref, and the deploy-json promotion check did not pass." + }] + }); + } const kubectlContext = await resolveKubectlContext(args, env, ctx.runCommand); transaction.kubectlContext = kubectlContext; liveBefore = { @@ -1779,7 +1905,30 @@ export async function runDevCdApply(argv, io = {}) { }); } - const plannedSteps = [ + const publishSteps = target.publishRequired === false + ? [ + { + id: "desired-state-check-existing-promotion", + phase: "publishing", + command: process.execPath, + args: [ + "scripts/deploy-desired-state-plan.mjs", + "--promotion-commit", + target.commitId, + "--check", + "--pretty" + ], + timeoutMs: 120000 + }, + { + id: "artifact-catalog-validate", + phase: "publishing", + command: process.execPath, + args: ["scripts/validate-artifact-catalog.mjs"], + timeoutMs: 120000 + } + ] + : [ { id: "desired-state-check-before-publish", phase: "publishing", @@ -1828,6 +1977,10 @@ export async function runDevCdApply(argv, io = {}) { ], timeoutMs: 120000 }, + ]; + + const plannedSteps = [ + ...publishSteps, { id: "runtime-db-provisioning", phase: "applying", diff --git a/scripts/src/dev-cd-apply.test.mjs b/scripts/src/dev-cd-apply.test.mjs index c346c78b..41c88e3b 100644 --- a/scripts/src/dev-cd-apply.test.mjs +++ b/scripts/src/dev-cd-apply.test.mjs @@ -47,7 +47,8 @@ function parsePatch(args) { return JSON.parse(args[index + 1]); } -async function makeRepo() { +async function makeRepo(options = {}) { + const commitId = options.commitId ?? "abc1234"; const repoRoot = await fsTempDir(); await mkdir(path.join(repoRoot, "deploy"), { recursive: true }); await writeFile( @@ -57,7 +58,7 @@ async function makeRepo() { environment: "dev", namespace: "hwlab-dev", endpoint: "http://74.48.78.17:16667", - commitId: "abc1234" + commitId }, null, 2)}\n` ); return repoRoot; @@ -98,7 +99,17 @@ function makeHttpGetJson() { }; } -function makeRunCommand({ heldLock = null, commandLog = [] } = {}) { +const publishedCommit = "abc1234abc1234abc1234abc1234abc1234abc1"; +const laterControlCommit = "def5678def5678def5678def5678def5678def5"; + +function makeRunCommand({ + heldLock = null, + commandLog = [], + targetCommitId = publishedCommit, + headCommitId = targetCommitId, + extraGitRefs = {}, + desiredStatePromotionStatus = "pass" +} = {}) { 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); @@ -106,10 +117,16 @@ function makeRunCommand({ heldLock = null, commandLog = [] } = {}) { return 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: "" }; + return { code: 0, stdout: `${targetCommitId}\n`, stderr: "" }; } if (command === "git" && args[0] === "rev-parse" && args.includes("HEAD^{commit}")) { - return { code: 0, stdout: "abc1234abc1234abc1234abc1234abc1234abc1\n", stderr: "" }; + return { code: 0, stdout: `${headCommitId}\n`, stderr: "" }; + } + if (command === "git" && args[0] === "rev-parse" && args[1] === "--verify") { + const ref = String(args[2] ?? "").replace(/\^\{commit\}$/u, ""); + const commitId = extraGitRefs[ref]; + if (commitId) return { code: 0, stdout: `${commitId}\n`, stderr: "" }; + return { code: 1, stdout: "", stderr: `fatal: ambiguous argument '${ref}'` }; } if (command === "which" && args[0] === "kubectl") { return { code: 0, stdout: "/usr/local/bin/kubectl\n", stderr: "" }; @@ -176,6 +193,30 @@ function makeRunCommand({ heldLock = null, commandLog = [] } = {}) { lease.metadata.resourceVersion = String(Number(lease.metadata.resourceVersion ?? "2") + 1); return { code: 0, stdout: `${JSON.stringify(lease)}\n`, stderr: "" }; } + if ((command === process.execPath || command.endsWith("/node")) && args.includes("scripts/deploy-desired-state-plan.mjs") && args.includes("--promotion-commit")) { + const promotionCommit = args[args.indexOf("--promotion-commit") + 1]; + const status = desiredStatePromotionStatus; + return { + code: status === "pass" ? 0 : 1, + stdout: `${JSON.stringify({ + status, + summary: { + desiredCommitId: promotionCommit.slice(0, 7), + artifactState: "published", + ciPublished: true, + registryVerified: true, + blockers: status === "pass" ? 0 : 1 + }, + target: { + convergence: { + state: status === "pass" ? "already_promoted" : "promotion_mismatch" + } + }, + diagnostics: status === "pass" ? [] : [{ code: "promotion_commit_mismatch", path: "desired-state", message: "fixture mismatch" }] + })}\n`, + stderr: "" + }; + } if (command === process.execPath || command.endsWith("/node")) { return { code: 0, stdout: JSON.stringify({ ok: true, command: args.join(" ") }), stderr: "" }; } @@ -278,6 +319,48 @@ test("dev-cd default status is read-only compact JSON", async () => { assertNoReadOnlySideEffects(commandLog); }); +test("dev-cd status accepts a published deploy-json promotion under a later control HEAD", async () => { + const repoRoot = await makeRepo({ commitId: "abc1234" }); + const commandLog = []; + let output = ""; + const code = await runDevCdApply(["--status", "--kubeconfig", "/tmp/kubeconfig"], { + repoRoot, + env: {}, + runCommand: makeRunCommand({ + commandLog, + targetCommitId: laterControlCommit, + headCommitId: laterControlCommit, + extraGitRefs: { + abc1234: publishedCommit + } + }), + httpGetJson: makeHttpGetJson(), + now: () => new Date(iso(100000)), + stdout: { write: (chunk) => { output += chunk; } } + }); + + const status = JSON.parse(output); + assert.equal(code, 0); + assert.equal(status.status, "pass"); + assert.equal(status.target.promotionCommit, publishedCommit); + assert.equal(status.target.shortCommitId, "abc1234"); + assert.equal(status.target.promotionSource, "deploy-json"); + assert.equal(status.target.publishRequired, false); + assert.equal(status.target.controlRef.shortCommitId, "def5678"); + assert.equal(status.target.headMatchesTarget, true); + assert.equal(status.target.desiredStateCheck.status, "pass"); + assert.equal(status.deployJson.matchesTarget, true); + assert.match(status.nextActions[0], /without republishing HEAD/u); + 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/deploy-desired-state-plan.mjs") && + entry.args.includes("--promotion-commit") && + entry.args.includes(publishedCommit) + ), true); + assertNoReadOnlySideEffects(commandLog); +}); + test("dev-cd status summarizes held, released, and stale locks without unsafe takeover hints", async () => { const cases = [ { @@ -618,6 +701,67 @@ test("transaction runs phases, allows internal side-effect env, releases lock, a assert.equal(writtenReport.devCdApply.liveVerify.summary.checked, 2); }); +test("transaction can apply an already published deploy-json promotion without republishing control HEAD", async () => { + const repoRoot = await makeRepo({ commitId: "abc1234" }); + const commandLog = []; + let output = ""; + const code = await runDevCdApply([ + "--apply", + "--confirm-dev", + "--confirmed-non-production", + "--owner-task-id", + "task-promotion", + "--kubeconfig", + "/tmp/kubeconfig", + "--write-report", + "--full-output" + ], { + repoRoot, + env: {}, + runCommand: makeRunCommand({ + commandLog, + targetCommitId: laterControlCommit, + headCommitId: laterControlCommit, + extraGitRefs: { + abc1234: publishedCommit + } + }), + httpGetJson: makeHttpGetJson(), + now: () => new Date(iso(100000)), + stdout: { write: (chunk) => { output += chunk; } } + }); + + const report = JSON.parse(output); + assert.equal(code, 0); + assert.equal(report.status, "pass"); + assert.equal(report.devCdApply.target.promotionCommit, publishedCommit); + assert.equal(report.devCdApply.target.promotionSource, "deploy-json"); + assert.equal(report.devCdApply.target.publishRequired, false); + assert.deepEqual( + report.devCdApply.steps.map((step) => step.id), + [ + "desired-state-check-existing-promotion", + "artifact-catalog-validate", + "runtime-db-provisioning", + "runtime-db-migration", + "dev-deploy-apply", + "runtime-durable-postflight" + ] + ); + assert.equal(commandLog.some((entry) => entry.args.includes("scripts/dev-artifact-publish.mjs")), false); + assert.equal(commandLog.some((entry) => entry.args.includes("scripts/refresh-artifact-catalog.mjs")), false); + assert.equal(commandLog.some((entry) => + entry.args.includes("scripts/deploy-desired-state-plan.mjs") && + entry.args.includes("--promotion-commit") && + entry.args.includes(publishedCommit) + ), true); + const applyCall = commandLog.find((entry) => entry.args.includes("scripts/dev-deploy-apply.mjs")); + assert.ok(applyCall?.env.HWLAB_CD_TRANSACTION_ID); + assert.equal(applyCall.env.HWLAB_CD_TRANSACTION_OWNER, "task-promotion"); + assert.equal(report.devCdApply.liveVerify.status, "pass"); + assert.equal(report.devCdApply.liveVerify.summary.commitMatches, 2); +}); + test("apply stdout is concise by default while write-report keeps the full report", async () => { const repoRoot = await makeRepo(); let output = "";