fix: 改进 PaC 首次接入诊断
This commit is contained in:
@@ -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 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 <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 {
|
||||
|
||||
Reference in New Issue
Block a user