fix: apply deploy-json promotion in dev cd
This commit is contained in:
@@ -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 <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`、
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
Reference in New Issue
Block a user