fix: resolve dev cd artifact promotion boundary
This commit is contained in:
@@ -50,7 +50,8 @@ function parsePatch(args) {
|
||||
async function makeRepo(options = {}) {
|
||||
const commitId = options.commitId ?? "abc1234";
|
||||
const repoRoot = await fsTempDir();
|
||||
await mkdir(path.join(repoRoot, "deploy"), { recursive: true });
|
||||
await mkdir(path.join(repoRoot, "deploy/k8s/base"), { recursive: true });
|
||||
await mkdir(path.join(repoRoot, "reports/dev-gate"), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(repoRoot, "deploy/deploy.json"),
|
||||
`${JSON.stringify({
|
||||
@@ -58,9 +59,103 @@ async function makeRepo(options = {}) {
|
||||
environment: "dev",
|
||||
namespace: "hwlab-dev",
|
||||
endpoint: "http://74.48.78.17:16667",
|
||||
commitId
|
||||
commitId,
|
||||
services: [
|
||||
{
|
||||
serviceId: "hwlab-cloud-api",
|
||||
image: `127.0.0.1:5000/hwlab/hwlab-cloud-api:${commitId.slice(0, 7)}`,
|
||||
namespace: "hwlab-dev",
|
||||
profile: "dev",
|
||||
env: { HWLAB_COMMIT_ID: commitId.slice(0, 7) }
|
||||
},
|
||||
{
|
||||
serviceId: "hwlab-cloud-web",
|
||||
image: `127.0.0.1:5000/hwlab/hwlab-cloud-web:${commitId.slice(0, 7)}`,
|
||||
namespace: "hwlab-dev",
|
||||
profile: "dev",
|
||||
env: { HWLAB_COMMIT_ID: commitId.slice(0, 7) }
|
||||
}
|
||||
]
|
||||
}, null, 2)}\n`
|
||||
);
|
||||
await writeFile(
|
||||
path.join(repoRoot, "deploy/artifact-catalog.dev.json"),
|
||||
`${JSON.stringify({
|
||||
catalogVersion: "v1",
|
||||
kind: "hwlab-artifact-catalog",
|
||||
environment: "dev",
|
||||
profile: "dev",
|
||||
namespace: "hwlab-dev",
|
||||
endpoint: "http://74.48.78.17:16667",
|
||||
commitId,
|
||||
artifactState: "published",
|
||||
publish: {
|
||||
ciPublished: true,
|
||||
registryVerified: true,
|
||||
provenance: "reports/dev-gate/dev-artifacts.json"
|
||||
},
|
||||
services: [
|
||||
{
|
||||
serviceId: "hwlab-cloud-api",
|
||||
commitId,
|
||||
image: `127.0.0.1:5000/hwlab/hwlab-cloud-api:${commitId.slice(0, 7)}`,
|
||||
imageTag: commitId.slice(0, 7),
|
||||
digest: "sha256:b3713aa07164a9ff79a436bd583552427de42922835a5baff50484f53d98f296",
|
||||
publishState: "published",
|
||||
profile: "dev",
|
||||
namespace: "hwlab-dev",
|
||||
artifactRequired: true
|
||||
},
|
||||
{
|
||||
serviceId: "hwlab-cloud-web",
|
||||
commitId,
|
||||
image: `127.0.0.1:5000/hwlab/hwlab-cloud-web:${commitId.slice(0, 7)}`,
|
||||
imageTag: commitId.slice(0, 7),
|
||||
digest: "sha256:3be80fcfa769d68b0a7ec44e9215b93546d01bf23411533c5cb4888438f2bd84",
|
||||
publishState: "published",
|
||||
profile: "dev",
|
||||
namespace: "hwlab-dev",
|
||||
artifactRequired: true
|
||||
}
|
||||
]
|
||||
}, null, 2)}\n`
|
||||
);
|
||||
await writeFile(
|
||||
path.join(repoRoot, "reports/dev-gate/dev-artifacts.json"),
|
||||
`${JSON.stringify({
|
||||
reportVersion: "v1",
|
||||
taskId: "dev-artifact-publish",
|
||||
status: "published",
|
||||
commitId,
|
||||
artifactPublish: {
|
||||
status: "published",
|
||||
mode: "publish",
|
||||
sourceCommitId: commitId,
|
||||
registryPrefix: "127.0.0.1:5000/hwlab",
|
||||
serviceCount: 2,
|
||||
requiredServiceCount: 2,
|
||||
publishedCount: 2,
|
||||
generatedAt: iso(),
|
||||
services: [
|
||||
{
|
||||
serviceId: "hwlab-cloud-api",
|
||||
status: "published",
|
||||
artifactRequired: true,
|
||||
image: `127.0.0.1:5000/hwlab/hwlab-cloud-api:${commitId.slice(0, 7)}`,
|
||||
digest: "sha256:b3713aa07164a9ff79a436bd583552427de42922835a5baff50484f53d98f296"
|
||||
},
|
||||
{
|
||||
serviceId: "hwlab-cloud-web",
|
||||
status: "published",
|
||||
artifactRequired: true,
|
||||
image: `127.0.0.1:5000/hwlab/hwlab-cloud-web:${commitId.slice(0, 7)}`,
|
||||
digest: "sha256:3be80fcfa769d68b0a7ec44e9215b93546d01bf23411533c5cb4888438f2bd84"
|
||||
}
|
||||
]
|
||||
}
|
||||
}, null, 2)}\n`
|
||||
);
|
||||
await writeFile(path.join(repoRoot, "deploy/k8s/base/workloads.yaml"), `# fixture ${commitId}\n`);
|
||||
return repoRoot;
|
||||
}
|
||||
|
||||
@@ -101,6 +196,8 @@ function makeHttpGetJson() {
|
||||
|
||||
const publishedCommit = "abc1234abc1234abc1234abc1234abc1234abc1";
|
||||
const laterControlCommit = "def5678def5678def5678def5678def5678def5";
|
||||
const artifactMergeCommit = "fedcba9fedcba9fedcba9fedcba9fedcba9fedc";
|
||||
const artifactReportCommit = "a77efa1a77efa1a77efa1a77efa1a77efa1a77e";
|
||||
|
||||
function makeRunCommand({
|
||||
heldLock = null,
|
||||
@@ -108,6 +205,14 @@ function makeRunCommand({
|
||||
targetCommitId = publishedCommit,
|
||||
headCommitId = targetCommitId,
|
||||
extraGitRefs = {},
|
||||
commitParents = {},
|
||||
latestArtifactMergeCommit = null,
|
||||
changedPaths = [
|
||||
"deploy/artifact-catalog.dev.json",
|
||||
"deploy/deploy.json",
|
||||
"deploy/k8s/base/workloads.yaml",
|
||||
"reports/dev-gate/dev-artifacts.json"
|
||||
],
|
||||
desiredStatePromotionStatus = "pass",
|
||||
runtimeJobLogSuffix = ""
|
||||
} = {}) {
|
||||
@@ -120,6 +225,11 @@ function makeRunCommand({
|
||||
if (command === "git" && args[0] === "rev-parse" && args.includes("origin/main^{commit}")) {
|
||||
return { code: 0, stdout: `${targetCommitId}\n`, stderr: "" };
|
||||
}
|
||||
if (command === "git" && args[0] === "rev-parse" && args.includes("--short=7")) {
|
||||
const ref = String(args.at(-1) ?? "");
|
||||
const commitId = extraGitRefs[ref] ?? ref;
|
||||
return { code: 0, stdout: `${commitId.slice(0, 7)}\n`, stderr: "" };
|
||||
}
|
||||
if (command === "git" && args[0] === "rev-parse" && args.includes("HEAD^{commit}")) {
|
||||
return { code: 0, stdout: `${headCommitId}\n`, stderr: "" };
|
||||
}
|
||||
@@ -129,6 +239,19 @@ function makeRunCommand({
|
||||
if (commitId) return { code: 0, stdout: `${commitId}\n`, stderr: "" };
|
||||
return { code: 1, stdout: "", stderr: `fatal: ambiguous argument '${ref}'` };
|
||||
}
|
||||
if (command === "git" && args[0] === "log" && args.includes("--first-parent")) {
|
||||
return latestArtifactMergeCommit
|
||||
? { code: 0, stdout: `${latestArtifactMergeCommit}\n`, stderr: "" }
|
||||
: { code: 1, stdout: "", stderr: "no matching commits" };
|
||||
}
|
||||
if (command === "git" && args[0] === "rev-list" && args.includes("--parents")) {
|
||||
const ref = String(args.at(-1) ?? "");
|
||||
const parents = commitParents[ref] ?? [];
|
||||
return { code: 0, stdout: `${[ref, ...parents].join(" ")}\n`, stderr: "" };
|
||||
}
|
||||
if (command === "git" && args[0] === "diff-tree") {
|
||||
return { code: 0, stdout: `${changedPaths.join("\n")}\n`, stderr: "" };
|
||||
}
|
||||
if (command === "which" && args[0] === "kubectl") {
|
||||
return { code: 0, stdout: "/usr/local/bin/kubectl\n", stderr: "" };
|
||||
}
|
||||
@@ -333,6 +456,11 @@ test("dev-cd status accepts a published deploy-json promotion under a later cont
|
||||
headCommitId: laterControlCommit,
|
||||
extraGitRefs: {
|
||||
abc1234: publishedCommit
|
||||
},
|
||||
latestArtifactMergeCommit: artifactMergeCommit,
|
||||
commitParents: {
|
||||
[artifactMergeCommit]: [publishedCommit, artifactReportCommit],
|
||||
[artifactReportCommit]: [publishedCommit]
|
||||
}
|
||||
}),
|
||||
httpGetJson: makeHttpGetJson(),
|
||||
@@ -348,6 +476,16 @@ test("dev-cd status accepts a published deploy-json promotion under a later cont
|
||||
assert.equal(status.target.promotionSource, "deploy-json");
|
||||
assert.equal(status.target.publishRequired, false);
|
||||
assert.equal(status.target.controlRef.shortCommitId, "def5678");
|
||||
assert.equal(status.target.artifactBoundary.status, "pass");
|
||||
assert.equal(status.target.artifactBoundary.artifactMergeCommit.shortCommitId, "fedcba9");
|
||||
assert.equal(status.target.artifactBoundary.artifactMergeCommit.promotionParentMatches, true);
|
||||
assert.equal(status.target.artifactBoundary.artifactCommit.shortCommitId, "a77efa1");
|
||||
assert.equal(status.target.artifactBoundary.artifactCommit.promotionParentMatches, true);
|
||||
assert.equal(status.artifactCatalog.commitId, "abc1234");
|
||||
assert.equal(status.artifactCatalog.digestCounts.sha256, 2);
|
||||
assert.equal(status.artifactReport.sourceCommitId, "abc1234");
|
||||
assert.equal(status.liveDelta.status, "pass");
|
||||
assert.equal(status.liveDelta.expectedCommit, "abc1234");
|
||||
assert.equal(status.target.headMatchesTarget, true);
|
||||
assert.equal(status.target.desiredStateCheck.status, "pass");
|
||||
assert.equal(status.deployJson.matchesTarget, true);
|
||||
@@ -362,6 +500,188 @@ test("dev-cd status accepts a published deploy-json promotion under a later cont
|
||||
assertNoReadOnlySideEffects(commandLog);
|
||||
});
|
||||
|
||||
test("dev-cd status exposes moving origin/main when checkout is behind a deploy-json promotion control ref", 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: publishedCommit,
|
||||
extraGitRefs: {
|
||||
abc1234: publishedCommit
|
||||
},
|
||||
latestArtifactMergeCommit: artifactMergeCommit,
|
||||
commitParents: {
|
||||
[artifactMergeCommit]: [publishedCommit, artifactReportCommit],
|
||||
[artifactReportCommit]: [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, "degraded");
|
||||
assert.equal(status.target.promotionCommit, publishedCommit);
|
||||
assert.equal(status.target.controlRef.shortCommitId, "def5678");
|
||||
assert.equal(status.target.headCommitId, publishedCommit);
|
||||
assert.equal(status.target.headMatchesTarget, false);
|
||||
assert.match(status.nextActions.join("\n"), /Checkout or fast-forward/u);
|
||||
assertNoReadOnlySideEffects(commandLog);
|
||||
});
|
||||
|
||||
test("apply stops before lock acquisition when checkout is behind deploy-json promotion control ref", 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-moving-main",
|
||||
"--kubeconfig",
|
||||
"/tmp/kubeconfig"
|
||||
], {
|
||||
repoRoot,
|
||||
env: {},
|
||||
runCommand: makeRunCommand({
|
||||
commandLog,
|
||||
targetCommitId: laterControlCommit,
|
||||
headCommitId: publishedCommit,
|
||||
extraGitRefs: {
|
||||
abc1234: publishedCommit
|
||||
},
|
||||
latestArtifactMergeCommit: artifactMergeCommit,
|
||||
commitParents: {
|
||||
[artifactMergeCommit]: [publishedCommit, artifactReportCommit],
|
||||
[artifactReportCommit]: [publishedCommit]
|
||||
}
|
||||
}),
|
||||
httpGetJson: makeHttpGetJson(),
|
||||
now: () => new Date(iso(100000)),
|
||||
stdout: { write: (chunk) => { output += chunk; } }
|
||||
});
|
||||
|
||||
const summary = JSON.parse(output);
|
||||
assert.equal(code, 2);
|
||||
assert.equal(summary.status, "blocked");
|
||||
assert.equal(summary.target.controlRef.shortCommitId, "def5678");
|
||||
assert.equal(summary.blockers.some((blocker) => blocker.scope === "target-ref-head"), true);
|
||||
assert.equal(commandLog.some((entry) => entry.command.includes("kubectl")), 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);
|
||||
});
|
||||
|
||||
test("dev-cd status degrades artifact merge provenance when catalog/report do not match deploy-json promotion", async () => {
|
||||
const repoRoot = await makeRepo({ commitId: "abc1234" });
|
||||
await writeFile(
|
||||
path.join(repoRoot, "deploy/artifact-catalog.dev.json"),
|
||||
`${JSON.stringify({
|
||||
kind: "hwlab-artifact-catalog",
|
||||
commitId: "9999999",
|
||||
artifactState: "published",
|
||||
publish: { ciPublished: true, registryVerified: true, provenance: "reports/dev-gate/dev-artifacts.json" },
|
||||
services: []
|
||||
}, null, 2)}\n`
|
||||
);
|
||||
const commandLog = [];
|
||||
let output = "";
|
||||
const code = await runDevCdApply(["--status", "--kubeconfig", "/tmp/kubeconfig"], {
|
||||
repoRoot,
|
||||
env: {},
|
||||
runCommand: makeRunCommand({
|
||||
commandLog,
|
||||
targetCommitId: laterControlCommit,
|
||||
headCommitId: laterControlCommit,
|
||||
extraGitRefs: {
|
||||
abc1234: publishedCommit
|
||||
},
|
||||
latestArtifactMergeCommit: artifactMergeCommit,
|
||||
commitParents: {
|
||||
[artifactMergeCommit]: [publishedCommit, artifactReportCommit],
|
||||
[artifactReportCommit]: [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, "degraded");
|
||||
assert.equal(status.target.promotionCommit, publishedCommit);
|
||||
assert.equal(status.target.artifactBoundary.status, "degraded");
|
||||
assert.equal(status.target.artifactBoundary.desiredState.deployCommitMatches, true);
|
||||
assert.equal(status.target.artifactBoundary.desiredState.catalogCommitMatches, false);
|
||||
assert.equal(status.target.artifactBoundary.desiredState.reportCommitMatches, true);
|
||||
assertNoReadOnlySideEffects(commandLog);
|
||||
});
|
||||
|
||||
test("apply stops before lock acquisition when artifact merge provenance mismatches deploy-json promotion", async () => {
|
||||
const repoRoot = await makeRepo({ commitId: "abc1234" });
|
||||
await writeFile(
|
||||
path.join(repoRoot, "reports/dev-gate/dev-artifacts.json"),
|
||||
`${JSON.stringify({
|
||||
status: "published",
|
||||
commitId: "9999999",
|
||||
artifactPublish: {
|
||||
status: "published",
|
||||
sourceCommitId: "9999999",
|
||||
serviceCount: 0,
|
||||
requiredServiceCount: 0,
|
||||
publishedCount: 0,
|
||||
services: []
|
||||
}
|
||||
}, null, 2)}\n`
|
||||
);
|
||||
const commandLog = [];
|
||||
let output = "";
|
||||
const code = await runDevCdApply([
|
||||
"--apply",
|
||||
"--confirm-dev",
|
||||
"--confirmed-non-production",
|
||||
"--owner-task-id",
|
||||
"task-provenance",
|
||||
"--kubeconfig",
|
||||
"/tmp/kubeconfig"
|
||||
], {
|
||||
repoRoot,
|
||||
env: {},
|
||||
runCommand: makeRunCommand({
|
||||
commandLog,
|
||||
targetCommitId: laterControlCommit,
|
||||
headCommitId: laterControlCommit,
|
||||
extraGitRefs: {
|
||||
abc1234: publishedCommit
|
||||
},
|
||||
latestArtifactMergeCommit: artifactMergeCommit,
|
||||
commitParents: {
|
||||
[artifactMergeCommit]: [publishedCommit, artifactReportCommit],
|
||||
[artifactReportCommit]: [publishedCommit]
|
||||
}
|
||||
}),
|
||||
httpGetJson: makeHttpGetJson(),
|
||||
now: () => new Date(iso(100000)),
|
||||
stdout: { write: (chunk) => { output += chunk; } }
|
||||
});
|
||||
|
||||
const summary = JSON.parse(output);
|
||||
assert.equal(code, 2);
|
||||
assert.equal(summary.status, "blocked");
|
||||
assert.equal(summary.blockers.some((blocker) => blocker.scope === "artifact-boundary"), true);
|
||||
assert.equal(commandLog.some((entry) => entry.command.includes("kubectl")), 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);
|
||||
});
|
||||
|
||||
test("dev-cd status summarizes held, released, and stale locks without unsafe takeover hints", async () => {
|
||||
const cases = [
|
||||
{
|
||||
@@ -444,9 +764,22 @@ test("dev-cd status reports unavailable kubectl with redacted reason", async ()
|
||||
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("--short=7")) {
|
||||
return { code: 0, stdout: `${String(args.at(-1)).slice(0, 7)}\n`, stderr: "" };
|
||||
}
|
||||
if (command === "git" && args[0] === "rev-parse" && args.includes("HEAD^{commit}")) {
|
||||
return { code: 0, stdout: "abc1234abc1234abc1234abc1234abc1234abc1\n", stderr: "" };
|
||||
}
|
||||
if (command === "git" && args[0] === "log" && args.includes("--first-parent")) {
|
||||
return { code: 1, stdout: "", stderr: "no matching commits" };
|
||||
}
|
||||
if (command === "git" && args[0] === "rev-list" && args.includes("--parents")) {
|
||||
const ref = String(args.at(-1) ?? "");
|
||||
return { code: 0, stdout: `${ref}\n`, stderr: "" };
|
||||
}
|
||||
if (command === "git" && args[0] === "diff-tree") {
|
||||
return { code: 0, stdout: "", stderr: "" };
|
||||
}
|
||||
if (command === "which" && args[0] === "kubectl") {
|
||||
return { code: 1, stdout: "", stderr: "kubectl missing" };
|
||||
}
|
||||
@@ -770,6 +1103,11 @@ test("transaction can apply an already published deploy-json promotion without r
|
||||
headCommitId: laterControlCommit,
|
||||
extraGitRefs: {
|
||||
abc1234: publishedCommit
|
||||
},
|
||||
latestArtifactMergeCommit: artifactMergeCommit,
|
||||
commitParents: {
|
||||
[artifactMergeCommit]: [publishedCommit, artifactReportCommit],
|
||||
[artifactReportCommit]: [publishedCommit]
|
||||
}
|
||||
}),
|
||||
httpGetJson: makeHttpGetJson(),
|
||||
@@ -783,6 +1121,8 @@ test("transaction can apply an already published deploy-json promotion without r
|
||||
assert.equal(report.devCdApply.target.promotionCommit, publishedCommit);
|
||||
assert.equal(report.devCdApply.target.promotionSource, "deploy-json");
|
||||
assert.equal(report.devCdApply.target.publishRequired, false);
|
||||
assert.equal(report.devCdApply.target.artifactBoundary.status, "pass");
|
||||
assert.equal(report.devCdApply.target.artifactBoundary.artifactMergeCommit.shortCommitId, "fedcba9");
|
||||
assert.deepEqual(
|
||||
report.devCdApply.steps.map((step) => step.id),
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user