diff --git a/.agents/skills/unidesk-cicd/SKILL.md b/.agents/skills/unidesk-cicd/SKILL.md index 8ef10a85..6a920987 100644 --- a/.agents/skills/unidesk-cicd/SKILL.md +++ b/.agents/skills/unidesk-cicd/SKILL.md @@ -161,6 +161,10 @@ bun scripts/cli.ts hwlab nodes control-plane legacy-cicd --help - 领域服务不得生成或安装完整 Caddyfile。 - `status` 必须披露 redacted provenance: - installed/desired source commit; + - installed 与 desired 的 source relation; + - installed 是 desired 祖先且受管配置指纹一致时视为配置当前, + 不要求无关后继提交强制重写运行面; + - installed 倒退、分叉或关系无法验证时保持失败; - authority、managed fingerprint、Compose fingerprint 与 site count; - legacy container 是否已退役; - DNS 与 HTTP probe 的失败列表必须直接包含 site id、hostname、 diff --git a/docs/reference/platform-infra.md b/docs/reference/platform-infra.md index 0862a817..deef8df5 100644 --- a/docs/reference/platform-infra.md +++ b/docs/reference/platform-infra.md @@ -73,6 +73,11 @@ - 重建或 provenance 写入失败时恢复上一运行面。 - `status` 必须披露: - installed/desired source commit; + - installed 与 desired 的 source relation; + - installed 是 desired 的祖先且 managed Caddy/Compose 指纹与 desired + 完全一致时,判定配置当前; + - 后继无关提交不得要求强制 reconcile; + - installed 是 desired 的后继、两者分叉或关系无法验证时保持失败; - authority、managed Caddy fingerprint、Compose fingerprint 与 site count; - legacy container 是否已退役; - DNS、listener、Caddy validate 与逐站点 probe 摘要; diff --git a/project-management/PJ2026-01/specs/PJ2026-010604-public-entry.md b/project-management/PJ2026-01/specs/PJ2026-010604-public-entry.md index a20f7f1b..ba33d449 100644 --- a/project-management/PJ2026-01/specs/PJ2026-010604-public-entry.md +++ b/project-management/PJ2026-01/specs/PJ2026-010604-public-entry.md @@ -113,6 +113,10 @@ Caddy 更新必须使用受控 managed block 合并,不能用某个服务的 - 绝对外部 owning YAML 必须 pin 40 位 Git commit,并从该 commit 读取。 - 旧提交、分叉提交和乱序交付必须在 mutation 前拒绝。 - 运行面必须记录 redacted authority、source commit 与 fingerprint provenance。 +- 只读状态必须区分 source commit 一致、installed 为 desired 祖先、 + installed 为 desired 后继、分叉和无法验证。 +- installed 为 desired 祖先且受管配置指纹与 desired 完全一致时, + 应判定配置当前,不应要求无关后继提交强制重写运行面。 FRP、Caddy 和 TLS 输出只能包含目标、状态、fingerprint、health 和错误摘要,不输出 Secret、token、证书私钥或可复制凭据。 diff --git a/scripts/src/platform-infra-public-edge.ts b/scripts/src/platform-infra-public-edge.ts index 5b44645d..3a79e403 100644 --- a/scripts/src/platform-infra-public-edge.ts +++ b/scripts/src/platform-infra-public-edge.ts @@ -559,6 +559,7 @@ async function status( resolution: ResolutionResult, ): Promise> { const artifacts = resolution.unresolved.length === 0 ? renderPublicEdgeArtifacts(target, resolution.sites) : null; + const desiredSourceCommit = checkoutSourceCommit(); const result = await capture( config, target.route, @@ -568,12 +569,22 @@ async function status( resolution.sites, artifacts?.caddyfile ?? null, artifacts?.compose ?? null, - checkoutSourceCommit(), + desiredSourceCommit, delivery.authorityId, ), ); const runtime = parseJsonOutput(result.stdout) ?? { ok: false, capture: compactCapture(result, { full: true }) }; - const ok = result.exitCode === 0 && runtime.ok !== false; + const provenance = optionalRecord(runtime.provenance); + const installed = optionalRecord(provenance?.installed); + const installedSourceCommit = typeof installed?.sourceCommit === "string" ? installed.sourceCommit : null; + const sourceRelation = classifyPublicEdgeSourceRelation(installedSourceCommit, desiredSourceCommit); + const sourceAcceptable = sourceRelation === "same" || sourceRelation === "installed-ancestor"; + if (provenance !== null) { + provenance.sourceRelation = sourceRelation; + provenance.sourceAcceptable = sourceAcceptable; + } + runtime.ok = result.exitCode === 0 && runtime.ok !== false && sourceAcceptable; + const ok = runtime.ok === true; return { ok, action: "platform-infra-public-edge-status", @@ -967,7 +978,6 @@ state_matches_runtime = ( and "error" not in state and state.get("version") == 1 and state.get("authorityId") == desired_authority - and state.get("sourceCommit") == desired_commit and state.get("managedCaddyFingerprint") == current and state.get("composeFingerprint") == current_compose ) @@ -1016,6 +1026,41 @@ function checkoutSourceCommit(): string | null { return /^[0-9a-f]{40}$/u.test(value) ? value : null; } +export type PublicEdgeSourceRelation = + | "same" + | "installed-ancestor" + | "installed-descendant" + | "diverged" + | "unknown"; + +export function classifyPublicEdgeSourceRelation( + installedSourceCommit: string | null, + desiredSourceCommit: string | null, +): PublicEdgeSourceRelation { + if ( + installedSourceCommit === null + || desiredSourceCommit === null + || !/^[0-9a-f]{40}$/u.test(installedSourceCommit) + || !/^[0-9a-f]{40}$/u.test(desiredSourceCommit) + ) return "unknown"; + if (installedSourceCommit === desiredSourceCommit) return "same"; + const cwd = resolveRepoPath("."); + const installedAncestor = spawnSync( + "git", + ["merge-base", "--is-ancestor", installedSourceCommit, desiredSourceCommit], + { cwd, encoding: "utf8" }, + ); + if (installedAncestor.status === 0) return "installed-ancestor"; + if (installedAncestor.status !== 1) return "unknown"; + const installedDescendant = spawnSync( + "git", + ["merge-base", "--is-ancestor", desiredSourceCommit, installedSourceCommit], + { cwd, encoding: "utf8" }, + ); + if (installedDescendant.status === 0) return "installed-descendant"; + return installedDescendant.status === 1 ? "diverged" : "unknown"; +} + function record(value: unknown, path: string): Record { if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} 必须是对象`); return value as Record;