fix: 防止 artifact 复用污染组件证明
This commit is contained in:
@@ -79,7 +79,8 @@ HWLAB 是 monorepo,G14 CI/CD 加速必须按组件输入判断构建和滚动
|
||||
- 服务清单兼容顺序固定为:显式 `--services`、`deploy.services[]`、`deploy.k3s.serviceMappings[]`、`internal/protocol.SERVICE_IDS`。因此旧 `deploy/deploy.json` 形态和当前完整 `deploy.services[]` 形态都必须能被 planner 识别。
|
||||
- 组件边界固定由 `scripts/src/g14-ci-plan-lib.mjs` 的内建 service-path model 定义;如需新增或调整 `componentPaths`、`sharedPaths`、`runtimeDeps` 或 `buildSystemPaths`,直接修改 planner 库和对应测试,不再额外维护 repo-local commands/forbidden skeleton。
|
||||
- `hwpod`/`device-pod-cli` 是 runner 和 runtime 镜像内的稳定工具入口,不是纯本地 `hwlab-cli` 源码工具;`tools/device-pod-cli.mjs`、`tools/device-pod-cli.ts`、`tools/src/device-pod-cli-lib.ts` 和 `skills/device-pod-cli/` 必须作为 shared runtime input 进入 component model。修改这些路径时,G14/v0.2 CI 必须至少触发携带 `/usr/local/bin/hwpod` 的 runtime 服务重新构建或 env-reuse rollout,不能全量复用旧 artifact 后只报告 PipelineRun 成功。
|
||||
- `scripts/g14-artifact-publish.mjs` 默认启用组件级 lazy build:先运行 planner,再只构建/推送 `affectedServices`,`reusedServices` 从 `deploy/artifact-catalog.dev.json` 或 lane catalog 复用已有 sha256 digest;复用前必须同时满足 catalog 有可验证 digest 且非 env-reuse 服务的 `componentInputHash` 等于本轮 planner 计算值。catalog 缺 digest 或 component input hash 不一致时,planner 必须把该服务列为 affected 并重新发布,不能用旧 guard 阻塞,也不能退回 Docker 或 legacy full-build 路线。
|
||||
- `scripts/g14-artifact-publish.mjs` 默认启用组件级 lazy build:先运行 planner,再只构建/推送 `affectedServices`,`reusedServices` 从 `deploy/artifact-catalog.dev.json` 或 lane catalog 复用已有 sha256 digest。非 env-reuse 服务复用前必须满足 artifact provenance 自证:catalog 有可验证 digest、catalog 的 `sourceCommitId` 在当前 repo 可解析、用该 `sourceCommitId` 的 source tree 重新计算出的 `componentInputHash` 等于 catalog 记录、该 hash 再等于本轮 planner 计算值,且 `dockerfileHash`/`buildArgsHash` 没有不一致。catalog 缺 digest、缺 provenance、source tree 无法解析、catalog hash 与 catalog source tree 不一致、或 catalog hash 与本轮 input 不一致时,planner 必须把该服务列为 affected 并重新发布,不能用旧 guard 阻塞,也不能退回 Docker 或 legacy full-build 路线。
|
||||
- Artifact catalog 的 per-service provenance 是镜像 digest 的身份证明,不是本轮 planner 状态缓存。reuse 路径只能保留旧 artifact 自身的 `sourceCommitId`、`componentCommitId`、`componentInputHash`、`dockerfileHash`、`baseImage*` 和 `buildArgsHash`;禁止把当前 planner 的 component/build 元数据写入复用的旧 digest,否则下一轮 planner 会把旧镜像误判成已包含新输入,造成 CI/CD false-green。
|
||||
- `scripts/g14-artifact-publish.mjs` 的 publish report 必须携带 planner 的 per-service 元数据;`scripts/refresh-artifact-catalog.mjs` 默认只预览,只有 G14 Tekton promotion 显式传 `--write` 时,才把生成的 `commitId`、`image`、`imageTag`、`digest`、`publishState` 和 component provenance 字段写进当前 workspace 的 `deploy/artifact-catalog.dev.json`,随后只提交到 `G14-gitops`。`deploy/deploy.json` 是人写的 runtime config 真相源,不得被 promotion、refresh 脚本或人工发布流程回写镜像身份字段。
|
||||
- `scripts/g14-gitops-render.mjs` 只支持混合 desired state:workload 的 container image、`HWLAB_IMAGE`、`HWLAB_IMAGE_TAG` 和 pod template `source-commit` 来自 `deploy/artifact-catalog.dev.json` 的 per-service artifact identity;普通 env、replica、healthPath、profile 等配置来自 `deploy/deploy.json`。全局 GitOps metadata 仍记录本次 source commit。`--legacy-source-images` 与 `HWLAB_G14_USE_DEPLOY_IMAGES=0` 已废弃,不得把所有 workload image 回退渲染为同一个 source commit tag。
|
||||
- G14 Tekton promotion 在推送 `G14-gitops` 前,必须用 publish report 显式 `--write` 刷新 workspace 内的 `deploy/artifact-catalog.dev.json`,然后把刷新后的 catalog 和 rendered GitOps desired state 一起提交到 `G14-gitops`。promotion 不得自动修改或推送 `G14` source branch,也不得自动修改 `deploy/deploy.json` 或 `deploy/k8s/base/workloads.yaml`,这样人写配置不会和 CI 生成身份反复冲突。
|
||||
|
||||
@@ -1481,6 +1481,7 @@ function artifactRecordForCatalogReuse({ service, catalogService, commitId }) {
|
||||
const digest = catalogService?.digest ?? "not_published";
|
||||
const imageTag = catalogService?.imageTag ?? imageTagFromReference(image);
|
||||
const catalogCommitId = catalogService?.commitId ?? imageTag ?? null;
|
||||
const catalogProvenance = serviceImageCatalogProvenance(catalogService);
|
||||
if (!image || !imageTag || !shaDigestPattern.test(digest)) {
|
||||
return {
|
||||
...service,
|
||||
@@ -1491,9 +1492,10 @@ function artifactRecordForCatalogReuse({ service, catalogService, commitId }) {
|
||||
imageTag,
|
||||
buildCreatedAt: catalogService?.buildCreatedAt ?? null,
|
||||
buildSource: catalogService?.buildSource ?? null,
|
||||
buildBackend: "reused-catalog",
|
||||
digest,
|
||||
repositoryDigest: repositoryDigestFor(image, digest),
|
||||
...catalogProvenance,
|
||||
buildBackend: "reused-catalog",
|
||||
reusedFrom: null,
|
||||
notPublishedReason: "reuse_catalog_digest_unavailable"
|
||||
};
|
||||
@@ -1509,11 +1511,24 @@ function artifactRecordForCatalogReuse({ service, catalogService, commitId }) {
|
||||
buildSource: catalogService?.buildSource ?? null,
|
||||
digest,
|
||||
repositoryDigest: catalogService?.repositoryDigest ?? repositoryDigestFor(image, digest),
|
||||
...catalogProvenance,
|
||||
buildBackend: "reused-catalog",
|
||||
reusedFrom: service.reuse?.reusedFrom ?? catalogService?.componentInputHash ?? catalogCommitId,
|
||||
notPublishedReason: null
|
||||
};
|
||||
}
|
||||
|
||||
function serviceImageCatalogProvenance(catalogService) {
|
||||
return {
|
||||
componentCommitId: catalogService?.componentCommitId ?? null,
|
||||
componentInputHash: catalogService?.componentInputHash ?? null,
|
||||
dockerfileHash: catalogService?.dockerfileHash ?? null,
|
||||
baseImageReference: catalogService?.baseImageReference ?? null,
|
||||
baseImageDigest: catalogService?.baseImageDigest ?? null,
|
||||
buildArgsHash: catalogService?.buildArgsHash ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function artifactRecordForEnvReuseCatalogReuse({ service, catalogService, commitId }) {
|
||||
const environmentImage = catalogService?.environmentImage ?? service.environmentImage ?? null;
|
||||
const environmentDigest = catalogService?.environmentDigest ?? service.environmentDigest ?? "not_published";
|
||||
@@ -1651,6 +1666,7 @@ function artifactRecordFromServiceResultsEnv({ service, env }) {
|
||||
const image = serviceResultValue(env, service.serviceId, "IMAGE");
|
||||
const digest = serviceResultValue(env, service.serviceId, "DIGEST") ?? "not_published";
|
||||
const imageTag = serviceResultValue(env, service.serviceId, "IMAGE_TAG") ?? imageTagFromReference(image);
|
||||
const reusedServiceImage = status === "reused" && service.envReuse !== true;
|
||||
const artifact = {
|
||||
...service,
|
||||
serviceId: service.serviceId,
|
||||
@@ -1662,7 +1678,7 @@ function artifactRecordFromServiceResultsEnv({ service, env }) {
|
||||
sourceCommitId: serviceResultValue(env, service.serviceId, "SOURCE_COMMIT_ID"),
|
||||
commitId: imageTag,
|
||||
buildCreatedAt: serviceResultValue(env, service.serviceId, "BUILD_CREATED_AT"),
|
||||
componentInputHash: serviceResultValue(env, service.serviceId, "COMPONENT_INPUT_HASH") ?? service.componentInputHash ?? null,
|
||||
componentInputHash: serviceResultValue(env, service.serviceId, "COMPONENT_INPUT_HASH") ?? (reusedServiceImage ? null : service.componentInputHash ?? null),
|
||||
environmentInputHash: serviceResultValue(env, service.serviceId, "ENVIRONMENT_INPUT_HASH") ?? service.environmentInputHash ?? null,
|
||||
codeInputHash: serviceResultValue(env, service.serviceId, "CODE_INPUT_HASH") ?? service.codeInputHash ?? null,
|
||||
runtimeMode: serviceResultValue(env, service.serviceId, "RUNTIME_MODE") ?? service.runtimeMode ?? "service-image",
|
||||
@@ -1671,6 +1687,13 @@ function artifactRecordFromServiceResultsEnv({ service, env }) {
|
||||
bootSh: serviceResultValue(env, service.serviceId, "BOOT_SH") ?? service.bootSh ?? null,
|
||||
reusedFrom: serviceResultValue(env, service.serviceId, "REUSED_FROM"),
|
||||
buildBackend: serviceResultValue(env, service.serviceId, "BUILD_BACKEND"),
|
||||
...(reusedServiceImage ? {
|
||||
componentCommitId: null,
|
||||
dockerfileHash: null,
|
||||
baseImageReference: null,
|
||||
baseImageDigest: null,
|
||||
buildArgsHash: null
|
||||
} : {}),
|
||||
notPublishedReason: null
|
||||
};
|
||||
if (service.envReuse) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
@@ -69,6 +69,7 @@ test("planner rebuilds service image when catalog component input hash is stale"
|
||||
const initialCloudApi = initialPlan.services.find((service) => service.serviceId === "hwlab-cloud-api");
|
||||
const staleHash = initialCloudApi.componentInputHash;
|
||||
await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(createCatalogFixture("ready", {
|
||||
sourceCommitId: initialPlan.sourceCommitId,
|
||||
cloudApiComponentInputHash: staleHash
|
||||
}), null, 2));
|
||||
await git(repo, ["add", "deploy/artifact-catalog.v02.json"]);
|
||||
@@ -99,6 +100,61 @@ test("planner rebuilds service image when catalog component input hash is stale"
|
||||
assert.ok(plan.buildServices.includes("hwlab-cloud-api"));
|
||||
});
|
||||
|
||||
test("planner rejects polluted catalog hash that does not match catalog source tree", async () => {
|
||||
const repo = await createFixtureRepo({ includeDevicePod: true });
|
||||
const oldSourceCommit = (await git(repo, ["rev-parse", "HEAD~1"])).stdout.trim();
|
||||
|
||||
await writeFile(path.join(repo, "tools/src/device-pod-cli-lib.ts"), "export const hwpod = true;\n");
|
||||
await git(repo, ["add", "tools/src/device-pod-cli-lib.ts"]);
|
||||
await git(repo, ["commit", "-m", "change shared hwpod runtime"]);
|
||||
|
||||
const currentPlan = await createG14CiPlan({
|
||||
repoRoot: repo,
|
||||
lane: "v02",
|
||||
baseRef: "HEAD",
|
||||
targetRef: "HEAD",
|
||||
artifactCatalogPath: "deploy/nonexistent-artifact-catalog.json",
|
||||
services: ["hwlab-cloud-api"]
|
||||
});
|
||||
const currentCloudApi = currentPlan.services.find((service) => service.serviceId === "hwlab-cloud-api");
|
||||
await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(createCatalogFixture("ready", {
|
||||
sourceCommitId: oldSourceCommit,
|
||||
cloudApiComponentInputHash: currentCloudApi.componentInputHash
|
||||
}), null, 2));
|
||||
await git(repo, ["add", "deploy/artifact-catalog.v02.json"]);
|
||||
await git(repo, ["commit", "-m", "pollute catalog provenance"]);
|
||||
|
||||
await mkdir(path.join(repo, "docs/reference"), { recursive: true });
|
||||
await writeFile(path.join(repo, "docs/reference/g14-gitops-cicd.md"), "docs only after provenance pollution\n");
|
||||
await git(repo, ["add", "docs/reference/g14-gitops-cicd.md"]);
|
||||
await git(repo, ["commit", "-m", "docs after polluted catalog"]);
|
||||
|
||||
const plan = await createG14CiPlan({
|
||||
repoRoot: repo,
|
||||
lane: "v02",
|
||||
baseRef: "HEAD~1",
|
||||
targetRef: "HEAD",
|
||||
artifactCatalogPath: "deploy/artifact-catalog.v02.json",
|
||||
services: ["hwlab-cloud-api"]
|
||||
});
|
||||
const cloudApi = plan.services.find((service) => service.serviceId === "hwlab-cloud-api");
|
||||
assert.equal(cloudApi.affected, true);
|
||||
assert.equal(cloudApi.buildRequired, true);
|
||||
assert.equal(cloudApi.reuse.status, "component-source-mismatch");
|
||||
assert.deepEqual(cloudApi.reason, ["component-source-mismatch"]);
|
||||
});
|
||||
|
||||
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, /const catalogProvenance = serviceImageCatalogProvenance\(catalogService\);/u);
|
||||
assert.match(artifactPublish, /\.\.\.catalogProvenance,[\s\S]{0,80}buildBackend: "reused-catalog"/u);
|
||||
assert.match(artifactPublish, /componentInputHash: serviceResultValue\(env, service\.serviceId, "COMPONENT_INPUT_HASH"\) \?\? \(reusedServiceImage \? null : service\.componentInputHash \?\? null\)/u);
|
||||
|
||||
const gitopsRender = await readFile("scripts/g14-gitops-render.mjs", "utf8");
|
||||
assert.match(gitopsRender, /const componentInputHash = envReuse \? \(planned\.componentInputHash \|\| service\.componentInputHash \|\| ""\) : \(service\.componentInputHash \|\| ""\);/u);
|
||||
assert.doesNotMatch(gitopsRender, /"component-input-hash": planned\.componentInputHash \|\| service\.componentInputHash \|\| ""/u);
|
||||
});
|
||||
|
||||
test("component model uses built-in service paths", () => {
|
||||
const models = componentModelsForServices(["hwlab-cloud-api"]);
|
||||
assert.deepEqual(models[0].componentPaths, [
|
||||
@@ -139,6 +195,7 @@ test("planner ignores package script cleanup for runtime images", async () => {
|
||||
}, null, 2));
|
||||
await git(repo, ["add", "package.json"]);
|
||||
await git(repo, ["commit", "-m", "seed package scripts"]);
|
||||
await refreshFixtureCatalogForHead(repo, { includeDevicePod: true });
|
||||
|
||||
await writeFile(path.join(repo, "package.json"), JSON.stringify({
|
||||
type: "module",
|
||||
@@ -310,7 +367,10 @@ test("v02 planner keeps hwlab-cli-only changes out of runtime service builds", a
|
||||
});
|
||||
const initialCloudWeb = initialPlan.services.find((service) => service.serviceId === "hwlab-cloud-web");
|
||||
const initialDevicePod = initialPlan.services.find((service) => service.serviceId === "hwlab-device-pod");
|
||||
const initialHashes = Object.fromEntries(initialPlan.services.map((service) => [service.serviceId, service.componentInputHash]));
|
||||
await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(createCatalogFixture("ready", {
|
||||
sourceCommitId: initialPlan.sourceCommitId,
|
||||
componentInputHashes: initialHashes,
|
||||
cloudWebEnvironmentInputHash: initialCloudWeb.environmentInputHash,
|
||||
cloudWebCodeInputHash: initialCloudWeb.codeInputHash,
|
||||
cloudWebBootCommit: initialPlan.sourceCommitId,
|
||||
@@ -360,7 +420,10 @@ test("v02 planner treats hwpod runner CLI changes as runtime inputs", async () =
|
||||
});
|
||||
const initialCloudWeb = initialPlan.services.find((service) => service.serviceId === "hwlab-cloud-web");
|
||||
const initialDevicePod = initialPlan.services.find((service) => service.serviceId === "hwlab-device-pod");
|
||||
const initialHashes = Object.fromEntries(initialPlan.services.map((service) => [service.serviceId, service.componentInputHash]));
|
||||
await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(createCatalogFixture("ready", {
|
||||
sourceCommitId: initialPlan.sourceCommitId,
|
||||
componentInputHashes: initialHashes,
|
||||
cloudWebEnvironmentInputHash: initialCloudWeb.environmentInputHash,
|
||||
cloudWebCodeInputHash: initialCloudWeb.codeInputHash,
|
||||
cloudWebBootCommit: initialPlan.sourceCommitId,
|
||||
@@ -411,10 +474,6 @@ async function createFixtureRepo(options = {}) {
|
||||
await mkdir(path.join(repo, "deploy"), { recursive: true });
|
||||
await writeFile(path.join(repo, "deploy/deploy.json"), JSON.stringify(createDeployFixture({ deployServices, k3sServiceMappings, includeDevicePod }), null, 2));
|
||||
const catalogMode = options.catalog ?? "ready";
|
||||
if (catalogMode !== false) {
|
||||
await writeFile(path.join(repo, "deploy/artifact-catalog.dev.json"), JSON.stringify(createCatalogFixture(catalogMode), null, 2));
|
||||
await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(createCatalogFixture(catalogMode), null, 2));
|
||||
}
|
||||
await writeFile(path.join(repo, "package.json"), JSON.stringify({ type: "module" }, null, 2));
|
||||
await writeFile(path.join(repo, "package-lock.json"), JSON.stringify({ lockfileVersion: 3 }, null, 2));
|
||||
await writeFile(path.join(repo, "cmd/hwlab-cloud-api/main.ts"), "console.log('api');\n");
|
||||
@@ -440,9 +499,43 @@ async function createFixtureRepo(options = {}) {
|
||||
await git(repo, ["init"]);
|
||||
await git(repo, ["add", "."]);
|
||||
await git(repo, ["commit", "-m", "initial"]);
|
||||
if (catalogMode !== false) {
|
||||
const initialSha = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim();
|
||||
const initialPlan = await createG14CiPlan({
|
||||
repoRoot: repo,
|
||||
baseRef: "HEAD",
|
||||
targetRef: "HEAD",
|
||||
artifactCatalogPath: "deploy/nonexistent-artifact-catalog.json",
|
||||
services: ["hwlab-cloud-api", "hwlab-cloud-web", ...(includeDevicePod ? ["hwlab-device-pod"] : [])]
|
||||
});
|
||||
const componentInputHashes = Object.fromEntries(initialPlan.services.map((service) => [service.serviceId, service.componentInputHash]));
|
||||
const catalog = createCatalogFixture(catalogMode, { sourceCommitId: initialSha, componentInputHashes });
|
||||
await writeFile(path.join(repo, "deploy/artifact-catalog.dev.json"), JSON.stringify(catalog, null, 2));
|
||||
await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(catalog, null, 2));
|
||||
await git(repo, ["add", "deploy/artifact-catalog.dev.json", "deploy/artifact-catalog.v02.json"]);
|
||||
await git(repo, ["commit", "-m", "seed artifact catalogs"]);
|
||||
}
|
||||
return repo;
|
||||
}
|
||||
|
||||
async function refreshFixtureCatalogForHead(repo, { includeDevicePod = true } = {}) {
|
||||
const sourceCommitId = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim();
|
||||
const services = ["hwlab-cloud-api", "hwlab-cloud-web", ...(includeDevicePod ? ["hwlab-device-pod"] : [])];
|
||||
const plan = await createG14CiPlan({
|
||||
repoRoot: repo,
|
||||
baseRef: "HEAD",
|
||||
targetRef: "HEAD",
|
||||
artifactCatalogPath: "deploy/nonexistent-artifact-catalog.json",
|
||||
services
|
||||
});
|
||||
const componentInputHashes = Object.fromEntries(plan.services.map((service) => [service.serviceId, service.componentInputHash]));
|
||||
const catalog = createCatalogFixture("ready", { sourceCommitId, componentInputHashes });
|
||||
await writeFile(path.join(repo, "deploy/artifact-catalog.dev.json"), JSON.stringify(catalog, null, 2));
|
||||
await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(catalog, null, 2));
|
||||
await git(repo, ["add", "deploy/artifact-catalog.dev.json", "deploy/artifact-catalog.v02.json"]);
|
||||
await git(repo, ["commit", "-m", "refresh fixture artifact catalogs"]);
|
||||
}
|
||||
|
||||
function createDeployFixture({ deployServices, k3sServiceMappings, includeDevicePod }) {
|
||||
const deploy = {
|
||||
lanes: {
|
||||
@@ -481,11 +574,12 @@ function createCatalogFixture(mode, options = {}) {
|
||||
services: ["hwlab-cloud-api", "hwlab-cloud-web", "hwlab-device-pod"].map((serviceId) => ({
|
||||
serviceId,
|
||||
commitId: "abc1234",
|
||||
sourceCommitId: options.sourceCommitId ?? "abc1234",
|
||||
image: `127.0.0.1:5000/hwlab/${serviceId}:abc1234`,
|
||||
imageTag: "abc1234",
|
||||
digest,
|
||||
...((serviceId === "hwlab-cloud-api" && options.cloudApiComponentInputHash) ? {
|
||||
componentInputHash: options.cloudApiComponentInputHash
|
||||
...((options.componentInputHashes?.[serviceId] || (serviceId === "hwlab-cloud-api" && options.cloudApiComponentInputHash)) ? {
|
||||
componentInputHash: options.cloudApiComponentInputHash ?? options.componentInputHashes?.[serviceId]
|
||||
} : {}),
|
||||
...((serviceId === "hwlab-cloud-web" || serviceId === "hwlab-device-pod") ? {
|
||||
runtimeMode: "env-reuse-git-mirror-checkout",
|
||||
|
||||
@@ -1396,6 +1396,9 @@ const digest = envReuse ? (service.environmentDigest || service.digest || "not_p
|
||||
const imageTag = service.imageTag || (image.includes(":") ? image.slice(image.lastIndexOf(":") + 1) : "");
|
||||
const repository = image.includes(":") ? image.slice(0, image.lastIndexOf(":")) : image;
|
||||
const revision = process.env.HWLAB_SOURCE_REVISION || planned.bootCommit || service.bootCommit || service.sourceCommitId || service.commitId || imageTag;
|
||||
const componentInputHash = envReuse ? (planned.componentInputHash || service.componentInputHash || "") : (service.componentInputHash || "");
|
||||
const environmentInputHash = envReuse ? (service.environmentInputHash || planned.environmentInputHash || "") : (service.environmentInputHash || "");
|
||||
const codeInputHash = envReuse ? (planned.codeInputHash || service.codeInputHash || "") : (service.codeInputHash || "");
|
||||
const values = {
|
||||
"service-id": serviceId,
|
||||
status: /^sha256:[a-f0-9]{64}$/.test(digest) ? "reused" : "blocked_reuse_unavailable",
|
||||
@@ -1404,9 +1407,9 @@ const values = {
|
||||
digest,
|
||||
"repository-digest": /^sha256:[a-f0-9]{64}$/.test(digest) && repository ? repository + "@" + digest : "",
|
||||
"source-commit-id": envReuse ? revision : (service.sourceCommitId || service.commitId || imageTag),
|
||||
"component-input-hash": planned.componentInputHash || service.componentInputHash || "",
|
||||
"environment-input-hash": planned.environmentInputHash || service.environmentInputHash || "",
|
||||
"code-input-hash": planned.codeInputHash || service.codeInputHash || "",
|
||||
"component-input-hash": componentInputHash,
|
||||
"environment-input-hash": environmentInputHash,
|
||||
"code-input-hash": codeInputHash,
|
||||
"runtime-mode": envReuse ? "env-reuse-git-mirror-checkout" : "service-image",
|
||||
"boot-repo": planned.bootRepo || service.bootRepo || "",
|
||||
"boot-commit": envReuse ? revision : (service.bootCommit || ""),
|
||||
|
||||
@@ -195,10 +195,32 @@ export async function createG14CiPlan(options = {}) {
|
||||
runtimeKind: model.runtimeKind,
|
||||
entrypoint: model.entrypoint
|
||||
});
|
||||
const componentInputChanged = !envReuse && typeof catalogRecord?.componentInputHash === "string" && catalogRecord.componentInputHash !== componentInputHash;
|
||||
const catalogSourceCommitId = !envReuse ? text(catalogRecord?.sourceCommitId) : "";
|
||||
const catalogComponentInputHash = !envReuse ? text(catalogRecord?.componentInputHash) : "";
|
||||
const catalogComponentInputHashAtSource = !envReuse && catalogSourceCommitId
|
||||
? await hashGitPathsIfCommitExists(repoRoot, catalogSourceCommitId, componentInputPaths, {
|
||||
skipPath: (filePath) => isTestOnlyPath(filePath) || isDocsOnlyPath(filePath),
|
||||
semanticPackageJson: true
|
||||
})
|
||||
: null;
|
||||
const catalogComponentProvenance = !envReuse ? catalogComponentProvenanceStatus({
|
||||
catalogRecord,
|
||||
catalogSourceCommitId,
|
||||
catalogComponentInputHash,
|
||||
catalogComponentInputHashAtSource,
|
||||
componentInputHash,
|
||||
dockerfileHash,
|
||||
buildArgsHash
|
||||
}) : null;
|
||||
const catalogRecordForReuse = catalogRecord && catalogComponentProvenance ? {
|
||||
...catalogRecord,
|
||||
componentProvenanceStatus: catalogComponentProvenance.status,
|
||||
componentProvenanceReason: catalogComponentProvenance.reason
|
||||
} : catalogRecord;
|
||||
const componentInputChanged = !envReuse && catalogComponentProvenance?.safeToReuse !== true;
|
||||
const catalogReuse = envReuse
|
||||
? envReuseCandidate(catalogRecord, envChanged)
|
||||
: reuseCandidate(catalogRecord, imageRelevantChangedPaths.length > 0, componentInputChanged);
|
||||
: reuseCandidate(catalogRecordForReuse, imageRelevantChangedPaths.length > 0, componentInputChanged);
|
||||
const affected = envReuse
|
||||
? Boolean(envChanged || codeChanged)
|
||||
: imageRelevantChangedPaths.length > 0 || componentInputChanged || catalogReuse.status === "candidate-no-catalog" || catalogReuse.status === "candidate-unverified-digest";
|
||||
@@ -213,6 +235,10 @@ export async function createG14CiPlan(options = {}) {
|
||||
buildSystemPaths: model.buildSystemPaths,
|
||||
componentCommitId,
|
||||
componentInputHash,
|
||||
catalogSourceCommitId: catalogSourceCommitId || null,
|
||||
catalogComponentInputHash: catalogComponentInputHash || null,
|
||||
catalogComponentInputHashAtSource,
|
||||
catalogComponentProvenance,
|
||||
dockerfileHash,
|
||||
baseImageReference: baseImage,
|
||||
baseImageDigest: digestFromImageReference(baseImage),
|
||||
@@ -501,6 +527,10 @@ function reasonForService({ directMatches, sharedMatches, runtimeDepMatches, bui
|
||||
if (runtimeDepMatches.some((item) => !isTestOnlyPath(item) && !isDocsOnlyPath(item))) reasons.push("runtime-deps-changed");
|
||||
if (buildSystemMatches.some((item) => !isTestOnlyPath(item) && !isDocsOnlyPath(item))) reasons.push("build-system-changed");
|
||||
if (catalogReuse?.status === "component-input-mismatch") reasons.push("component-input-mismatch");
|
||||
if (catalogReuse?.status === "component-source-mismatch") reasons.push("component-source-mismatch");
|
||||
if (catalogReuse?.status === "component-source-unavailable") reasons.push("component-source-unavailable");
|
||||
if (catalogReuse?.status === "component-provenance-missing") reasons.push("component-provenance-missing");
|
||||
if (catalogReuse?.status === "component-build-mismatch") reasons.push("component-build-mismatch");
|
||||
if (catalogReuse?.status === "candidate-no-catalog") reasons.push("catalog-record-missing");
|
||||
if (catalogReuse?.status === "candidate-unverified-digest") reasons.push("catalog-digest-missing");
|
||||
return reasons.length ? reasons : ["affected"];
|
||||
@@ -517,7 +547,16 @@ function reasonForUnchanged(globalChange) {
|
||||
function reuseCandidate(catalogRecord, affected, componentInputChanged = false) {
|
||||
if (affected) return { status: "not-reused", reason: "component-affected" };
|
||||
if (!catalogRecord) return { status: "candidate-no-catalog", reason: "no-previous-artifact-record" };
|
||||
if (componentInputChanged) {
|
||||
if (componentInputChanged && catalogRecord?.componentProvenanceStatus) {
|
||||
return {
|
||||
status: catalogRecord.componentProvenanceStatus,
|
||||
reason: catalogRecord.componentProvenanceReason,
|
||||
image: catalogRecord.image ?? null,
|
||||
digest: catalogRecord.digest ?? null,
|
||||
reusedFrom: catalogRecord.componentInputHash ?? catalogRecord.sourceCommitId ?? catalogRecord.commitId ?? catalogRecord.imageTag ?? null
|
||||
};
|
||||
}
|
||||
if (componentInputChanged && typeof catalogRecord?.componentInputHash === "string") {
|
||||
return {
|
||||
status: "component-input-mismatch",
|
||||
reason: "catalog-component-input-hash-differs-from-current-plan",
|
||||
@@ -526,6 +565,15 @@ function reuseCandidate(catalogRecord, affected, componentInputChanged = false)
|
||||
reusedFrom: catalogRecord.componentInputHash ?? catalogRecord.commitId ?? catalogRecord.imageTag ?? null
|
||||
};
|
||||
}
|
||||
if (componentInputChanged) {
|
||||
return {
|
||||
status: "component-source-mismatch",
|
||||
reason: "catalog-source-commit-does-not-contain-current-component-input",
|
||||
image: catalogRecord.image ?? null,
|
||||
digest: catalogRecord.digest ?? null,
|
||||
reusedFrom: catalogRecord.sourceCommitId ?? catalogRecord.commitId ?? catalogRecord.imageTag ?? null
|
||||
};
|
||||
}
|
||||
if (!/^sha256:[a-f0-9]{64}$/u.test(catalogRecord.digest ?? "")) {
|
||||
return { status: "candidate-unverified-digest", image: catalogRecord.image ?? null, digest: catalogRecord.digest ?? null };
|
||||
}
|
||||
@@ -537,6 +585,39 @@ function reuseCandidate(catalogRecord, affected, componentInputChanged = false)
|
||||
};
|
||||
}
|
||||
|
||||
function catalogComponentProvenanceStatus({ catalogRecord, catalogSourceCommitId, catalogComponentInputHash, catalogComponentInputHashAtSource, componentInputHash, dockerfileHash, buildArgsHash }) {
|
||||
if (!catalogRecord) return { safeToReuse: false, status: "candidate-no-catalog", reason: "no-previous-artifact-record" };
|
||||
if (!catalogSourceCommitId) return { safeToReuse: false, status: "component-provenance-missing", reason: "catalog-source-commit-missing" };
|
||||
if (!catalogComponentInputHash) return { safeToReuse: false, status: "component-provenance-missing", reason: "catalog-component-input-hash-missing" };
|
||||
if (!catalogComponentInputHashAtSource) return { safeToReuse: false, status: "component-source-unavailable", reason: "catalog-source-commit-unavailable" };
|
||||
if (catalogComponentInputHashAtSource !== catalogComponentInputHash) {
|
||||
return { safeToReuse: false, status: "component-source-mismatch", reason: "catalog-component-input-hash-does-not-match-catalog-source-tree" };
|
||||
}
|
||||
if (catalogComponentInputHash !== componentInputHash) {
|
||||
return { safeToReuse: false, status: "component-input-mismatch", reason: "catalog-component-input-hash-differs-from-current-plan" };
|
||||
}
|
||||
if (text(catalogRecord.dockerfileHash) && catalogRecord.dockerfileHash !== dockerfileHash) {
|
||||
return { safeToReuse: false, status: "component-build-mismatch", reason: "catalog-dockerfile-hash-differs-from-current-plan" };
|
||||
}
|
||||
if (text(catalogRecord.buildArgsHash) && catalogRecord.buildArgsHash !== buildArgsHash) {
|
||||
return { safeToReuse: false, status: "component-build-mismatch", reason: "catalog-build-args-hash-differs-from-current-plan" };
|
||||
}
|
||||
return { safeToReuse: true, status: "safe-reuse", reason: "catalog-source-tree-and-current-component-input-match" };
|
||||
}
|
||||
|
||||
async function hashGitPathsIfCommitExists(repoRoot, targetRef, paths, options = {}) {
|
||||
try {
|
||||
await gitValue(repoRoot, ["rev-parse", "--verify", `${targetRef}^{commit}`]);
|
||||
return await hashGitPaths(repoRoot, targetRef, paths, options);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function text(value) {
|
||||
return String(value ?? "").trim();
|
||||
}
|
||||
|
||||
function envReuseCandidate(catalogRecord, envChanged) {
|
||||
if (envChanged) return { status: "not-reused", reason: "environment-affected" };
|
||||
if (!catalogRecord) return { status: "candidate-no-catalog", reason: "no-previous-artifact-record" };
|
||||
|
||||
Reference in New Issue
Block a user