diff --git a/.agents/skills/unidesk-cicd/SKILL.md b/.agents/skills/unidesk-cicd/SKILL.md index 8f18765c..04b3fee4 100644 --- a/.agents/skills/unidesk-cicd/SKILL.md +++ b/.agents/skills/unidesk-cicd/SKILL.md @@ -182,6 +182,11 @@ bun scripts/cli.ts hwlab nodes control-plane legacy-cicd --help health path 或 expected A 与稳定错误码; - public-edge 失败定位只执行一次 `status`, 禁止再串联 plan、临时脚本或额外 `trans` probe; + - 默认 target status 必须返回有界摘要并直接披露 `firstFailure`, + 禁止触发 `outputTruncated` 或依赖临时 dump; + - 默认摘要必须包含 authority、installed/desired commit、source relation、 + managed/Compose fingerprint、endpoint 健康和 warning 计数; + - 站点详情使用 `--site `,完整对象只通过显式 `--full` 获取; - 不披露 Secret、token 或 archive payload。 - public-edge 专站状态: - 领域服务统一调用 diff --git a/docs/reference/platform-infra.md b/docs/reference/platform-infra.md index 39b90306..f6f5194d 100644 --- a/docs/reference/platform-infra.md +++ b/docs/reference/platform-infra.md @@ -129,6 +129,10 @@ health path 或 expected A 与稳定错误码; - `status` 是失败站点定位的唯一首选入口, 禁止再组合 plan、临时脚本和额外 `trans` probe; + - 默认 target 状态只返回有界摘要,必须直接包含 authority、运行 commit、 + managed/Compose fingerprint、endpoint 健康和 `firstFailure`, + 不得触发 `outputTruncated`; + - 单站点详情使用 `--site `,一次性完整对象使用显式 `--full`; - 输出保持 `valuesPrinted=false`,不披露 Secret 或 archive payload。 - 受控入口: @@ -136,6 +140,8 @@ bun scripts/cli.ts platform-infra public-edge plan --target NC01 bun scripts/cli.ts platform-infra public-edge apply --target NC01 --dry-run bun scripts/cli.ts platform-infra public-edge status --target NC01 + bun scripts/cli.ts platform-infra public-edge status --target NC01 --site + bun scripts/cli.ts platform-infra public-edge status --target NC01 --full ``` ### 异地节点只读 Artifact Registry diff --git a/scripts/src/platform-infra-public-edge.ts b/scripts/src/platform-infra-public-edge.ts index 9c170ce0..3690b178 100644 --- a/scripts/src/platform-infra-public-edge.ts +++ b/scripts/src/platform-infra-public-edge.ts @@ -30,6 +30,7 @@ interface PublicEdgeOptions { authorityId: string | null; sourceCommit: string | null; ancestryFile: string | null; + full: boolean; } interface PublicEdgeDelivery { @@ -127,6 +128,7 @@ export function publicEdgeHelp(): Record { "bun scripts/cli.ts platform-infra public-edge apply --target NC01 --dry-run", "bun scripts/cli.ts platform-infra public-edge status --target NC01", "bun scripts/cli.ts platform-infra public-edge status --target NC01 --site superapi", + "bun scripts/cli.ts platform-infra public-edge status --target NC01 --full", ], source: "config/platform-infra/public-edge.yaml", guarantees: [ @@ -161,7 +163,11 @@ export async function runPlatformInfraPublicEdgeCommand(config: UniDeskConfig, a } if (options.action === "status") { const result = await status(config, options.configPath, delivery, target, resolution); - const visible = options.siteId === null ? result : narrowStatusToSite(result, options.siteId); + const visible = options.siteId !== null + ? narrowStatusToSite(result, options.siteId) + : options.full + ? result + : compactStatus(result); return renderMachine("platform-infra public-edge status", visible, "json", visible.ok === true); } if (resolution.sites.length === 0) { @@ -191,6 +197,7 @@ function parseOptions(args: string[]): PublicEdgeOptions { let authorityId: string | null = null; let sourceCommit: string | null = null; let ancestryFile: string | null = null; + let full = false; for (let index = 1; index < args.length; index += 1) { const arg = args[index]!; if (arg === "--config" || arg === "--target" || arg === "--site" || arg === "--authority" || arg === "--source-commit" || arg === "--ancestry-file") { @@ -207,6 +214,8 @@ function parseOptions(args: string[]): PublicEdgeOptions { dryRun = true; } else if (arg === "--confirm") { confirm = true; + } else if (arg === "--full") { + full = true; } else { throw inputError(`不支持的 public-edge 参数:${arg}`, "unsupported-option", arg); } @@ -226,7 +235,10 @@ function parseOptions(args: string[]): PublicEdgeOptions { if (siteId !== null && action !== "status") { throw inputError("--site 只适用于 status", "site-filter-not-allowed", "--site"); } - return { action, configPath, targetId, siteId, dryRun, confirm, authorityId, sourceCommit, ancestryFile }; + if (full && action !== "status") { + throw inputError("--full 只适用于 status", "full-disclosure-not-allowed", "--full"); + } + return { action, configPath, targetId, siteId, dryRun, confirm, authorityId, sourceCommit, ancestryFile, full }; } function readConfig(configPath: string): Record { @@ -698,6 +710,131 @@ function narrowStatusToSite(result: Record, siteId: string): Re }; } +function compactStatus(result: Record): Record { + const runtime = optionalRecord(result.runtime) ?? {}; + const target = optionalRecord(result.target) ?? {}; + const authority = optionalRecord(result.authority) ?? {}; + const provenance = optionalRecord(runtime.provenance) ?? {}; + const installed = optionalRecord(provenance.installed) ?? {}; + const config = optionalRecord(runtime.config) ?? {}; + const dns = optionalRecord(runtime.dns) ?? {}; + const probes = optionalRecord(runtime.siteProbes) ?? {}; + const sites = Array.isArray(result.sites) ? result.sites : []; + const unresolved = Array.isArray(result.unresolved) ? result.unresolved : []; + const dnsFailures = Array.isArray(dns.failures) ? dns.failures : []; + const probeFailures = Array.isArray(probes.failures) ? probes.failures : []; + const declaredWarnings = Array.isArray(result.warnings) ? result.warnings : []; + const warnings = [...declaredWarnings, ...dnsFailures]; + const listenerPorts = Array.isArray(target.listeners) ? target.listeners : []; + const check = (key: string): { ok: boolean; exitCode: number | null } => { + const exitCode = optionalRecord(runtime[key])?.exitCode; + return { ok: exitCode === 0, exitCode: typeof exitCode === "number" ? exitCode : null }; + }; + return { + ok: result.ok === true, + action: result.action, + mutation: false, + target: { + id: target.id ?? null, + publicAddress: target.publicAddress ?? null, + containerName: target.containerName ?? null, + endpoints: listenerPorts.map((port) => ({ + scheme: port === 443 ? "https" : port === 80 ? "http" : "tcp", + port, + listenerReady: check("tcpListeners").ok, + })), + }, + authority: { + id: authority.id ?? null, + sourceBranch: authority.sourceBranch ?? null, + pipelineId: authority.pipelineId ?? null, + }, + configRef: result.configRef, + complete: result.complete === true, + siteSummary: { + resolved: sites.length, + unresolved: unresolved.length, + probeFailures: probeFailures.length, + dnsWarnings: dnsFailures.length, + }, + runtime: { + ok: runtime.ok === true, + checks: { + container: check("container"), + tcpListeners: check("tcpListeners"), + preservedUdpListeners: check("preservedUdpListeners"), + caddyValidate: check("validate"), + legacyRetired: optionalRecord(runtime.legacyContainer)?.retired === true, + siteProbes: { ok: probes.exitCode === 0, exitCode: typeof probes.exitCode === "number" ? probes.exitCode : null }, + }, + source: { + installedCommit: installed.sourceCommit ?? null, + desiredCommit: provenance.desiredSourceCommit ?? null, + relation: provenance.sourceRelation ?? null, + acceptable: provenance.sourceAcceptable === true, + matchesRuntime: provenance.matchesRuntime === true, + }, + fingerprints: { + managedConfig: { + current: config.currentManagedFingerprint ?? null, + desired: config.desiredManagedFingerprint ?? null, + }, + compose: { + current: config.currentComposeFingerprint ?? null, + desired: config.desiredComposeFingerprint ?? null, + }, + matchesDesired: config.matchesDesired === true, + }, + }, + firstFailure: firstStatusFailure(runtime), + warningCount: warnings.length, + warning: warnings.length === 0 ? null : compactStatusWarning(warnings[0]), + next: { + site: `bun scripts/cli.ts platform-infra public-edge status --target ${String(target.id ?? "NC01")} --site `, + full: `bun scripts/cli.ts platform-infra public-edge status --target ${String(target.id ?? "NC01")} --full`, + }, + valuesPrinted: false, + }; +} + +function firstStatusFailure(runtime: Record): Record | null { + if (optionalRecord(runtime.capture) !== null) { + return { stage: "remoteStatus", code: "public-edge-status-capture-failed" }; + } + const failures: Array<[string, boolean, string]> = [ + ["container", optionalRecord(runtime.container)?.exitCode !== 0, "public-edge-container-not-running"], + ["legacyContainer", optionalRecord(runtime.legacyContainer)?.retired !== true, "public-edge-legacy-container-active"], + ["tcpListeners", optionalRecord(runtime.tcpListeners)?.exitCode !== 0, "public-edge-tcp-listener-not-ready"], + ["preservedUdpListeners", optionalRecord(runtime.preservedUdpListeners)?.exitCode !== 0, "public-edge-preserved-udp-listener-not-ready"], + ["caddyValidate", optionalRecord(runtime.validate)?.exitCode !== 0, "public-edge-caddy-invalid"], + ["provenance", optionalRecord(runtime.provenance)?.matchesRuntime !== true, "public-edge-runtime-provenance-mismatch"], + ["sourceCommit", optionalRecord(runtime.provenance)?.sourceAcceptable !== true, "public-edge-source-commit-unacceptable"], + ["config", optionalRecord(runtime.config)?.matchesDesired !== true, "public-edge-config-drift"], + ["siteProbes", optionalRecord(runtime.siteProbes)?.exitCode !== 0, "public-edge-site-probe-failed"], + ]; + const failure = failures.find(([, failed]) => failed); + if (failure === undefined) return null; + const result: Record = { stage: failure[0], code: failure[2] }; + if (failure[0] === "siteProbes") { + const failures = optionalRecord(runtime.siteProbes)?.failures; + if (Array.isArray(failures) && failures.length > 0) result.site = compactStatusWarning(failures[0]); + } + return result; +} + +function compactStatusWarning(value: unknown): Record { + const warning = optionalRecord(value) ?? {}; + return { + code: warning.code ?? "public-edge-warning", + siteId: warning.siteId ?? warning.id ?? null, + hostname: warning.hostname ?? null, + path: warning.path ?? null, + expectedA: warning.expectedA ?? null, + detail: typeof warning.detail === "string" ? warning.detail.slice(0, 240) : null, + blocking: warning.blocking === true, + }; +} + export function renderPublicEdgeArtifacts(target: PublicEdgeTarget, sites: ResolvedSite[]): { caddyfile: string; compose: string } { const protocols = target.listener.protocols.join(" "); const global = renderManagedBlock("public-edge-global", `\{\n\tservers \{\n\t\tprotocols ${protocols}\n\t\}\n\}`);