fix: 改进 PaC 首次接入诊断

This commit is contained in:
Codex
2026-07-16 11:56:47 +02:00
parent 6a512e51be
commit fefec604aa
8 changed files with 273 additions and 17 deletions
+7
View File
@@ -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>` 必须读取目标侧真实 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,再继续只读定位。
@@ -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 rendererlive 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 <NODE> --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 <NODE> --limit 10` 审计全部 consumer 的触发、耗时与 reuse
- target 汇总读取实时 Gitea Repository CR 与 Tekton PipelineRun/TaskRun,并按 namespace 复用读取、聚合和显式输出 `READ_ERRORS`
- 指定 `--consumer <id>` 或可唯一归属的 `--id` 时只走单 consumer 快路径;
- `config.scope` 明确显示 `target-summary``single-consumer`
- 单次交付耗时查询:
- 使用 `bun scripts/cli.ts platform-infra pipelines-as-code delivery-timing --target <NODE> --consumer <id>`
@@ -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 <NODE> --consumer <id>`
- `bun scripts/cli.ts platform-infra pipelines-as-code history --target <NODE> --consumer <id> --limit 10`
- 对归属或状态不明的行,使用 `bun scripts/cli.ts platform-infra pipelines-as-code history --target <NODE> --id <pipelinerun>` 下钻 PipelineRun。
- 已知失败 PipelineRun 时使用 `debug-step --target <NODE> --consumer <id> --id <pipelinerun>`;默认结果一次包含失败 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 提交必须分层:
@@ -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 运行面或其他服务配置。
@@ -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: {
@@ -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,
};
@@ -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<string, any>;
const consumers = document.consumers as Array<Record<string, any>>;
@@ -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<string>();
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<string, unknown>;
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<string, unknown>):
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<string, unknown>):
`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)}`,
@@ -1436,14 +1436,14 @@ function bootstrapResult(
};
}
async function status(config: UniDeskConfig, options: CommonOptions): Promise<Record<string, unknown>> {
async function status(config: UniDeskConfig, options: CommonOptions, collectArtifactLogs = false): Promise<Record<string, unknown>> {
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, unknown>): string {
async function observeCloseoutStatus(config: UniDeskConfig, options: CloseoutOptions, target: PacTarget, consumer: PacConsumer, phase: "ci" | "runtime", attempt: number, startedAt: number): Promise<Record<string, unknown>> {
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<string, string> = {
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<string, unknown>): 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<string, unknown>): 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<string, unknown>, 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<string, unknown>): 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<string, unknown>, sourceCommit: string | null): boolean {