From 42b68db978c3ba9ac8a6850aeebb1981aa1f1343 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 12 Jun 2026 19:14:02 +0000 Subject: [PATCH] fix: rebuild missing v03 env reuse images --- scripts/ci-plan.mjs | 4 +- scripts/ci-plan.test.mjs | 69 ++++++++++++++++++++++ scripts/gitops-render.mjs | 6 +- scripts/src/ci-plan-lib.mjs | 112 ++++++++++++++++++++++++++++++++++-- 4 files changed, 182 insertions(+), 9 deletions(-) diff --git a/scripts/ci-plan.mjs b/scripts/ci-plan.mjs index 523f6996..8070dc90 100644 --- a/scripts/ci-plan.mjs +++ b/scripts/ci-plan.mjs @@ -21,6 +21,8 @@ function parseArgs(argv) { else if (arg === "--artifact-catalog") parsed.artifactCatalogPath = readOption(argv, ++index, arg); else if (arg === "--lane") parsed.lane = readOption(argv, ++index, arg); else if (arg === "--registry-prefix") parsed.registryPrefix = readOption(argv, ++index, arg); + else if (arg === "--verify-reuse-registry") parsed.verifyReuseRegistry = true; + else if (arg === "--no-verify-reuse-registry") parsed.verifyReuseRegistry = false; else if (arg === "--base-image") parsed.baseImage = readOption(argv, ++index, arg); else if (arg === "--services") parsed.services = readOption(argv, ++index, arg).split(",").map((item) => item.trim()).filter(Boolean); else if (arg === "--pretty") parsed.pretty = true; @@ -38,7 +40,7 @@ function readOption(argv, index, name) { function usage() { return [ - "usage: node scripts/ci-plan.mjs [--lane v02|v03] [--base-ref REF] [--target-ref REF] [--pretty]", + "usage: node scripts/ci-plan.mjs [--lane v02|v03] [--base-ref REF] [--target-ref REF] [--verify-reuse-registry] [--pretty]", "", "Plan node monorepo image builds without mutating CI/CD state.", "For runtime lane env reuse, service component boundaries and env recipe must come from deploy/deploy.yaml.", diff --git a/scripts/ci-plan.test.mjs b/scripts/ci-plan.test.mjs index 619dab0b..dcb93ab6 100644 --- a/scripts/ci-plan.test.mjs +++ b/scripts/ci-plan.test.mjs @@ -132,6 +132,75 @@ test("v02 planner rebuilds env image when an env reuse catalog digest is missing assert.deepEqual(cloudApi.reason, ["environment-input-changed"]); }); +test("v03 planner rebuilds env image when catalog env input hash is stale", async () => { + const repo = await createFixtureRepo(); + const initialSha = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim(); + const initialPlan = await createCiPlan({ + repoRoot: repo, + lane: "v03", + baseRef: "HEAD", + targetRef: initialSha, + artifactCatalogPath: "deploy/artifact-catalog.v03.json", + services: ["hwlab-cloud-api"] + }); + const catalog = createCatalogFixture("ready", catalogOptionsFromPlan(initialPlan, { sourceCommitId: initialSha })); + catalog.services.find((service) => service.serviceId === "hwlab-cloud-api").environmentInputHash = "stale-environment-input"; + await writeFile(path.join(repo, "deploy/artifact-catalog.v03.json"), JSON.stringify(catalog, null, 2)); + await git(repo, ["add", "deploy/artifact-catalog.v03.json"]); + await git(repo, ["commit", "-m", "seed stale v03 env catalog"]); + + const plan = await createCiPlan({ + repoRoot: repo, + lane: "v03", + baseRef: "HEAD~1", + targetRef: "HEAD", + artifactCatalogPath: "deploy/artifact-catalog.v03.json", + services: ["hwlab-cloud-api"] + }); + const cloudApi = plan.services.find((service) => service.serviceId === "hwlab-cloud-api"); + assert.equal(cloudApi.buildRequired, true); + assert.equal(cloudApi.environmentInputChanged, true); + assert.deepEqual(cloudApi.reason, ["environment-input-mismatch"]); + assert.deepEqual(plan.buildServices, ["hwlab-cloud-api"]); +}); + +test("v03 planner rebuilds env image when target registry lacks reused digest", async () => { + const repo = await createFixtureRepo(); + const initialSha = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim(); + const initialPlan = await createCiPlan({ + repoRoot: repo, + lane: "v03", + baseRef: "HEAD", + targetRef: initialSha, + artifactCatalogPath: "deploy/artifact-catalog.v03.json", + services: ["hwlab-cloud-api"] + }); + await writeFile(path.join(repo, "deploy/artifact-catalog.v03.json"), JSON.stringify(createCatalogFixture("ready", catalogOptionsFromPlan(initialPlan, { sourceCommitId: initialSha })), null, 2)); + await git(repo, ["add", "deploy/artifact-catalog.v03.json"]); + await git(repo, ["commit", "-m", "seed fresh v03 catalog"]); + + await mkdir(path.join(repo, "deploy/gitops/node/d601/runtime-v03"), { recursive: true }); + await writeFile(path.join(repo, "deploy/gitops/node/d601/runtime-v03/workloads.yaml"), "kind: List\nitems: []\n"); + await git(repo, ["add", "."]); + await git(repo, ["commit", "-m", "change generated gitops after fresh catalog"]); + + const plan = await createCiPlan({ + repoRoot: repo, + lane: "v03", + baseRef: "HEAD~1", + targetRef: "HEAD", + artifactCatalogPath: "deploy/artifact-catalog.v03.json", + services: ["hwlab-cloud-api"], + verifyReuseRegistry: true, + reuseRegistryProbe: async () => ({ status: "missing", method: "HEAD", statusCode: 404 }) + }); + const cloudApi = plan.services.find((service) => service.serviceId === "hwlab-cloud-api"); + assert.equal(cloudApi.buildRequired, true); + assert.equal(cloudApi.reuseRegistry.status, "missing"); + assert.deepEqual(cloudApi.reason, ["reuse-registry-missing"]); + assert.deepEqual(plan.buildServices, ["hwlab-cloud-api"]); +}); + test("artifact reuse paths preserve catalog provenance instead of current planner provenance", async () => { const artifactPublish = await readFile("scripts/artifact-publish.mjs", "utf8"); assert.match(artifactPublish, /downloadDependencyScript/u); diff --git a/scripts/gitops-render.mjs b/scripts/gitops-render.mjs index 276eec66..7bf320da 100644 --- a/scripts/gitops-render.mjs +++ b/scripts/gitops-render.mjs @@ -1646,7 +1646,7 @@ echo '{"event":"ci-base-image","phase":"plan-artifacts","image":"${ciToolsRunner for tool in node git; do command -v "$tool" >/dev/null 2>&1 || { echo '{"event":"ci-base-image","phase":"plan-artifacts","ok":false,"reason":"missing-tool","tool":"'"$tool"'"}'; exit 31; }; done cd /workspace/source/repo test "$(git rev-parse HEAD)" = "$(params.revision)" -node scripts/ci-plan.mjs --lane "$(params.lane)" --target-ref HEAD --deploy-config deploy/deploy.yaml --artifact-catalog "$(params.catalog-path)" --registry-prefix "$(params.registry-prefix)" --services "$(params.services)" > /workspace/source/ci-plan.json +node scripts/ci-plan.mjs --lane "$(params.lane)" --target-ref HEAD --deploy-config deploy/deploy.yaml --artifact-catalog "$(params.catalog-path)" --registry-prefix "$(params.registry-prefix)" --services "$(params.services)" --verify-reuse-registry > /workspace/source/ci-plan.json node - <<'NODE' const fs = require("node:fs"); const plan = JSON.parse(fs.readFileSync("/workspace/source/ci-plan.json", "utf8")); @@ -1789,7 +1789,7 @@ if [ "$buildkit_ready" != "1" ]; then echo '{"event":"buildkit-sidecar-not-ready","serviceId":"'"$(params.service-id)"'","addr":"'"$HWLAB_BUILDKIT_ADDR"'"}' >&2 exit 1 fi -node scripts/artifact-publish.mjs --publish --lane "$(params.lane)" --catalog-path "$(params.catalog-path)" --deploy-config deploy/deploy.yaml --image-tag-mode "$(params.image-tag-mode)" --registry-prefix "$(params.registry-prefix)" --report "/workspace/source/service-results/$(params.service-id).json" --tekton-results-dir /tekton/results --quiet-build --concurrency 1 --services "$(params.service-id)" --buildkit-command /workspace/buildkit-bin/buildctl --buildkit-addr "$HWLAB_BUILDKIT_ADDR" +HWLAB_CI_PLAN_VERIFY_REUSE_REGISTRY=1 node scripts/artifact-publish.mjs --publish --lane "$(params.lane)" --catalog-path "$(params.catalog-path)" --deploy-config deploy/deploy.yaml --image-tag-mode "$(params.image-tag-mode)" --registry-prefix "$(params.registry-prefix)" --report "/workspace/source/service-results/$(params.service-id).json" --tekton-results-dir /tekton/results --quiet-build --concurrency 1 --services "$(params.service-id)" --buildkit-command /workspace/buildkit-bin/buildctl --buildkit-addr "$HWLAB_BUILDKIT_ADDR" `; } @@ -1809,7 +1809,7 @@ export HWLAB_ARTIFACT_LANE="$(params.lane)" export HWLAB_ARTIFACT_CATALOG_PATH="$(params.catalog-path)" export HWLAB_DEPLOY_MANIFEST_PATH="deploy/deploy.yaml" export HWLAB_ARTIFACT_IMAGE_TAG_MODE="$(params.image-tag-mode)" -node scripts/artifact-publish.mjs --publish --lane "$(params.lane)" --catalog-path "$(params.catalog-path)" --deploy-config deploy/deploy.yaml --image-tag-mode "$(params.image-tag-mode)" --registry-prefix "$(params.registry-prefix)" --report /workspace/source/dev-artifacts.json --quiet-build --concurrency 1 --services "$(params.services)" --external-service-report-dir /workspace/source/service-results +HWLAB_CI_PLAN_VERIFY_REUSE_REGISTRY=1 node scripts/artifact-publish.mjs --publish --lane "$(params.lane)" --catalog-path "$(params.catalog-path)" --deploy-config deploy/deploy.yaml --image-tag-mode "$(params.image-tag-mode)" --registry-prefix "$(params.registry-prefix)" --report /workspace/source/dev-artifacts.json --quiet-build --concurrency 1 --services "$(params.services)" --external-service-report-dir /workspace/source/service-results node scripts/refresh-artifact-catalog.mjs --lane "$(params.lane)" --catalog-path "$(params.catalog-path)" --deploy-config deploy/deploy.yaml --image-tag-mode "$(params.image-tag-mode)" --target-ref HEAD --publish-report /workspace/source/dev-artifacts.json --no-write `; } diff --git a/scripts/src/ci-plan-lib.mjs b/scripts/src/ci-plan-lib.mjs index 3bee4509..9d28d93b 100644 --- a/scripts/src/ci-plan-lib.mjs +++ b/scripts/src/ci-plan-lib.mjs @@ -1,5 +1,7 @@ import { createHash } from "node:crypto"; import { execFile } from "node:child_process"; +import * as http from "node:http"; +import * as https from "node:https"; import path from "node:path"; import { promisify } from "node:util"; @@ -54,6 +56,9 @@ export async function createCiPlan(options = {}) { const lane = options.lane ?? "v02"; const registryPrefix = options.registryPrefix ?? process.env.HWLAB_DEV_REGISTRY_PREFIX ?? process.env.HWLAB_DEV_REGISTRY ?? "127.0.0.1:5000/hwlab"; const baseImage = options.baseImage ?? process.env.HWLAB_DEV_BASE_IMAGE ?? "127.0.0.1:5000/hwlab/hwlab-node20-base:20-bookworm-slim"; + const verifyReuseRegistry = options.verifyReuseRegistry ?? booleanEnv(process.env.HWLAB_CI_PLAN_VERIFY_REUSE_REGISTRY); + const reuseRegistryProbe = options.reuseRegistryProbe ?? (verifyReuseRegistry ? probeRegistryManifest : null); + const registryProbeTimeoutMs = Number.parseInt(String(options.registryProbeTimeoutMs ?? process.env.HWLAB_CI_PLAN_REGISTRY_PROBE_TIMEOUT_MS ?? 3000), 10); const deployJson = await readStructuredFileIfPresent(repoRoot, deployConfigPath, {}); const laneConfig = laneDeployConfig(deployJson, lane); @@ -118,7 +123,18 @@ export async function createCiPlan(options = {}) { const environmentImage = envReuse ? environmentImageFromCatalog(model.serviceId, catalogRecord) : null; const environmentDigest = envReuse ? environmentDigestFromCatalog(catalogRecord) : null; const environmentReady = envReuse && /^sha256:[a-f0-9]{64}$/u.test(environmentDigest ?? "") && Boolean(environmentImage); - const envChanged = envReuse ? relevantEnvMatches.length > 0 || !environmentReady : null; + const environmentInputChanged = envReuse ? catalogRecord?.environmentInputHash !== environmentInputHash : null; + const reuseRegistry = envReuse && reuseRegistryProbe && environmentReady + ? await reuseRegistryProbe({ + serviceId: model.serviceId, + image: environmentImage, + digest: environmentDigest, + registryPrefix, + timeoutMs: Number.isFinite(registryProbeTimeoutMs) ? registryProbeTimeoutMs : 3000 + }) + : null; + const reuseRegistryUnavailable = Boolean(reuseRegistry && reuseRegistry.status !== "present"); + const envChanged = envReuse ? Boolean(relevantEnvMatches.length > 0 || !environmentReady || environmentInputChanged || reuseRegistryUnavailable) : null; const codeChanged = envReuse ? relevantCodeMatches.length > 0 || catalogRecord?.codeInputHash !== codeInputHash : null; const componentInputPaths = uniqueSorted([ ...model.componentPaths, @@ -197,7 +213,9 @@ export async function createCiPlan(options = {}) { runtimeMode: envReuse ? ENV_REUSE_RUNTIME_MODE : "service-image", envReuse, envChanged, + environmentInputChanged, codeChanged, + reuseRegistry, environmentInputHash, codeInputHash, bootRepo: envReuse ? canonicalBootRepo(deployJson, lane) : null, @@ -208,7 +226,7 @@ export async function createCiPlan(options = {}) { envChangedPaths: relevantEnvMatches, codeChangedPaths: relevantCodeMatches, reason: affected - ? reasonForService({ directMatches, sharedMatches, runtimeDepMatches, buildSystemMatches, catalogReuse, envReuse, envChanged, codeChanged }) + ? reasonForService({ directMatches, sharedMatches, runtimeDepMatches, buildSystemMatches, catalogReuse, envReuse, envChanged, environmentInputChanged, codeChanged, reuseRegistry }) : reasonForUnchanged(globalChange), reuse: catalogReuse }); @@ -243,7 +261,8 @@ export async function createCiPlan(options = {}) { inputs: { registryPrefix, baseImage, - serviceCount: services.length + serviceCount: services.length, + verifyReuseRegistry }, changedPaths: normalizedChangedPaths, changedPathSummary: globalChange, @@ -648,9 +667,11 @@ async function gitValue(repoRoot, args) { return result.stdout.trim(); } -function reasonForService({ directMatches, sharedMatches, runtimeDepMatches, buildSystemMatches, catalogReuse = null, envReuse = false, envChanged = null, codeChanged = null }) { +function reasonForService({ directMatches, sharedMatches, runtimeDepMatches, buildSystemMatches, catalogReuse = null, envReuse = false, envChanged = null, environmentInputChanged = null, codeChanged = null, reuseRegistry = null }) { const reasons = []; - if (envReuse && envChanged) reasons.push("environment-input-changed"); + if (envReuse && environmentInputChanged) reasons.push("environment-input-mismatch"); + if (envReuse && reuseRegistry && reuseRegistry.status !== "present") reasons.push("reuse-registry-missing"); + if (envReuse && envChanged && reasons.length === 0) reasons.push("environment-input-changed"); if (envReuse && codeChanged) reasons.push("code-input-changed"); if (directMatches.some((item) => !isTestOnlyPath(item) && !isDocsOnlyPath(item))) reasons.push("component-path-changed"); if (sharedMatches.some((item) => !isTestOnlyPath(item) && !isDocsOnlyPath(item))) reasons.push("shared-runtime-changed"); @@ -780,6 +801,87 @@ function digestFromImageReference(image) { return match ? match[1] : null; } +function booleanEnv(value) { + return ["1", "true", "yes", "on"].includes(String(value ?? "").trim().toLowerCase()); +} + +async function probeRegistryManifest({ image, digest, timeoutMs = 3000 }) { + const request = registryManifestRequest(image, digest); + if (!request) return { status: "invalid-reference", image, digest, reason: "registry-reference-unparseable" }; + const result = await registryManifestHttpProbe(request, "HEAD", timeoutMs); + if (result.status === "present" || result.status === "missing") return { ...result, image, digest }; + const fallback = await registryManifestHttpProbe(request, "GET", timeoutMs); + return { ...fallback, image, digest }; +} + +function registryManifestRequest(image, digest) { + const parsed = parseTaggedImage(image); + if (!parsed || !/^sha256:[a-f0-9]{64}$/u.test(digest ?? "")) return null; + const protocol = parsed.host === "127.0.0.1" || parsed.host.startsWith("127.") || parsed.host === "localhost" || parsed.host.includes(":5000") + ? "http:" + : "https:"; + return { + protocol, + host: parsed.host, + path: `/v2/${parsed.repository}/manifests/${digest}` + }; +} + +function parseTaggedImage(image) { + const text = String(image ?? "").trim(); + const slash = text.indexOf("/"); + if (slash <= 0) return null; + const host = text.slice(0, slash); + const rest = text.slice(slash + 1); + const tagSeparator = rest.lastIndexOf(":"); + if (!host || tagSeparator <= 0 || rest.includes("@")) return null; + return { + host, + repository: rest.slice(0, tagSeparator) + }; +} + +function registryManifestHttpProbe(request, method, timeoutMs) { + const client = request.protocol === "http:" ? http : https; + return new Promise((resolve) => { + const req = client.request({ + protocol: request.protocol, + host: request.host.includes(":") ? request.host.slice(0, request.host.lastIndexOf(":")) : request.host, + port: request.host.includes(":") ? request.host.slice(request.host.lastIndexOf(":") + 1) : undefined, + method, + path: request.path, + timeout: timeoutMs, + headers: { + Accept: [ + "application/vnd.oci.image.manifest.v1+json", + "application/vnd.docker.distribution.manifest.v2+json", + "application/vnd.oci.image.index.v1+json", + "application/vnd.docker.distribution.manifest.list.v2+json" + ].join(", ") + } + }, (res) => { + res.resume(); + res.on("end", () => { + if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) { + resolve({ status: "present", method, statusCode: res.statusCode }); + } else if (res.statusCode === 404) { + resolve({ status: "missing", method, statusCode: res.statusCode }); + } else { + resolve({ status: "unknown", method, statusCode: res.statusCode ?? 0, reason: "unexpected-status" }); + } + }); + }); + req.on("timeout", () => { + req.destroy(); + resolve({ status: "unknown", method, statusCode: 0, reason: "timeout" }); + }); + req.on("error", (error) => { + resolve({ status: "unknown", method, statusCode: 0, reason: error.message }); + }); + req.end(); + }); +} + function stableHash(value) { return createHash("sha256").update(stableJson(value)).digest("hex"); }