diff --git a/.agents/skills/unidesk-cicd/SKILL.md b/.agents/skills/unidesk-cicd/SKILL.md index 504acb8e..7b282ad8 100644 --- a/.agents/skills/unidesk-cicd/SKILL.md +++ b/.agents/skills/unidesk-cicd/SKILL.md @@ -60,6 +60,7 @@ bun scripts/cli.ts hwlab nodes control-plane legacy-cicd --help - CI/CD 只读等待: - 默认使用 `closeout --wait --timeout 120s` 观察 Tekton、Argo 和 runtime 收敛;已处于成功或失败终态时立即返回。 - 未完成时由目标控制面的 Kubernetes watch 持有单次等待,不得在调用侧每 `1s`、`5s` 重复执行 `status`。 + - delivery 只有 source commit、GitOps exact/descendant ancestry、Argo 与 runtime sha256 digest 全部一致时才 ready;`no-runtime-change` 使用独立保留合同。 - timeout 必须保留当前状态、`timedOut=true`、耗时、稳定 identity/cursor 与 `next.wait`;零变化也必须有输出。 - `--timeout` 只接受严格 `<正整数>ms|s|m`,测试使用短 timeout 或虚拟时钟,不真实等待 `120s`。 - watch 断连或后端错误必须显式失败,不能折叠为 timeout;该入口固定 `mutation=false`,不触发 PipelineRun、Argo refresh、GitOps 或 rollout。 @@ -164,6 +165,7 @@ bun scripts/cli.ts hwlab nodes control-plane legacy-cicd --help - desired 只来自 owning YAML 与共享 renderer,禁止从 live Pipeline/PipelineRun 反向导出; - `verify-runtime` 使用干净且 `HEAD` 等于完整 source commit 的独立 worktree; - 未声明 owner、worktree 证据不成立或运行面证据冲突时 fail-closed; + - `plan|check|write` 必须检查内联 Pipeline 的 `command`、`args` 和 shell `script` 实际执行的仓库相对源码入口;入口缺失时在 source PR 前 fail-closed,不读取仓库外路径; - 错误和 drift 在默认、`--json`、`--full` 中都只能披露路径、类型、长度、SHA-256 或 URL fingerprint; - 禁止回显脚本、URL 凭据、查询参数、Authorization、Secret 和远端 stderr。 - PaC source-only closeout 必须使用目标侧结构化证据: @@ -184,6 +186,7 @@ bun scripts/cli.ts hwlab nodes control-plane legacy-cicd --help - PaC `debug-step --id ` 必须读取目标侧真实 PipelineRun、TaskRun condition/result 与合同 Task 日志: - 默认输出每个 terminal role 的 presence、status、source 与首个断点; - 同时披露 artifact catalog、digest、GitOps commit 和 collector 读错; + - 默认一次返回失败 TaskRun、step、container、exit code、termination reason 和脱敏有界日志尾,不再要求操作者另行拼接 `trans logs`; - 该入口只读且 `mutation=false`,不能创建 PipelineRun 或写 GitOps/runtime; - 未带 `--id` 时才只运行 evaluator fixture。 - PaC `diagnose-regression` 必须保持只读、轻量和有界: @@ -232,6 +235,10 @@ bun scripts/cli.ts hwlab nodes control-plane legacy-cicd --help - 显式 `--json` 的 stdout 只允许一个合法 JSON 文档,progress 写入 stderr。 - 触发或验收 rollout 时必须绑定 lane、source commit、PipelineRun/GitOps revision、runtime ready 和 `/health` 端点验证结果;web-probe/Playwright 结果只能作为单独的 post-deploy 证据。 - CI/CD 状态、日志和事件查询必须减少 trans/SSH 传输:能在目标 NODE/k8s 内解析、聚合、裁剪的内容,必须在目标侧计算成短 JSON/table 摘要后再回传;禁止为了本地解析而把完整 ConfigMap、大对象、长日志或原始 API payload 透传回来。 +- PaC history 范围必须显式且高效: + - 未指定 `--consumer` 时汇总 target 全部 consumer,并按 namespace 复用 PipelineRun/TaskRun 读取; + - 指定 `--consumer` 或可唯一归属的 `--id` 时只读取单 consumer 快路径; + - 输出 `config.scope=target-summary|single-consumer`,不得静默退回默认 consumer。 - 退役 branch-follower 排障只允许使用 `state-read|controller-source|status-read|decide`、`status|events|logs` 等只读边界;`state-write`、gate、`run-once` 和自动 loop 不再是当前 PaC consumer 的修复或验收入口。 - 自动链单步测不通时,应在 Git-backed YAML、controller 或源码中修复根因,并由修复 PR 合并产生的新正常 webhook/PaC 事件复测;不得用 branch-follower 写状态或补跑当前交付。 - CI/CD 排障中再次踩到已暴露的运行面坑、工具误用、镜像假设或状态可见性缺口时,必须先把长期规则写入本 skill/reference,再继续只读定位。 diff --git a/.agents/skills/unidesk-cicd/references/gitea-pac.md b/.agents/skills/unidesk-cicd/references/gitea-pac.md index aa048640..5c389e8c 100644 --- a/.agents/skills/unidesk-cicd/references/gitea-pac.md +++ b/.agents/skills/unidesk-cicd/references/gitea-pac.md @@ -48,7 +48,7 @@ GitHub 是唯一上游写入权威。目标 source branch 的 GitHub PR merge - 在 source PR 合并前完成源码侧准备: - owning YAML 中声明 Gitea repository、PaC repository 与 consumer; - - consumer 声明 `sourceArtifact` 时,先在 source worktree 执行 `source-artifact check`; + - consumer 声明 `sourceArtifact` 时,先在 source worktree 执行 `source-artifact check`;该命令同时检查内联 Pipeline 的 `command`、`args` 与 shell `script` 实际执行的仓库相对源码入口是否存在; - 不创建人工 PipelineRun,也不推送 Gitea branch 或 snapshot。 - 只使用同一个组合入口完成控制面首次引导: @@ -109,6 +109,10 @@ bun scripts/cli.ts platform-infra pipelines-as-code source-artifact verify-runti - desired 只来自 owning YAML 与共享 domain renderer;live Pipeline 和 PipelineRun 只能作为只读诊断证据,禁止从运行面反向导出 desired 或源码制品。 - `plan`、`check`、`write` 只比较或更新显式消费仓 worktree,不访问运行面;worktree 必须是绝对路径、Git 根目录,并与声明仓库的精确 remote identity 一致。 - `write` 先完成全部路径、内容和 renderer 合同校验,再通过同目录临时文件与 rename 更新;连续执行必须无差异。 +- `plan|check|write` 对受管 Pipeline 做通用源码入口检查: + - 只识别实际执行上下文中的仓库相对 `scripts/`、`src/`、`deploy/` 与 `config/` 入口; + - 缺失入口进入 `sourceEntrypoints.missing` 并使 `check|write` fail-closed; + - `..` 逃逸和仓库外路径不参与读取,输出只披露相对路径。 - `status` 是非门禁诊断,允许不传 commit。 - `verify-runtime` 必须满足以下条件: - 传入完整四十位 `--source-commit`; @@ -140,7 +144,11 @@ bun scripts/cli.ts platform-infra pipelines-as-code source-artifact verify-runti | `sentinel-nc01-v03` | `pikasTech/unidesk@master` | `devops-infra` | `hwlab-web-probe-sentinel-nc01-pac` | `hwlab-web-probe-sentinel-nc01` | `platform-infra pipelines-as-code status --target NC01 --consumer sentinel-nc01-v03` | | `hwlab-nc01-v03` | `pikasTech/HWLAB@v0.3` | `hwlab-ci` | `hwlab-nc01-v03-ci-image-publish` | `hwlab-node-v03` | `platform-infra pipelines-as-code status --target NC01 --consumer hwlab-nc01-v03` | -使用 `bun scripts/cli.ts platform-infra pipelines-as-code history --target --limit 10` 审计全部 consumer 的触发、耗时与 reuse。命令必须读取 target node 上的实时 Gitea Repository CR 与 Tekton PipelineRun/TaskRun,在 target 侧聚合并显式输出 `READ_ERRORS`。 +- history 范围: + - 使用 `bun scripts/cli.ts platform-infra pipelines-as-code history --target --limit 10` 审计全部 consumer 的触发、耗时与 reuse; + - target 汇总读取实时 Gitea Repository CR 与 Tekton PipelineRun/TaskRun,并按 namespace 复用读取、聚合和显式输出 `READ_ERRORS`; + - 指定 `--consumer ` 或可唯一归属的 `--id` 时只走单 consumer 快路径; + - `config.scope` 明确显示 `target-summary` 或 `single-consumer`。 - 单次交付耗时查询: - 使用 `bun scripts/cli.ts platform-infra pipelines-as-code delivery-timing --target --consumer `; @@ -161,8 +169,16 @@ bun scripts/cli.ts platform-infra pipelines-as-code source-artifact verify-runti - `bun scripts/cli.ts platform-infra pipelines-as-code status --target --consumer ` - `bun scripts/cli.ts platform-infra pipelines-as-code history --target --consumer --limit 10` - 对归属或状态不明的行,使用 `bun scripts/cli.ts platform-infra pipelines-as-code history --target --id ` 下钻 PipelineRun。 +- 已知失败 PipelineRun 时使用 `debug-step --target --consumer --id `;默认结果一次包含失败 TaskRun、step、container、exit code、termination reason 与脱敏有界日志尾。 -`closeout` 只保留为可选的只读历史/诊断兼容入口,不得出现在 migrated consumer 的默认观察顺序、覆盖矩阵、正常下一步或 PR 合并后的主/子代理任务中。它不得创建、同步、刷新、重试或补跑任何交付对象。consumer 专属 debug 命令只允许复现单步故障,不得成为正式交付或恢复入口。 +- `closeout` 兼容边界: + - 只保留为可选的只读历史/诊断兼容入口; + - 不得出现在 migrated consumer 的默认观察顺序、覆盖矩阵、正常下一步或 PR 合并后的主/子代理任务中; + - 不得创建、同步、刷新、重试或补跑任何交付对象; + - 显式 `--wait` 由 Kubernetes watch 持有等待; + - 只有 source commit、GitOps exact/descendant ancestry、Argo 与 runtime sha256 digest 一致时才 ready; + - `no-runtime-change` 使用独立保留合同; + - consumer 专属 debug 命令只允许复现单步故障,不得成为正式交付或恢复入口。 GitHub webhook delivery 的持久接收与 refs 提交必须分层: diff --git a/docs/MDTODO/details/sub2api-upstream-reliability/R5.1_Task_Report.md b/docs/MDTODO/details/sub2api-upstream-reliability/R5.1_Task_Report.md new file mode 100644 index 00000000..78e95b52 --- /dev/null +++ b/docs/MDTODO/details/sub2api-upstream-reliability/R5.1_Task_Report.md @@ -0,0 +1,30 @@ +# R5.1 任务报告 + +## 结果 + +- `source-artifact plan|check|write` 现在从内联 Pipeline 的 `command`、`args` 和 shell `script` 中识别实际执行的仓库相对源码入口。 +- 缺失入口进入 `sourceEntrypoints.missing`,使 `check|write` 在 source PR 前失败;`..` 逃逸不读取仓库外路径。 +- `debug-step --id` 现在从失败 TaskRun 的 Pod selector 一次读取失败 task、step、container、exit code、termination reason 与最多 12 行、每行最多 320 字符的脱敏日志尾。 +- `closeout --wait` 复用后端 Kubernetes watch,最终 ready 必须同时满足 source commit、GitOps exact/descendant ancestry 和 runtime sha256 digest 精确一致;`no-runtime-change` 保持独立合同。 +- history 未指定 consumer 时保持 target 汇总,并按 namespace 复用 PipelineRun/TaskRun API 读取;明确 consumer 或唯一 id 时保持单 consumer 快路径,输出 `config.scope`。 +- 同步更新 `unidesk-cicd` SKILL 与 `references/gitea-pac.md`,没有访问或修改生产运行面。 + +## 验证 + +- `bun test scripts/src/platform-infra-pipelines-as-code-source-artifact.test.ts -t 'source entrypoint inspection'`:2/2 通过。 +- `bun test scripts/src/platform-infra-pac-failure-evidence.test.ts -t 'debug-step 默认投影|closeout requires|collector 以单次'`:3/3 通过。 +- `bun test scripts/src/read-only-long-poll.test.ts`:5/5 通过。 +- `bun test scripts/src/cicd-delivery-authority.test.ts -t 'PaC history detail'`:1/1 通过。 +- `bash -n scripts/src/platform-infra-pipelines-as-code-remote.sh`:通过。 +- `bun scripts/cli.ts platform-infra pipelines-as-code --help`:通过。 +- `bun scripts/cli.ts check --syntax-only`:11/11 通过。 +- `git diff --check`:通过。 + +## 基线说明 + +扩展运行四个相关测试文件共 89 个用例,86 个通过;3 个失败来自最新 master 的既有配置/断言漂移,与本次改动无关:consumer 期望 15 但实际 16、HWLAB feature-config span 文本断言、Kafka features 字段断言。聚焦本次合同的 11 个用例全部通过。 + +## 边界 + +- 未触发 PipelineRun、mirror sync、Argo refresh/sync 或任何生产 mutation。 +- 未修改 Sub2API、ApiState、共享 PaC 运行面或其他服务配置。 diff --git a/scripts/src/platform-infra-pac-failure-evidence.test.ts b/scripts/src/platform-infra-pac-failure-evidence.test.ts index 56462759..39b75838 100644 --- a/scripts/src/platform-infra-pac-failure-evidence.test.ts +++ b/scripts/src/platform-infra-pac-failure-evidence.test.ts @@ -6,7 +6,7 @@ import { createRequire } from "node:module"; import { sentinelPublishImageBuildShell } from "./hwlab-node-web-sentinel-cicd-jobs"; import type { SentinelCicdState } from "./hwlab-node-web-sentinel-cicd-shared"; import { renderAgentRunControlPlaneStatusSummary } from "./agentrun/public-exposure"; -import { compactHistoryJson, compactStatusSummary, pacReadOnlyCaptureObservation, renderDebugStep, renderHistory, renderStatus } from "./platform-infra-pipelines-as-code"; +import { closeoutDeliveryAligned, compactHistoryJson, compactStatusSummary, pacReadOnlyCaptureObservation, renderDebugStep, renderHistory, renderStatus } from "./platform-infra-pipelines-as-code"; const require = createRequire(import.meta.url); const { parsePacLogRecords } = require("../native/cicd/pac-status-evaluator.cjs") as { @@ -162,7 +162,10 @@ describe("PaC 失败证据合同", () => { expect(remote).toContain("const contractTaskRuns = matching.filter((item) => taskTerminalRecord(item) !== null)"); expect(remote).toContain("'--tail=320', '--max-log-requests=20'"); expect(remote.match(/cp\.spawnSync\('kubectl', \['-n', namespace, 'logs'/gu)).toHaveLength(2); - expect(remote).toContain("if (process.env.UNIDESK_PAC_ACTION !== 'status') {"); + expect(remote).toContain("process.env.UNIDESK_PAC_COLLECT_ARTIFACT_LOGS === '1'"); + expect(remote).toContain("`tekton.dev/taskRun=${task.name}`"); + expect(remote).toContain("const pipelineRunsByNamespace = new Map()"); + expect(remote).toContain("const taskRunsByNamespace = new Map()"); expect(remote).toContain("let mode = 'taskrun-status-only';"); expect(remote).toContain('payload_file="${UNIDESK_PAC_TASKRUN_FILE:-}"'); expect(remote).toContain('task_file="${UNIDESK_PAC_TASKRUN_FILE:-}"'); @@ -173,6 +176,46 @@ describe("PaC 失败证据合同", () => { expect(remote).toContain("traceId: row.traceId || null"); }); + test("debug-step 默认投影失败 TaskRun、step、exit code 与有界日志", () => { + const output = renderDebugStep({ + realRun: { + found: true, + pipelineRun: { id: "fixture", status: "False" }, + failureEvidence: { + taskRun: "fixture-build", + step: "build", + container: "step-build", + exitCode: 1, + terminationReason: "Error", + logReadOk: true, + logTail: ["module not found: scripts/missing.ts"], + }, + }, + checks: [], target: {}, next: {}, + }).renderedText; + expect(output).toContain("FAILED TASK EVIDENCE"); + expect(output).toContain("fixture-build"); + expect(output).toContain("step-build"); + expect(output).toContain("module not found: scripts/missing.ts"); + }); + + test("closeout requires GitOps ancestry and exact runtime digest", () => { + const digest = `sha256:${"a".repeat(64)}`; + const base = { + artifact: { imageStatus: "built", digest }, + argo: { revisionRelation: { relation: "descendant" } }, + runtime: { digest }, + }; + expect(closeoutDeliveryAligned(base)).toBe(true); + expect(closeoutDeliveryAligned({ ...base, argo: { revisionRelation: { relation: "stale" } } })).toBe(false); + expect(closeoutDeliveryAligned({ ...base, runtime: { digest: `sha256:${"b".repeat(64)}` } })).toBe(false); + expect(closeoutDeliveryAligned({ ...base, argo: { revisionRelation: { relation: "retained" } } })).toBe(false); + expect(closeoutDeliveryAligned({ + sourceObservation: { mode: "no-runtime-change", valid: true }, + diagnostics: { code: "pac-ready-no-runtime-change" }, + })).toBe(true); + }); + test("sentinel image build 显式等待 socket 与 worker readiness 并清理 daemon", () => { const shell = sentinelPublishImageBuildShell({ spec: { diff --git a/scripts/src/platform-infra-pipelines-as-code-remote.sh b/scripts/src/platform-infra-pipelines-as-code-remote.sh index 9a4eb136..c1b9c3c8 100644 --- a/scripts/src/platform-infra-pipelines-as-code-remote.sh +++ b/scripts/src/platform-infra-pipelines-as-code-remote.sh @@ -963,10 +963,22 @@ function rowFor(consumer, item, taskRuns) { const rows = []; const consumerSummaries = []; +const pipelineRunsByNamespace = new Map(); +const taskRunsByNamespace = new Map(); +function namespaceResources(cache, consumer, resource) { + if (!cache.has(consumer.namespace)) { + cache.set(consumer.namespace, kubectlJson( + ['-n', consumer.namespace, 'get', resource, '-o', 'json'], + { items: [] }, + `${consumer.namespace}:${resource}`, + )); + } + return cache.get(consumer.namespace); +} for (const consumer of consumers) { consumer.admissionState = admissionStateForConsumer(consumer); - const pipelineRuns = kubectlJson(['-n', consumer.namespace, 'get', 'pipelinerun', '-o', 'json'], { items: [] }, `${consumer.id}:pipelinerun`); - const taskRuns = kubectlJson(['-n', consumer.namespace, 'get', 'taskrun', '-o', 'json'], { items: [] }, `${consumer.id}:taskrun`); + const pipelineRuns = namespaceResources(pipelineRunsByNamespace, consumer, 'pipelinerun'); + const taskRuns = namespaceResources(taskRunsByNamespace, consumer, 'taskrun'); let matches = (pipelineRuns.items || []).filter((item) => { const name = item.metadata?.name || ''; if (detailId) return name === detailId && pipelineRunMatchesConsumer(consumer, item); @@ -1180,7 +1192,7 @@ const terminalRecords = contractTaskRuns.map(taskTerminalRecord).filter(Boolean) const logErrors = []; let logText = ''; let mode = 'taskrun-status-only'; -if (process.env.UNIDESK_PAC_ACTION !== 'status') { +if (process.env.UNIDESK_PAC_ACTION !== 'status' || process.env.UNIDESK_PAC_COLLECT_ARTIFACT_LOGS === '1') { mode = 'pipeline-run-logs'; const result = cp.spawnSync('kubectl', ['-n', namespace, 'logs', '-l', `tekton.dev/pipelineRun=${pipelineRun}`, '--all-containers=true', '--tail=320', '--max-log-requests=20'], { encoding: 'utf8', @@ -1925,6 +1937,7 @@ const history = JSON.parse(process.env.UNIDESK_PAC_DEBUG_HISTORY_JSON || '{}'); const rows = Array.isArray(history.rows) ? history.rows : []; const historyErrors = Array.isArray(history.errors) ? history.errors : []; const row = record(rows[0]); +const cp = require('node:child_process'); const sourceObservation = record(row.sourceObservation); const artifact = record(row.artifact); const catalog = record(artifact.catalog); @@ -1934,6 +1947,38 @@ const terminal = [ terminalRow(sourceObservation, 'collectArtifacts', 'collect-artifacts'), terminalRow(sourceObservation, 'gitopsPromote', 'gitops-promote'), ]; +function safeLogLine(value) { + return String(value || '') + .replace(/(authorization\s*[:=]\s*)(?:bearer\s+)?\S+/giu, '$1[REDACTED]') + .replace(/(token|password|secret|api[_-]?key)(\s*[:=]\s*)\S+/giu, '$1$2[REDACTED]') + .replace(/\b(?:sk|ghp|github_pat)-[A-Za-z0-9_-]{8,}\b/gu, '[REDACTED]') + .replace(/https?:\/\/[^\s/]+@/giu, 'https://[REDACTED]@') + .replace(/([?&][^=\s]+)=([^&\s]+)/gu, '$1=[REDACTED]') + .slice(0, 320); +} +function failedTaskEvidence() { + const task = record(record(row.taskRuns).failed); + if (typeof task.name !== 'string' || task.name.length === 0) return null; + const result = cp.spawnSync('kubectl', ['-n', process.env.UNIDESK_PAC_TARGET_NAMESPACE, 'logs', '-l', `tekton.dev/taskRun=${task.name}`, '--all-containers=true', '--prefix=true', '--tail=40', '--max-log-requests=10'], { + encoding: 'utf8', timeout: 8000, maxBuffer: 256 * 1024, + }); + const lines = String(result.stdout || result.stderr || '').split(/\r?\n/u).filter(Boolean).slice(-12).map(safeLogLine); + const step = record(task.failedStep); + return { + taskRun: task.name, + taskStatus: task.status || null, + taskReason: task.reason || null, + step: step.name || null, + container: step.container || null, + exitCode: Number.isInteger(step.exitCode) ? step.exitCode : null, + terminationReason: step.terminationReason || step.reason || null, + logReadOk: !result.error && result.status === 0, + logTail: lines, + truncated: lines.length >= 12, + valuesPrinted: false, + }; +} +const failureEvidence = failedTaskEvidence(); let firstBreak = null; function breakAt(code, stage, reason) { if (firstBreak === null) firstBreak = { code, stage, reason, valuesPrinted: false }; @@ -2009,6 +2054,7 @@ const realRun = { }, traceId: row.traceId || null, error: Object.keys(record(row.error)).length === 0 ? null : record(row.error), + failureEvidence, firstBreak, valuesPrinted: false, }; 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 7b85ab9b..6098430e 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 @@ -16,6 +16,7 @@ import { canonicalSha256, canonicalizePacPipelineSpec, firstPacSourceArtifactDrift, + inspectPacSourceEntrypoints, pacSourceArtifactSafeError, pacSourceArtifactProvenanceFromManifest, pipelineRunLabels, @@ -242,6 +243,35 @@ describe("PaC source artifact CLI contract", () => { expect(() => assertPacSourceArtifactVerifyWorktree("verify-runtime", sourceCommit, { ...exact, head: "b".repeat(40), matchesSourceCommit: false })).toThrow("source-worktree-commit-mismatch"); }); + test("source entrypoint inspection covers command args and inline shell scripts", () => { + const temporary = mkdtempSync(resolve(tmpdir(), "unidesk-pac-entrypoints-")); + try { + mkdirSync(resolve(temporary, "scripts"), { recursive: true }); + writeFileSync(resolve(temporary, "scripts", "present.ts"), "export {};\n"); + const inspection = inspectPacSourceEntrypoints({ + tasks: [ + { taskSpec: { steps: [{ command: ["bun"], args: ["scripts/present.ts"] }] } }, + { taskSpec: { steps: [{ command: ["bash", "-lc"], args: ["bun scripts/missing.ts --check"] }] } }, + { taskSpec: { steps: [{ script: "node scripts/present.ts\npython3 scripts/also-missing.py" }] } }, + { taskSpec: { steps: [{ script: "node scripts/../../outside.ts" }] } }, + ], + }, temporary); + expect(inspection).toEqual({ + aligned: false, + referenced: ["scripts/also-missing.py", "scripts/missing.ts", "scripts/present.ts"], + missing: ["scripts/also-missing.py", "scripts/missing.ts"], + }); + } finally { + rmSync(temporary, { recursive: true, force: true }); + } + }); + + test("source entrypoint inspection accepts existing repository-relative entries", () => { + expect(inspectPacSourceEntrypoints({ + tasks: [{ taskSpec: { steps: [{ script: "bun scripts/cli.ts --help" }] } }], + }, rootPath())).toEqual({ aligned: true, referenced: ["scripts/cli.ts"], missing: [] }); + }); + test("taskRunTemplate operational facts are declared in owning YAML", () => { const document = Bun.YAML.parse(readFileSync(rootPath("config", "platform-infra", "pipelines-as-code.yaml"), "utf8")) as Record; const consumers = document.consumers as Array>; diff --git a/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts b/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts index 096104a9..47cff733 100644 --- a/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts +++ b/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts @@ -149,6 +149,12 @@ interface SourceInspection { readonly provenanceAligned: boolean; } +export interface PacSourceEntrypointInspection { + readonly aligned: boolean; + readonly referenced: readonly string[]; + readonly missing: readonly string[]; +} + export interface SourceWorktreeState { readonly head: string; readonly clean: boolean; @@ -270,6 +276,7 @@ export async function runPacSourceArtifact( }; assertPacSourceArtifactVerifyWorktree(options.action, options.sourceCommit, sourceWorktreeState); const desired = renderDesiredArtifact(binding, sourceWorktree); + const sourceEntrypoints = inspectPacSourceEntrypoints(desired.desiredSpec, sourceWorktree); let source = inspectSourceArtifact(sourceWorktree, binding, desired); const written: string[] = []; if (options.action === "write") { @@ -288,7 +295,7 @@ export async function runPacSourceArtifact( sourceCommit: options.sourceCommit, }) : null; - const sourceOk = source.aligned && source.provenanceAligned; + const sourceOk = source.aligned && source.provenanceAligned && sourceEntrypoints.aligned; const runtimeAlignment = sourceArtifactRuntimeAlignment(runtime, sourceWorktreeState, options.sourceCommit, binding.consumer.sourceArtifact.mode); const runtimeOk = runtimeAlignment === "aligned"; const ok = options.action === "check" @@ -322,6 +329,7 @@ export async function runPacSourceArtifact( namespace: binding.consumer.namespace, }, source, + sourceEntrypoints, sourceWorktreeState, runtime, runtimeTarget: { sourceCommit: options.sourceCommit }, @@ -343,7 +351,7 @@ export async function runPacSourceArtifact( ], valuesPrinted: false, }, - next: sourceArtifactNext(binding, options, sourceOk, sourceWorktreeState, runtime, runtimeAlignment), + next: sourceArtifactNext(binding, options, sourceOk, sourceEntrypoints, sourceWorktreeState, runtime, runtimeAlignment), }; } @@ -378,14 +386,55 @@ export function sourceArtifactRuntimeAlignment( return "aligned"; } +export function inspectPacSourceEntrypoints(desiredSpec: unknown, sourceWorktree: string): PacSourceEntrypointInspection { + const referenced = new Set(); + const executable = /^(?:\.\/)?(?:scripts|src|deploy|config)\/[A-Za-z0-9._/-]+\.(?:cjs|js|mjs|py|sh|ts)$/u; + const collect = (value: string, executionContext: boolean): void => { + if (!executionContext && !/(?:^|[\s;&|])(?:bun|node|deno|tsx|python3?|bash|sh)(?:\s|$)/u.test(value)) return; + for (const raw of value.split(/[\s;&|()'"`]+/u)) { + const normalized = raw.replace(/^\.\//u, "").replace(/[,:]+$/u, ""); + if (executable.test(normalized) && !normalized.split("/").includes("..")) referenced.add(normalized); + } + }; + const visit = (value: unknown, executionContext = false): void => { + if (Array.isArray(value)) { + for (const item of value) visit(item, executionContext); + return; + } + if (value === null || typeof value !== "object") { + if (typeof value === "string") collect(value, executionContext); + return; + } + const item = value as Record; + const command = Array.isArray(item.command) ? item.command.filter((part): part is string => typeof part === "string") : []; + const commandExecutesSource = command.some((part) => /^(?:bun|node|deno|tsx|python3?|bash|sh)$/u.test(part)); + if (commandExecutesSource) { + for (const part of command) collect(part, true); + if (Array.isArray(item.args)) visit(item.args, true); + } + for (const [key, child] of Object.entries(item)) { + if ((key === "command" || key === "args") && commandExecutesSource) continue; + visit(child, executionContext); + } + }; + visit(desiredSpec); + const paths = [...referenced].sort(); + const missing = paths.filter((path) => !existsSync(resolve(sourceWorktree, path))); + return { aligned: missing.length === 0, referenced: paths, missing }; +} + function sourceArtifactNext( binding: PacSourceArtifactBinding, options: PacSourceArtifactOptions, sourceOk: boolean, + sourceEntrypoints: PacSourceEntrypointInspection, worktree: SourceWorktreeState, runtime: PacSourceArtifactRuntimeObservation | null, runtimeAlignment: PacSourceArtifactRuntimeAlignment, ): string { + if (!sourceEntrypoints.aligned) { + return "Restore the reported repository-relative source entrypoints, then rerun source-artifact check before publishing the source PR."; + } if (!sourceOk) { return options.action === "write" ? "Generated files remain inconsistent; inspect the safe firstDrift evidence and do not publish." @@ -412,6 +461,7 @@ export function renderPacSourceArtifactResult(result: Record): const files = record(result.files); const desired = record(result.desired); const source = record(result.source); + const sourceEntrypoints = record(result.sourceEntrypoints); const sourceWorktreeState = record(result.sourceWorktreeState); const runtime = result.runtime === null ? null : record(result.runtime); const live = runtime === null ? null : record(runtime.live); @@ -428,6 +478,7 @@ export function renderPacSourceArtifactResult(result: Record): `desired=${shortSha(desired.canonicalSha256)} source=${shortSha(source.pipelineCanonicalSha256)} aligned=${text(source.aligned)} provenance=${text(source.provenanceAligned)}`, `sourceWorktree=head:${shortSha(sourceWorktreeState.head)} clean:${text(sourceWorktreeState.clean)} commitMatch:${text(sourceWorktreeState.matchesSourceCommit)}`, `sourceDrift=${formatDrift(firstDrift)}`, + `sourceEntrypoints=aligned:${text(sourceEntrypoints.aligned)} referenced:${Array.isArray(sourceEntrypoints.referenced) ? sourceEntrypoints.referenced.length : 0} missing:${Array.isArray(sourceEntrypoints.missing) && sourceEntrypoints.missing.length > 0 ? sourceEntrypoints.missing.join(",") : "-"}`, `runtime=${text(result.runtimeAlignment)} commit=${text(embedded?.sourceCommit ?? record(result.runtimeTarget).sourceCommit)} candidates=${text(embedded?.candidateCount)}`, `live=status:${text(live?.status)} name:${text(live?.name)} hash:${shortSha(live?.canonicalSha256)} provenance:${text(live?.provenanceAligned)} drift:${formatDrift(liveDrift)}`, `embedded=status:${text(embedded?.status)} name:${text(embedded?.name)} hash:${shortSha(embedded?.canonicalSha256)} provenance:${text(embedded?.provenanceAligned)} drift:${formatDrift(embeddedDrift)}`, diff --git a/scripts/src/platform-infra-pipelines-as-code.ts b/scripts/src/platform-infra-pipelines-as-code.ts index ab4b560e..a4c1990b 100644 --- a/scripts/src/platform-infra-pipelines-as-code.ts +++ b/scripts/src/platform-infra-pipelines-as-code.ts @@ -1436,14 +1436,14 @@ function bootstrapResult( }; } -async function status(config: UniDeskConfig, options: CommonOptions): Promise> { +async function status(config: UniDeskConfig, options: CommonOptions, collectArtifactLogs = false): Promise> { const pac = readPacConfig({ consumerId: options.consumerId, selectDefault: true }); const target = resolveTarget(pac, options.targetId); const consumer = resolveConsumer(pac, options.consumerId); const repository = resolveRepository(pac, consumer.repositoryRef); const secrets = ensureSecrets(pac, false); const next = nextCommands(target.id, consumer.id, pac.defaults.consumerId); - const observed = await capturePacReadOnly(config, pac, "status", target, consumer, remoteScript("status", pac, target, repository, consumer, { ...options, confirm: false, dryRun: true, wait: false }, secrets, ""), `${stringValue(next.status)} --full`); + const observed = await capturePacReadOnly(config, pac, "status", target, consumer, remoteScript("status", pac, target, repository, consumer, { ...options, confirm: false, dryRun: true, wait: false }, secrets, "", [consumer], collectArtifactLogs), `${stringValue(next.status)} --full`); const { result, parsed, observation } = observed; const summary = parsed === null ? null : statusSummary(parsed); const deliveryAuthority = resolveCicdDeliveryAuthority({ consumerId: consumer.id, node: consumer.node, lane: consumer.lane }); @@ -1574,7 +1574,7 @@ async function closeout(config: UniDeskConfig, options: CloseoutOptions): Promis diagnostics = record(summary.diagnostics); observedSourceCommit = observedCloseoutSourceCommit(latest, diagnostics); sourceMatched = options.sourceCommit === null || observedSourceCommit === options.sourceCommit; - const baseReady = current.ok === true && sourceMatched; + const baseReady = closeoutReady(current, options.sourceCommit); gitOpsMirrorFlush = { ...record(gitOpsMirrorFlush), afterSummary: summary, @@ -1660,7 +1660,7 @@ function closeoutCursor(value: Record): string { async function observeCloseoutStatus(config: UniDeskConfig, options: CloseoutOptions, target: PacTarget, consumer: PacConsumer, phase: "ci" | "runtime", attempt: number, startedAt: number): Promise> { printCloseoutProgress(target, consumer, `${phase}-status`, "started", startedAt, { attempt }); - const current = await status(config, options); + const current = await status(config, options, true); const summary = record(current.summary); const latest = record(summary.latestPipelineRun); const diagnostics = record(summary.diagnostics); @@ -1728,6 +1728,7 @@ async function history(config: UniDeskConfig, options: HistoryOptions): Promise< config: { path: configLabel, source: "Gitea Repository CR + Tekton PipelineRun/TaskRun live objects; no UniDesk-owned history store is written.", + scope: selectedConsumers.length === 1 ? "single-consumer" : "target-summary", limitPerConsumer: options.limit, displayTimeZone: pac.display.timeZone, valuesPrinted: false, @@ -2046,7 +2047,7 @@ export class PacReleaseManifestRenderError extends Error { } } -function remoteScript(action: "apply" | "status" | "history" | "diagnose-regression" | "debug-step", pac: PacConfig, target: PacTarget, repository: PacRepository, consumer: PacConsumer, options: ApplyOptions | HistoryOptions, secrets: SecretMaterial, releaseManifest: string, historyConsumers: PacConsumer[] = [consumer]): string { +function remoteScript(action: "apply" | "status" | "history" | "diagnose-regression" | "debug-step", pac: PacConfig, target: PacTarget, repository: PacRepository, consumer: PacConsumer, options: ApplyOptions | HistoryOptions, secrets: SecretMaterial, releaseManifest: string, historyConsumers: PacConsumer[] = [consumer], collectArtifactLogs = false): string { const webhookUrl = `${pac.gitea.internalBaseUrl.replace(/\/+$/u, "").replace(/gitea-http\.[^.]+\.svc\.cluster\.local:3000/u, `${pac.release.controllerServiceName}.${pac.release.namespace}.svc.cluster.local:${pac.release.controllerServicePort}`)}`; const admissionIdentity = pacAdmissionDesiredIdentity(target.id); const rbacIdentity = pacConsumerRbacDesiredIdentity(target.id); @@ -2054,6 +2055,7 @@ function remoteScript(action: "apply" | "status" | "history" | "diagnose-regress const repositoryOwner = pac.consumers.find((item) => item.repositoryRef === repository.id); const env: Record = { UNIDESK_PAC_ACTION: action, + UNIDESK_PAC_COLLECT_ARTIFACT_LOGS: collectArtifactLogs ? "1" : "0", UNIDESK_PAC_EVALUATOR_B64: Buffer.from(readFileSync(evaluatorFile, "utf8"), "utf8").toString("base64"), UNIDESK_PAC_FIELD_MANAGER: fieldManager, UNIDESK_PAC_TARGET_ID: target.id, @@ -3244,6 +3246,8 @@ export function renderDebugStep(result: Record): RenderedCliRes const terminal = arrayRecords(realRun.terminal); const firstBreak = record(realRun.firstBreak); const typedError = record(realRun.error); + const failureEvidence = record(realRun.failureEvidence); + const failureLogTail = Array.isArray(failureEvidence.logTail) ? failureEvidence.logTail.filter((item): item is string => typeof item === "string") : []; const fixturePassed = checks.filter((item) => item.ok === true).length; const lines = [ "PLATFORM-INFRA PIPELINES-AS-CODE DEBUG STEP", @@ -3293,6 +3297,20 @@ export function renderDebugStep(result: Record): RenderedCliRes short(stringValue(artifact.gitopsCommit), 16), ]]), renderFeatureConfigSchemaLine(record(artifact.sourceObservation).featureConfigSchema), + ...(Object.keys(failureEvidence).length === 0 ? [] : [ + "", + "FAILED TASK EVIDENCE", + ...table(["TASKRUN", "STEP", "CONTAINER", "EXIT", "REASON", "LOG_READ"], [[ + short(stringValue(failureEvidence.taskRun), 52), + stringValue(failureEvidence.step), + stringValue(failureEvidence.container), + stringValue(failureEvidence.exitCode), + stringValue(failureEvidence.terminationReason ?? failureEvidence.taskReason), + boolText(failureEvidence.logReadOk), + ]]), + " log-tail:", + ...(failureLogTail.length === 0 ? [" -"] : failureLogTail.map((line) => ` ${line}`)), + ]), ]), "", "STATUS EVALUATOR FIXTURES", @@ -3457,8 +3475,23 @@ function closeoutReady(value: Record, sourceCommit: string | nu const latest = record(summary.latestPipelineRun); const diagnostics = record(summary.diagnostics); const observedSourceCommit = observedCloseoutSourceCommit(latest, diagnostics); - if (sourceCommit === null) return true; - return observedSourceCommit === sourceCommit; + if (sourceCommit !== null && observedSourceCommit !== sourceCommit) return false; + return closeoutDeliveryAligned(summary); +} + +export function closeoutDeliveryAligned(summary: Record): boolean { + const artifact = record(summary.artifact); + const sourceObservation = record(summary.sourceObservation); + const diagnostics = record(summary.diagnostics); + if (stringValue(sourceObservation.mode) === "no-runtime-change" + && sourceObservation.valid === true + && stringValue(diagnostics.code) === "pac-ready-no-runtime-change") return true; + if (stringValue(artifact.imageStatus) === "disabled") return true; + const relation = stringValue(record(record(summary.argo).revisionRelation).relation); + if (!["exact", "descendant"].includes(relation)) return false; + const expectedDigest = stringValue(artifact.digest); + const runtimeDigest = stringValue(record(summary.runtime).digest); + return /^sha256:[0-9a-f]{64}$/u.test(expectedDigest) && runtimeDigest === expectedDigest; } function closeoutCiReady(value: Record, sourceCommit: string | null): boolean {