From 1cea70fc6eff5442a812c7c21961e4877e80964e Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 17 Jul 2026 03:54:24 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=A7=A3=E8=80=A6=20host=20=E9=95=9C?= =?UTF-8?q?=E5=83=8F=E4=B8=8E=20GitOps=20=E8=B5=84=E6=BA=90=E5=8F=91?= =?UTF-8?q?=E5=B8=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../unidesk-cicd/references/gitea-pac.md | 26 +++++++++++-- config/unidesk-host-k8s.yaml | 39 ++----------------- .../cicd/prepare-unidesk-host-release.mjs | 8 ++-- .../cicd/publish-unidesk-host-gitops.mjs | 19 ++++++--- .../cicd/publish-unidesk-host-gitops.test.ts | 23 +++++++---- ...-pipelines-as-code-source-artifact.test.ts | 23 +---------- 6 files changed, 60 insertions(+), 78 deletions(-) diff --git a/.agents/skills/unidesk-cicd/references/gitea-pac.md b/.agents/skills/unidesk-cicd/references/gitea-pac.md index 5c389e8c..d4b62cf0 100644 --- a/.agents/skills/unidesk-cicd/references/gitea-pac.md +++ b/.agents/skills/unidesk-cicd/references/gitea-pac.md @@ -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 详情。 diff --git a/config/unidesk-host-k8s.yaml b/config/unidesk-host-k8s.yaml index b8d6e48d..f2bccfb7 100644 --- a/config/unidesk-host-k8s.yaml +++ b/config/unidesk-host-k8s.yaml @@ -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 diff --git a/scripts/native/cicd/prepare-unidesk-host-release.mjs b/scripts/native/cicd/prepare-unidesk-host-release.mjs index 5dff5295..c7c1b8c4 100755 --- a/scripts/native/cicd/prepare-unidesk-host-release.mjs +++ b/scripts/native/cicd/prepare-unidesk-host-release.mjs @@ -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"; } diff --git a/scripts/native/cicd/publish-unidesk-host-gitops.mjs b/scripts/native/cicd/publish-unidesk-host-gitops.mjs index 786f361b..af7d888f 100755 --- a/scripts/native/cicd/publish-unidesk-host-gitops.mjs +++ b/scripts/native/cicd/publish-unidesk-host-gitops.mjs @@ -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, diff --git a/scripts/native/cicd/publish-unidesk-host-gitops.test.ts b/scripts/native/cicd/publish-unidesk-host-gitops.test.ts index de44c3db..54d7e2ae 100644 --- a/scripts/native/cicd/publish-unidesk-host-gitops.test.ts +++ b/scripts/native/cicd/publish-unidesk-host-gitops.test.ts @@ -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; - 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); }); }); diff --git a/scripts/src/platform-infra-pipelines-as-code-source-artifact.test.ts b/scripts/src/platform-infra-pipelines-as-code-source-artifact.test.ts index 32c4d14f..3b2ff7bc 100644 --- a/scripts/src/platform-infra-pipelines-as-code-source-artifact.test.ts +++ b/scripts/src/platform-infra-pipelines-as-code-source-artifact.test.ts @@ -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; - const runtimePaths = new Set([ - ...(hostConfig.delivery.changeDetection.sharedRuntimePaths as string[]), - ...(hostConfig.delivery.services as Array>) - .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",