fix: 解耦 host 镜像与 GitOps 资源发布

This commit is contained in:
Codex
2026-07-17 03:54:24 +02:00
parent 167bec2681
commit 1cea70fc6e
6 changed files with 60 additions and 78 deletions
@@ -210,12 +210,30 @@ PaC history/status 必须把候选 PipelineRun 分成 `outer-pac-event`、`inner
## UniDesk Host 运行面变更过滤
`unidesk-host` consumer 将 source observation 与 runtime delivery 分离。`config/unidesk-host-k8s.yaml#delivery.changeDetection.runtimePaths` 是唯一可编辑的 runtime 变更 source path 列表,`delivery.gitops.releaseStatePath` 指向用作比较基线的 GitOps release state 生成物。
`unidesk-host` consumer 将镜像构建与 GitOps 资源发布分离:
- `build`:至少一个声明的 runtime path 发生变更,或没有可信的历史 release state。流水线构建 image、更新 manifest 与 release state,然后由 Argo 收敛。
- `skip`source commit 已推进,但没有声明的 runtime path 发生变更。流水线不得构建 image、修改 GitOps branch 或触发 Argo rollout,应保留既有 runtime source commit 与 digest 作为 provenance。
- `config/unidesk-host-k8s.yaml#delivery.changeDetection.sharedImagePaths` 只声明所有服务镜像共同复制的源码输入;
- `delivery.services[].imagePaths` 只声明对应服务镜像复制的源码输入;
- `delivery.gitops.resources` 每次都从当前 source commit 确定性渲染,再由 Git diff 判定是否发布;
- `delivery.services[].gitops.releaseStatePath` 指向各服务镜像比较基线。
- `build`
- 至少一个声明的镜像路径发生变更,或没有可信的历史 release state
- 流水线构建镜像、更新 manifest 与 release state,然后由 Argo 收敛。
- `skip`
- source commit 已推进,但没有声明的镜像路径发生变更;
- 流水线不得构建镜像,应保留既有镜像 source commit 与 digest
- GitOps 资源仍独立渲染和发布。
- `disabled``delivery.enabled=false`。删除生成的 manifest 与 release state;这代表 service retirement,不是 `skip` 的同义词。
CLI、文档、issue metadata 与 `runtimePaths` 之外的其他文件必须得到 `IMAGE_STATUS=skipped`。成功 skip 仍应产生一条短 PaC PipelineRun 作为 source-observation 证据,但应跳过 image build 与 GitOps publication。默认 `status` 必须校验保留的 digest、GitOps revision、Argo state、runtime readiness 与 health,不得强制 runtime source commit 等于更新的非 runtime source commit;兼容 `closeout` 只能复用同一只读证据。
- 镜像路径边界:
- CLI、文档、issue metadata、GitOps renderer 和交付 helper 不能进入镜像路径列表;
- 否则会把配置发布误判为应用镜像重建。
- 成功跳过镜像构建时:
- 仍应产生一条短 PaC PipelineRun 作为 source observation 证据;
- 允许 GitOps 资源独立形成新 revision
- 默认 `status` 必须校验保留的 digest、GitOps revision、Argo state、runtime readiness 与 health
- 不得强制镜像 source commit 等于更新的非镜像 source commit
- 兼容 `closeout` 只能复用同一只读证据。
缺少 reuse 文本不能证明 reuse path 失败,应先核对 consumer type 与 PipelineRun 详情。
+3 -36
View File
@@ -20,45 +20,12 @@ runtime:
delivery:
enabled: true
changeDetection:
sharedRuntimePaths:
- .tekton/unidesk-host-pac.yaml
- config/feature-config.schema.json
- config/hwlab-node-lanes.yaml
- config/platform-infra/pipelines-as-code.yaml
- config/unidesk-host-k8s.yaml
- bun.lock
- package.json
- scripts/native/cicd/build-unidesk-host-image.sh
- scripts/native/cicd/feature-config-schema-warning.mjs
- scripts/native/cicd/otel-runtime-config.ts
- scripts/native/cicd/pac-status-evaluator.cjs
- scripts/native/cicd/prepare-unidesk-host-release.mjs
- scripts/native/cicd/publish-unidesk-host-gitops.mjs
- scripts/native/cicd/render-sentinel-publish-task.ts
- scripts/native/deploy/render-unidesk-host-service.mjs
- scripts/native/hwlab/runtime-gitops-observability.mjs
- scripts/native/hwlab/runtime-gitops-postprocess.mjs
- scripts/native/hwlab/runtime-gitops-scripts-configmap.mjs
- scripts/native/hwlab/runtime-gitops-verify.mjs
- scripts/vendor/ajv-dist/8.17.1/ajv2020.min.js
- scripts/vendor/ajv-dist/8.17.1/manifest.json
- scripts/src/agentrun-manifests.ts
- scripts/src/hwlab-node/render.ts
- scripts/src/hwlab-node/web-probe.ts
- scripts/src/hwlab-node-web-observe-runner-realtime-source.ts
- scripts/src/hwlab-node-web-sentinel-cicd-jobs.ts
- scripts/src/pac-feature-config-schema-stage.ts
- scripts/src/platform-infra-pac-feature-config-projection.ts
- scripts/src/platform-infra-pipelines-as-code-source-artifact.ts
- scripts/src/platform-infra-pipelines-as-code.ts
- scripts/src/config.ts
- scripts/src/hwlab-node-lanes.ts
- scripts/src/yaml-composition.ts
sharedImagePaths:
- src/components/shared
services:
- id: todo-note
serviceRef: services.todoNote
runtimePaths:
imagePaths:
- src/components/microservices/todo-note/Dockerfile
- src/components/microservices/todo-note/bun.lock
- src/components/microservices/todo-note/package.json
@@ -71,7 +38,7 @@ delivery:
releaseStatePath: deploy/gitops-state/unidesk-host/todo-note.json
- id: decision-center
serviceRef: services.decisionCenter
runtimePaths:
imagePaths:
- src/components/microservices/decision-center/Dockerfile
- src/components/microservices/decision-center/package.json
- src/components/microservices/decision-center/src
@@ -67,7 +67,7 @@ function deliveryServices(config, delivery) {
id,
serviceRef,
service,
runtimePaths: stringArray(item.runtimePaths, `${path}.runtimePaths`),
imagePaths: stringArray(item.imagePaths, `${path}.imagePaths`),
imageRepository: required(image.repository, `${path}.image.repository`),
manifestPath: safeRelativePath(gitops.manifestPath, `${path}.gitops.manifestPath`),
releaseStatePath: safeRelativePath(gitops.releaseStatePath, `${path}.gitops.releaseStatePath`),
@@ -100,7 +100,7 @@ function main() {
if (delivery.enabled !== true && delivery.enabled !== false) throw new Error("delivery.enabled must be boolean");
const enabled = delivery.enabled === true;
const changeDetection = record(delivery.changeDetection, "delivery.changeDetection");
const sharedRuntimePaths = stringArray(changeDetection.sharedRuntimePaths, "delivery.changeDetection.sharedRuntimePaths");
const sharedImagePaths = stringArray(changeDetection.sharedImagePaths, "delivery.changeDetection.sharedImagePaths");
const services = deliveryServices(config, delivery);
const build = record(delivery.build, "delivery.build");
const proxy = record(build.proxy, "delivery.build.proxy");
@@ -126,11 +126,11 @@ function main() {
const sourceRef = `refs/unidesk/release-baseline/unidesk-host-source-${service.id}`;
const sourceReady = run("git", ["fetch", "--depth=1", "--filter=blob:none", "origin", `+${sourceSnapshotPrefix}/${baselineSourceCommit}:${sourceRef}`], sourceRoot, true).status === 0;
if (sourceReady) {
const paths = [...new Set([...sharedRuntimePaths, ...service.runtimePaths])];
const paths = [...new Set([...sharedImagePaths, ...service.imagePaths])];
const diff = run("git", ["diff", "--name-only", "--no-renames", sourceRef, sourceCommit, "--", ...paths], sourceRoot);
changedPaths = diff.stdout.split(/\r?\n/u).filter(Boolean);
action = changedPaths.length === 0 ? "skip" : "build";
reason = changedPaths.length === 0 ? "runtime-inputs-unchanged" : "runtime-inputs-changed";
reason = changedPaths.length === 0 ? "image-inputs-unchanged" : "image-inputs-changed";
} else {
reason = "source-baseline-unavailable";
}
@@ -221,7 +221,7 @@ for (const service of services) {
}
const action = serviceResults.some((item) => item.action === "build") ? "build" : enabled ? "skip" : "disabled";
const gitopsResources = action === "skip" ? [] : renderGitOpsResources(gitops, sourceRoot);
const gitopsResources = renderGitOpsResources(gitops, sourceRoot);
for (const resource of gitopsResources) {
const resourcePath = resolve(worktree, resource.path);
@@ -237,11 +237,18 @@ for (const resource of gitopsResources) {
run("git", ["config", "user.name", required(author.name, "delivery.gitops.author.name")], worktree);
run("git", ["config", "user.email", required(author.email, "delivery.gitops.author.email")], worktree);
run("git", ["add", "-A"], worktree);
const changedResourceIds = gitopsResources
.filter((resource) => run("git", ["diff", "--cached", "--quiet", "--", resource.path], worktree, true).status !== 0)
.map((resource) => resource.id);
const resourceChanged = changedResourceIds.length > 0;
const changed = run("git", ["diff", "--cached", "--quiet"], worktree, true).status !== 0;
if (changed) {
const commitAction = action === "disabled" ? "remove" : "deploy";
const changedServices = serviceResults.filter((item) => item.action !== "skip").map((item) => item.id).join(",");
run("git", ["commit", "-m", `unidesk-host: ${commitAction} ${changedServices} ${sourceCommit.slice(0, 12)}`], worktree);
const changedTargets = [
...serviceResults.filter((item) => item.action !== "skip").map((item) => item.id),
...changedResourceIds,
].join(",");
run("git", ["commit", "-m", `unidesk-host: ${commitAction} ${changedTargets} ${sourceCommit.slice(0, 12)}`], worktree);
}
run("git", ["remote", "set-url", "origin", writeUrl], worktree);
@@ -271,8 +278,9 @@ process.stdout.write(`${JSON.stringify({
ok: true,
phase: "gitops-publish",
action,
status: action === "build" ? "built" : action === "skip" ? "skipped" : "disabled",
status: action === "build" ? "built" : action === "disabled" ? "disabled" : changed ? "published" : "skipped",
imageStatus: action === "build" ? "built" : action === "skip" ? "skipped" : "disabled",
resourceStatus: enabled ? (resourceChanged ? "published" : "reused") : resourceChanged ? "removed" : "disabled",
sourceCommit,
gitopsCommit,
affectedServices: serviceResults.filter((item) => item.action === "build").map((item) => item.id),
@@ -280,7 +288,8 @@ process.stdout.write(`${JSON.stringify({
rolloutServices: serviceResults.filter((item) => item.action === "build").map((item) => item.id),
reusedServices: serviceResults.filter((item) => item.action === "skip").map((item) => item.id),
services: serviceResults,
resources: gitopsResources.map((resource) => ({ id: resource.id, path: resource.path })),
resources: gitopsResources.map((resource) => ({ id: resource.id, path: resource.path, changed: changedResourceIds.includes(resource.id) })),
resourceChanged,
changed,
pushAttempts,
valuesPrinted: false,
@@ -42,7 +42,7 @@ describe("UniDesk Host GitOps publisher", () => {
});
});
test("skip preserves the GitOps branch and every managed resource", () => {
test("image skip publishes changed GitOps resources without changing service state", () => {
const root = mkdtempSync(join(tmpdir(), "unidesk-host-gitops-skip-"));
temporaryRoots.push(root);
const seed = join(root, "seed");
@@ -79,7 +79,7 @@ describe("UniDesk Host GitOps publisher", () => {
const serviceReleaseDir = join(releaseDir, "services", "todo-note");
mkdirSync(serviceReleaseDir, { recursive: true });
writeFileSync(join(serviceReleaseDir, "action"), "skip\n");
writeFileSync(join(serviceReleaseDir, "reason"), "runtime-inputs-unchanged\n");
writeFileSync(join(serviceReleaseDir, "reason"), "image-inputs-unchanged\n");
writeFileSync(join(serviceReleaseDir, "baseline-source-commit"), `${runtimeSourceCommit}\n`);
writeFileSync(configPath, Bun.YAML.stringify({
services: { todoNote: {} },
@@ -88,7 +88,7 @@ describe("UniDesk Host GitOps publisher", () => {
services: [{
id: "todo-note",
serviceRef: "services.todoNote",
runtimePaths: ["src/components/microservices/todo-note/src"],
imagePaths: ["src/components/microservices/todo-note/src"],
image: { repository: "registry.example/todo-note" },
gitops: {
manifestPath: "deploy/gitops/unidesk-host/todo-note.yaml",
@@ -122,10 +122,19 @@ describe("UniDesk Host GitOps publisher", () => {
], { cwd: rootPath(), encoding: "utf8", timeout: 30_000 });
expect(result.status, result.stderr).toBe(0);
const output = JSON.parse(result.stdout) as Record<string, any>;
expect(output).toMatchObject({ action: "skip", status: "skipped", changed: false, pushAttempts: 0, resources: [] });
expect(output.gitopsCommit).toBe(originalHead);
expect(git(["rev-parse", "refs/heads/unidesk-host-gitops"], bare).stdout.trim()).toBe(originalHead);
expect(show(bare, resourcePath)).toBe("stale configmap must remain unchanged\n");
expect(output).toMatchObject({
action: "skip",
status: "published",
imageStatus: "skipped",
resourceStatus: "published",
resourceChanged: true,
changed: true,
pushAttempts: 1,
resources: [{ id: "hwlab-runtime-gitops-scripts", path: resourcePath }],
});
expect(output.gitopsCommit).not.toBe(originalHead);
expect(git(["rev-parse", "refs/heads/unidesk-host-gitops"], bare).stdout.trim()).toBe(output.gitopsCommit);
expect(show(bare, resourcePath)).not.toBe("stale configmap must remain unchanged\n");
expect(JSON.parse(show(bare, statePath)).sourceCommit).toBe(runtimeSourceCommit);
});
});
@@ -450,28 +450,7 @@ describe("HWLAB YAML-owned Pipeline provenance", () => {
"runtime-gitops-verify.mjs",
]);
const hostConfig = Bun.YAML.parse(readFileSync(rootPath("config", "unidesk-host-k8s.yaml"), "utf8")) as Record<string, any>;
const runtimePaths = new Set([
...(hostConfig.delivery.changeDetection.sharedRuntimePaths as string[]),
...(hostConfig.delivery.services as Array<Record<string, any>>)
.flatMap((service) => service.runtimePaths as string[]),
]);
for (const inputPath of [
"bun.lock",
"config/hwlab-node-lanes.yaml",
"package.json",
"scripts/native/cicd/feature-config-schema-warning.mjs",
"scripts/native/hwlab/runtime-gitops-observability.mjs",
"scripts/native/hwlab/runtime-gitops-postprocess.mjs",
"scripts/native/hwlab/runtime-gitops-scripts-configmap.mjs",
"scripts/native/hwlab/runtime-gitops-verify.mjs",
"scripts/vendor/ajv-dist/8.17.1/ajv2020.min.js",
"scripts/vendor/ajv-dist/8.17.1/manifest.json",
"scripts/src/config.ts",
"scripts/src/hwlab-node/render.ts",
"scripts/src/hwlab-node-lanes.ts",
"scripts/src/platform-infra-pipelines-as-code.ts",
"scripts/src/yaml-composition.ts",
]) expect(runtimePaths.has(inputPath), inputPath).toBe(true);
expect(hostConfig.delivery.changeDetection).toEqual({ sharedImagePaths: ["src/components/shared"] });
expect(hostConfig.delivery.gitops.resources).toContainEqual({
id: "hwlab-nc01-v03-runtime-gitops-scripts",
renderer: "hwlab-runtime-gitops-scripts",