From 2dd8814078e19e7d663004038e5b3c1488ee97f2 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 11 Jul 2026 07:03:12 +0200 Subject: [PATCH] fix(cicd): harden PaC source artifact verification --- .agents/skills/unidesk-cicd/SKILL.md | 10 +- .../unidesk-cicd/references/gitea-pac.md | 20 +- config/agentrun.yaml | 1 + config/platform-infra/pipelines-as-code.yaml | 8 + .../cicd/pac-source-artifact-runtime.mjs | 168 +++++++-- scripts/src/agentrun-lanes.ts | 2 + ...-pipelines-as-code-source-artifact.test.ts | 349 ++++++++++++++++-- ...infra-pipelines-as-code-source-artifact.ts | 346 +++++++++++++---- .../src/platform-infra-pipelines-as-code.ts | 148 ++++++-- 9 files changed, 882 insertions(+), 170 deletions(-) diff --git a/.agents/skills/unidesk-cicd/SKILL.md b/.agents/skills/unidesk-cicd/SKILL.md index 88c210a7..27503802 100644 --- a/.agents/skills/unidesk-cicd/SKILL.md +++ b/.agents/skills/unidesk-cicd/SKILL.md @@ -51,7 +51,15 @@ bun scripts/cli.ts platform-infra pipelines-as-code source-artifact verify-runti - CI/CD source authority 只能来自 YAML 声明的 Kubernetes 托管 source authority:legacy lane 使用 k8s git-mirror snapshot,Gitea/PaC migrated lane 使用 GitHub PR merge -> GitHub webhook bridge -> Gitea controlled mirror + immutable snapshot ref -> Pipelines-as-Code。受控命令先在 k8s 内同步/创建不可变 `refs/unidesk/snapshots/.../` stage ref;build/status/publish 只消费该 snapshot,host worktree、本地 `git fetch/pull`、可变 branch ref 或 Pipeline 内直连 GitHub 都不能作为 authoritative source。 - JD01/NC01 `agentrun--v02`、`sentinel--v03`、`hwlab--v03` 的正式 CI/CD closeout 入口是 `cicd status --node ` 和 `platform-infra pipelines-as-code closeout|status|history --target --consumer `。`closeout` 是通用 consumer 引导入口,只读取/等待 PaC、Tekton、GitOps、Argo 和 runtime 对齐,不触发旧手动发布。`cicd branch-follower` 和 `cicd gitea-actions-poc` 对这些 consumer 只保留历史/迁移只读用途,不得作为当前交付判断入口。 - PaC `.tekton` 文件必须用 Repository CR 的 target/node 参数隔离 JD01/NC01,避免同一个 Gitea push 在一个 target cluster 内额外创建另一个 target 的 PipelineRun;history/detail 必须按实际 PipelineRun prefix/pipeline 归属 consumer。 -- PaC source artifact 必须由 `config/platform-infra/pipelines-as-code.yaml#consumers[].sourceArtifact` 显式声明,并通过 `source-artifact plan|check|write|status|verify-runtime` 管理;desired 只来自 owning YAML 与共享 renderer,禁止从 live Pipeline/PipelineRun 反向导出。`verify-runtime` 必须绑定完整 source commit,未声明 owner 或证据冲突时 fail-closed。 +- PaC source artifact 必须遵循以下边界: + - 由 `config/platform-infra/pipelines-as-code.yaml#consumers[].sourceArtifact` 显式声明; + - 通过 `source-artifact plan|check|write|status|verify-runtime` 管理; + - `taskRunTemplate` 等运行事实归属 owning YAML,代码只负责校验和渲染; + - desired 只来自 owning YAML 与共享 renderer,禁止从 live Pipeline/PipelineRun 反向导出; + - `verify-runtime` 使用干净且 `HEAD` 等于完整 source commit 的独立 worktree; + - 未声明 owner、worktree 证据不成立或运行面证据冲突时 fail-closed; + - 错误和 drift 在默认、`--json`、`--full` 中都只能披露路径、类型、长度、SHA-256 或 URL fingerprint; + - 禁止回显脚本、URL 凭据、查询参数、Authorization、Secret 和远端 stderr。 - PaC source-only closeout 必须使用目标侧结构化证据: - `g14-ci-plan` 的 source commit、affected/build/rollout 计数必须完整; - `collect-artifacts` 与 `gitops-promote` 必须同时明确 `no-build-no-rollout-plan`; diff --git a/.agents/skills/unidesk-cicd/references/gitea-pac.md b/.agents/skills/unidesk-cicd/references/gitea-pac.md index 51132ba6..ef091b84 100644 --- a/.agents/skills/unidesk-cicd/references/gitea-pac.md +++ b/.agents/skills/unidesk-cicd/references/gitea-pac.md @@ -34,11 +34,25 @@ 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 更新;连续执行必须无差异。 -- `status` 是非门禁诊断,允许不传 commit;`verify-runtime` 必须传完整四十位 `--source-commit`,并按 PaC/source-commit label 或 annotation 精确筛选 PipelineRun,不得按 prefix 直接取全局 latest。 +- `status` 是非门禁诊断,允许不传 commit。 +- `verify-runtime` 必须满足以下条件: + - 传入完整四十位 `--source-commit`; + - `--source-worktree` 干净; + - worktree `HEAD` 与该 commit 完全相同; + - 合并提交的验收新建精确提交 worktree,不使用带未提交修改或停在父提交的开发 worktree; + - PipelineRun 按 PaC/source-commit label 或 annotation 精确筛选,不按 prefix 直接取全局 latest。 - 同一 source commit 因 webhook 重投或 PaC retry 产生多个 PipelineRun 时,只在完整内联 spec 与 provenance 全部一致时确定性选择最新一条并披露候选数;存在冲突必须 fail-closed。 -- runtime observer 必须区分对象 `NotFound`、transport/RBAC/命令不可用和 spec drift;完整大 spec 通过受控临时文件传入目标侧原生 observer,不得放入 shell argv 或环境变量。 +- runtime observer 必须满足以下约束: + - 区分对象 `NotFound`、transport/RBAC/命令不可用和 spec drift; + - 完整大 spec 通过受控临时文件传入目标侧原生 observer,不放入 shell argv 或环境变量; + - 默认、`--json` 和 `--full` 的错误与 drift 只输出结构路径、类型、长度、SHA-256 或 URL fingerprint; + - 不回显脚本、URL 凭据、查询参数、Authorization、Secret、stdout 或 stderr 原文。 - 完整 spec 比较只移除六类已验证的 Tekton admission default,并限定在裸 Pipeline spec、完整 Pipeline 或 PipelineRun `pipelineSpec` 三个明确根;其他同名字段不得过滤。 -- HWLAB source artifact 必须保留正式 postprocess、verify 和六个 Kafka refresh 环境合同;AgentRun source artifact 必须保留 owning Pipeline 的完整 RBAC、参数和 manager 环境合同。 +- `sourceArtifact.taskRunTemplate` 是 `hostNetwork`、`dnsPolicy` 和 `fsGroup` 的唯一所有者;生成器不得另设硬编码默认值。 +- HWLAB source artifact 必须经过完整 domain renderer、正式 postprocess/verify 和完整 spec 比较。 + - Kafka refresh 等业务合同由 owning renderer 与消费者结构化负例测试保障; + - 不得在通用生成器中用字符串 marker 扫描建立第二真相。 +- AgentRun source artifact 通过完整 owning Pipeline 与完整 spec 比较保留 RBAC、参数和 manager 环境合同。 ## Coverage Matrix diff --git a/config/agentrun.yaml b/config/agentrun.yaml index 7ead6dec..ae60ce2a 100644 --- a/config/agentrun.yaml +++ b/config/agentrun.yaml @@ -454,6 +454,7 @@ controlPlane: source: statusMode: k3s-git-mirror repository: pikasTech/agentrun + worktreeRemote: git@github.com:pikasTech/agentrun.git branch: v0.2 sourceAuthority: mode: gitMirrorSnapshot diff --git a/config/platform-infra/pipelines-as-code.yaml b/config/platform-infra/pipelines-as-code.yaml index 3f537f05..3ad13012 100644 --- a/config/platform-infra/pipelines-as-code.yaml +++ b/config/platform-infra/pipelines-as-code.yaml @@ -110,6 +110,10 @@ consumers: configRef: config/agentrun.yaml#controlPlane.lanes.nc01-v02 pipelineRunPath: .tekton/agentrun-nc01-v02.yaml maxKeepRuns: 8 + taskRunTemplate: + hostNetwork: true + dnsPolicy: ClusterFirstWithHostNet + fsGroup: 1000 - extends: templates.consumers.sentinelV03 variables: NODE: JD01 @@ -151,6 +155,10 @@ consumers: pipelinePath: ci/pipelines/hwlab-nc01-v03-ci-image-publish.yaml pipelineRunPath: .tekton/hwlab-nc01-v03-pac.yaml maxKeepRuns: 8 + taskRunTemplate: + hostNetwork: true + dnsPolicy: ClusterFirstWithHostNet + fsGroup: 1000 templates: repositories: agentrunV02: diff --git a/scripts/native/cicd/pac-source-artifact-runtime.mjs b/scripts/native/cicd/pac-source-artifact-runtime.mjs index 6fbf92bf..50e003a0 100644 --- a/scripts/native/cicd/pac-source-artifact-runtime.mjs +++ b/scripts/native/cicd/pac-source-artifact-runtime.mjs @@ -6,6 +6,7 @@ const provenanceKeys = { configRef: "unidesk.ai/owning-config-ref", effectiveConfigSha256: "unidesk.ai/effective-config-sha256", renderer: "unidesk.ai/source-artifact-renderer", + mode: "unidesk.ai/source-artifact-mode", }; const sourceCommitKeys = [ @@ -16,14 +17,59 @@ const sourceCommitKeys = [ "hwlab.pikastech.local/source-commit", ]; +const knownDriftPathKeys = new Set([ + "accessModes", "annotations", "apiVersion", "args", "command", "computeResources", "default", "description", "dnsPolicy", "env", "envFrom", + "executionMode", "finally", "fsGroup", "generateName", "hostNetwork", "image", "kind", "labels", "length", "metadata", "mode", "mountPath", "name", "namespace", "onError", "operator", + "optional", "params", "pipeline", "pipelineRef", "pipelineSpec", "podTemplate", "readinessProbe", "readOnly", "requests", "resources", + "results", "retries", "runAfter", "script", "secret", "secretName", "securityContext", "serviceAccountName", "sidecars", "spec", "steps", + "storage", "subPath", "taskRunTemplate", "taskSpec", "tasks", "timeout", "timeouts", "type", "value", "values", "volumeClaimTemplate", + "volumeMounts", "volumes", "when", "workspaces", "workingDir", "wrapper", +]); + function record(value) { return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {}; } -function compact(value) { - const raw = JSON.stringify(value); - if (raw === undefined) return String(value); - return raw.length <= 160 ? raw : `${raw.slice(0, 157)}...`; +function stableValue(value) { + if (Array.isArray(value)) return value.map(stableValue); + if (value !== null && typeof value === "object") { + return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stableValue(value[key])])); + } + if (value === undefined) return { __type: "undefined" }; + if (typeof value === "bigint") return { __type: "bigint", value: String(value) }; + return value; +} + +function valueEvidence(value) { + const type = value === null ? "null" : Array.isArray(value) ? "array" : typeof value; + const serialized = JSON.stringify(stableValue(value)); + const text = serialized === undefined ? String(value) : serialized; + const length = typeof value === "string" + ? Buffer.byteLength(value, "utf8") + : Array.isArray(value) + ? value.length + : value !== null && typeof value === "object" + ? Object.keys(value).length + : Buffer.byteLength(text, "utf8"); + const sha256 = createHash("sha256").update(text).digest("hex"); + return `type=${type},length=${length},sha256=${sha256}`; +} + +function safeRuntimeReason(reason) { + const text = typeof reason === "string" ? reason : String(reason); + const fixed = new Set([ + "source-commit-not-specified", + "source-commit-run-not-found", + "source-commit-identity-conflict", + "pipeline-get-not-found", + "pipelinerun-list-not-found", + "pipelinerun-list-invalid-shape", + ]); + if (fixed.has(text)) return text; + if (/^source-commit-run-conflict:\d+$/u.test(text)) return text; + if (/^(?:pipeline-get|pipelinerun-list)-command-failed:(?:unknown|\d+):type=string,length=\d+,sha256=[0-9a-f]{64}$/u.test(text)) return text; + if (/^runtime-observer-input-error:type=string,length=\d+,sha256=[0-9a-f]{64}$/u.test(text)) return text; + return `runtime-observer-reason:${valueEvidence(text)}`; } function unavailable(reason, sourceCommit = null, candidateCount = 0) { @@ -36,7 +82,7 @@ function unavailable(reason, sourceCommit = null, candidateCount = 0) { configRef: null, effectiveConfigSha256: null, sourceCommit, - reason, + reason: safeRuntimeReason(reason), candidateCount, }; } @@ -82,8 +128,8 @@ export function canonicalPacPipelineSha256(value) { function firstCanonicalDrift(expected, actual, path = "$") { if (Object.is(expected, actual)) return null; if (Array.isArray(expected) || Array.isArray(actual)) { - if (!Array.isArray(expected) || !Array.isArray(actual)) return { path, expected: compact(expected), actual: compact(actual) }; - if (expected.length !== actual.length) return { path: `${path}.length`, expected: String(expected.length), actual: String(actual.length) }; + if (!Array.isArray(expected) || !Array.isArray(actual)) return { path, expected: valueEvidence(expected), actual: valueEvidence(actual) }; + if (expected.length !== actual.length) return { path: `${path}.length`, expected: valueEvidence(expected.length), actual: valueEvidence(actual.length) }; for (let index = 0; index < expected.length; index += 1) { const drift = firstCanonicalDrift(expected[index], actual[index], `${path}[${index}]`); if (drift !== null) return drift; @@ -93,18 +139,21 @@ function firstCanonicalDrift(expected, actual, path = "$") { const expectedRecord = expected !== null && typeof expected === "object"; const actualRecord = actual !== null && typeof actual === "object"; if (expectedRecord || actualRecord) { - if (!expectedRecord || !actualRecord) return { path, expected: compact(expected), actual: compact(actual) }; + if (!expectedRecord || !actualRecord) return { path, expected: valueEvidence(expected), actual: valueEvidence(actual) }; const keys = [...new Set([...Object.keys(expected), ...Object.keys(actual)])].sort(); for (const key of keys) { - if (!Object.prototype.hasOwnProperty.call(expected, key) || !Object.prototype.hasOwnProperty.call(actual, key)) { - return { path: `${path}.${key}`, expected: compact(expected[key]), actual: compact(actual[key]) }; + const expectedOwnsKey = Object.prototype.hasOwnProperty.call(expected, key); + const actualOwnsKey = Object.prototype.hasOwnProperty.call(actual, key); + const childPath = knownDriftPathKeys.has(key) ? `${path}.${key}` : `${path}.[key:${valueEvidence(key)}]`; + if (!expectedOwnsKey || !actualOwnsKey) { + return { path: childPath, expected: valueEvidence(expected[key]), actual: valueEvidence(actual[key]) }; } - const drift = firstCanonicalDrift(expected[key], actual[key], `${path}.${key}`); + const drift = firstCanonicalDrift(expected[key], actual[key], childPath); if (drift !== null) return drift; } return null; } - return { path, expected: compact(expected), actual: compact(actual) }; + return { path, expected: valueEvidence(expected), actual: valueEvidence(actual) }; } function firstDrift(expected, actual) { @@ -119,10 +168,11 @@ function kubectlJson(args, label) { }; } catch (error) { const stderr = typeof error?.stderr === "string" ? error.stderr : Buffer.isBuffer(error?.stderr) ? error.stderr.toString("utf8") : ""; - const message = stderr.trim().split(/\r?\n/u)[0] || String(error?.message ?? "command failed"); + const stdout = typeof error?.stdout === "string" ? error.stdout : Buffer.isBuffer(error?.stdout) ? error.stdout.toString("utf8") : ""; + const message = stderr.trim().split(/\r?\n/u)[0] || stdout.trim().split(/\r?\n/u)[0] || String(error?.message ?? "command failed"); if (/\bNotFound\b|\bnot found\b/u.test(message)) return { kind: "not-found", reason: `${label}-not-found` }; const status = Number.isInteger(error?.status) ? error.status : "unknown"; - return { kind: "unavailable", reason: `${label}-command-failed:${status}:${compact(message)}` }; + return { kind: "unavailable", reason: `${label}-command-failed:${status}:${valueEvidence(message)}` }; } } @@ -132,6 +182,7 @@ function manifestProvenance(manifest) { configRef: typeof annotations[provenanceKeys.configRef] === "string" ? annotations[provenanceKeys.configRef] : null, effectiveConfigSha256: typeof annotations[provenanceKeys.effectiveConfigSha256] === "string" ? annotations[provenanceKeys.effectiveConfigSha256] : null, renderer: typeof annotations[provenanceKeys.renderer] === "string" ? annotations[provenanceKeys.renderer] : null, + mode: typeof annotations[provenanceKeys.mode] === "string" ? annotations[provenanceKeys.mode] : null, }; } @@ -144,20 +195,68 @@ function manifestSourceCommits(manifest) { .map((value) => value.toLowerCase()))]; } -function observedItem(input, manifest, spec, sourceCommit = null, candidateCount = 0) { +function safeManifestName(value) { + return typeof value === "string" + && value.length <= 253 + && /^[a-z0-9](?:[-a-z0-9.]*[a-z0-9])?$/u.test(value) + ? value + : null; +} + +function safeAlignedConfigRef(observed, expected) { + return observed === expected + && typeof expected === "string" + && expected.length <= 512 + && /^[A-Za-z0-9_./-]+#[A-Za-z0-9_.-]+$/u.test(expected) + ? expected + : null; +} + +function safeAlignedSha256(observed, expected) { + return observed === expected && typeof expected === "string" && /^sha256:[0-9a-f]{64}$/u.test(expected) ? expected : null; +} + +function pipelineRunWrapper(manifest) { + const spec = record(manifest?.spec); + const hasPipelineSpec = Object.prototype.hasOwnProperty.call(spec, "pipelineSpec"); + const hasPipelineRef = Object.prototype.hasOwnProperty.call(spec, "pipelineRef"); + const observedMode = manifestProvenance(manifest).mode; + const mode = observedMode === "embedded-pipeline-spec" || observedMode === "remote-pipeline-annotation" ? observedMode : "invalid"; + const executionMode = hasPipelineSpec && !hasPipelineRef ? "pipeline-spec" : "invalid"; + const wrapperSpec = { ...spec }; + delete wrapperSpec.pipelineSpec; + delete wrapperSpec.pipelineRef; + return { mode, executionMode, spec: wrapperSpec }; +} + +function observedItem(input, manifest, spec, sourceCommit = null, candidateCount = 0, wrapper = null) { const observedProvenance = manifestProvenance(manifest); const provenanceAligned = observedProvenance.configRef === input.provenance.configRef && observedProvenance.effectiveConfigSha256 === input.provenance.effectiveConfigSha256 - && observedProvenance.renderer === input.provenance.renderer; - const drift = firstDrift(input.desiredSpec, spec); + && observedProvenance.renderer === input.provenance.renderer + && observedProvenance.mode === input.provenance.mode; + const comparePipelineSpec = wrapper === null || wrapper.executionMode === "pipeline-spec"; + const specDrift = comparePipelineSpec ? firstDrift(input.desiredSpec, spec) : null; + const wrapperDrift = wrapper === null || input.desiredWrapper === null + ? null + : firstDrift(input.desiredWrapper, wrapper); + const drift = specDrift ?? wrapperDrift; return { status: drift === null ? "aligned" : "drifted", - name: typeof record(manifest.metadata).name === "string" ? record(manifest.metadata).name : null, - canonicalSha256: canonicalPacPipelineSha256(spec), + name: safeManifestName(record(manifest.metadata).name), + canonicalSha256: canonicalPacPipelineSha256( + wrapper === null + ? spec + : wrapper.executionMode === "pipeline-spec" + ? { pipelineSpec: spec, wrapper } + : { wrapper }, + ), firstDrift: drift, provenanceAligned, - configRef: observedProvenance.configRef, - effectiveConfigSha256: observedProvenance.effectiveConfigSha256, + configRef: safeAlignedConfigRef(observedProvenance.configRef, input.provenance.configRef), + effectiveConfigSha256: safeAlignedSha256(observedProvenance.effectiveConfigSha256, input.provenance.effectiveConfigSha256), + renderer: observedProvenance.renderer === input.provenance.renderer ? input.provenance.renderer : null, + mode: observedProvenance.mode === input.provenance.mode ? input.provenance.mode : null, sourceCommit, reason: null, candidateCount, @@ -205,11 +304,23 @@ export function observePacSourceArtifactRuntime(input, readKubectlJson = kubectl const selected = selectPipelineRunBySourceCommit(items, input.pipelineRunPrefix, input.sourceCommit); if (selected.kind === "ok") { const signatures = selected.candidates.map((run) => JSON.stringify({ - canonicalSha256: canonicalPacPipelineSha256(record(record(run.spec).pipelineSpec)), + canonicalSha256: (() => { + const wrapper = pipelineRunWrapper(run); + return canonicalPacPipelineSha256(wrapper.executionMode === "pipeline-spec" + ? { pipelineSpec: record(record(run.spec).pipelineSpec), wrapper } + : { wrapper }); + })(), provenance: manifestProvenance(run), })); embedded = new Set(signatures).size === 1 - ? observedItem(input, selected.run, record(record(selected.run.spec).pipelineSpec), input.sourceCommit, selected.candidates.length) + ? observedItem( + input, + selected.run, + record(record(selected.run.spec).pipelineSpec), + input.sourceCommit, + selected.candidates.length, + pipelineRunWrapper(selected.run), + ) : unavailable(`source-commit-run-conflict:${selected.candidates.length}`, input.sourceCommit, selected.candidates.length); } else { embedded = selected.kind === "missing" @@ -222,17 +333,16 @@ export function observePacSourceArtifactRuntime(input, readKubectlJson = kubectl return { live, embedded }; } -if (typeof process.env.PAC_SOURCE_ARTIFACT_INPUT_FILE === "string" || typeof process.env.PAC_SOURCE_ARTIFACT_INPUT === "string") { +if (typeof process.env.PAC_SOURCE_ARTIFACT_INPUT_FILE === "string") { try { - const inputText = typeof process.env.PAC_SOURCE_ARTIFACT_INPUT_FILE === "string" - ? readFileSync(process.env.PAC_SOURCE_ARTIFACT_INPUT_FILE, "utf8") - : Buffer.from(process.env.PAC_SOURCE_ARTIFACT_INPUT, "base64").toString("utf8"); + const inputText = readFileSync(process.env.PAC_SOURCE_ARTIFACT_INPUT_FILE, "utf8"); const input = JSON.parse(inputText); process.stdout.write(JSON.stringify(observePacSourceArtifactRuntime(input))); } catch (error) { + const evidence = valueEvidence(String(error?.message ?? error)); process.stdout.write(JSON.stringify({ - live: unavailable(`runtime-observer-input-error:${compact(String(error?.message ?? error))}`), - embedded: unavailable(`runtime-observer-input-error:${compact(String(error?.message ?? error))}`), + live: unavailable(`runtime-observer-input-error:${evidence}`), + embedded: unavailable(`runtime-observer-input-error:${evidence}`), })); } } diff --git a/scripts/src/agentrun-lanes.ts b/scripts/src/agentrun-lanes.ts index 682ee63c..05615f30 100644 --- a/scripts/src/agentrun-lanes.ts +++ b/scripts/src/agentrun-lanes.ts @@ -108,6 +108,7 @@ export interface AgentRunLaneSpec { readonly source: { readonly statusMode: "host-worktree" | "k3s-git-mirror"; readonly repository: string; + readonly worktreeRemote: string; readonly branch: string; readonly sourceAuthority: AgentRunSourceAuthoritySpec | null; readonly sourceSnapshot: AgentRunSourceSnapshotSpec | null; @@ -595,6 +596,7 @@ function parseLane(lane: string, node: AgentRunNodeSpec, input: Record { return { "unidesk.ai/owning-config-ref": provenance.configRef, "unidesk.ai/effective-config-sha256": provenance.effectiveConfigSha256, "unidesk.ai/source-artifact-renderer": provenance.renderer, + "unidesk.ai/source-artifact-mode": provenance.mode, }; } @@ -51,7 +71,7 @@ function pipelineRun(name: string, creationTimestamp: string, spec: Record { test("verify-runtime requires and normalizes a full source commit", () => { expect(() => parsePacSourceArtifactOptions([ "verify-runtime", "--target", "NC01", "--consumer", "agentrun-nc01-v02", "--source-worktree", "/tmp/repo", - ])).toThrow("requires explicit --source-commit"); + ])).toThrow("source-commit-required"); const parsed = parsePacSourceArtifactOptions([ "verify-runtime", "--target", "NC01", "--consumer", "agentrun-nc01-v02", "--source-worktree", "/tmp/repo", "--source-commit", sourceCommit.toUpperCase(), ]); @@ -100,20 +121,87 @@ describe("PaC source artifact CLI contract", () => { "--target", "NC01", "--consumer", "agentrun-nc01-v02", "--source-worktree", rootPath(), ], { cwd: rootPath(), encoding: "utf8", timeout: 30_000 }); expect(result.status).toBe(1); - expect(result.stdout).toContain("origin does not match PaC repository pikasTech-agentrun"); - expect(result.stdout).toContain("pikasTech/unidesk.git"); + expect(result.stdout).toContain("code=source-worktree-origin-mismatch"); + expect(result.stdout).toContain("observedUrlFingerprint=sha256:"); + expect(result.stdout).not.toContain("observedOwnerRepo"); expect(result.stdout).not.toContain("stack"); expect(result.stdout).not.toContain("taskSpec"); expect(result.stdout.length).toBeLessThan(1500); }); + test("default, JSON, and full failures never disclose a malicious origin", () => { + const temporary = mkdtempSync(resolve(tmpdir(), "unidesk-pac-origin-test-")); + const secrets = [ + "review-user", + "REVIEW_SUPER_SECRET", + "QUERY_SUPER_SECRET", + "Authorization", + "https://review-user:REVIEW_SUPER_SECRET@example.invalid/pikasTech/agentrun.git?token=QUERY_SUPER_SECRET", + ]; + try { + for (const args of [ + ["init"], + ["config", "user.email", "pac-test@example.invalid"], + ["config", "user.name", "PaC Test"], + ]) expect(spawnSync("git", args, { cwd: temporary, encoding: "utf8" }).status).toBe(0); + writeFileSync(resolve(temporary, "README.md"), "fixture\n"); + expect(spawnSync("git", ["add", "README.md"], { cwd: temporary, encoding: "utf8" }).status).toBe(0); + expect(spawnSync("git", ["commit", "-m", "fixture"], { cwd: temporary, encoding: "utf8" }).status).toBe(0); + expect(spawnSync("git", ["remote", "add", "origin", secrets[4] as string], { cwd: temporary, encoding: "utf8" }).status).toBe(0); + const origins = [ + secrets[4] as string, + "audit@example.invalid:pikasTech/agentrun.git?token=SCP_QUERY_SUPER_SECRET", + "https://review-user:REVIEW_SUPER_SECRET@github.com/pikasTech/agentrun.git", + "https://github.com/pikasTech/agentrun.git?token=QUERY_SUPER_SECRET", + "https://github.com/pikasTech/agentrun.git#QUERY_SUPER_SECRET", + ]; + for (const origin of origins) { + expect(spawnSync("git", ["remote", "set-url", "origin", origin], { cwd: temporary, encoding: "utf8" }).status).toBe(0); + for (const outputFlag of [null, "--json", "--full"] as const) { + const args = [ + "scripts/cli.ts", "platform-infra", "pipelines-as-code", "source-artifact", "plan", + "--target", "NC01", "--consumer", "agentrun-nc01-v02", "--source-worktree", temporary, + ]; + if (outputFlag !== null) args.push(outputFlag); + const result = spawnSync("bun", args, { cwd: rootPath(), encoding: "utf8", timeout: 30_000 }); + const output = `${result.stdout}\n${result.stderr}`; + expect(result.status).toBe(1); + expect(output).toContain("source-worktree-origin-mismatch"); + expect(output).toContain("observedUrlFingerprint"); + expect(output).not.toContain("observedOwnerRepo"); + for (const secret of [...secrets, "SCP_QUERY_SUPER_SECRET"]) expect(output).not.toContain(secret); + } + } + } finally { + rmSync(temporary, { recursive: true, force: true }); + } + }, 15_000); + + test("verification requires a clean worktree at the exact requested commit", () => { + const exact = { head: sourceCommit, clean: true, matchesSourceCommit: true }; + expect(() => assertPacSourceArtifactVerifyWorktree("verify-runtime", sourceCommit, exact)).not.toThrow(); + expect(() => assertPacSourceArtifactVerifyWorktree("status", sourceCommit, { ...exact, clean: false })).not.toThrow(); + expect(() => assertPacSourceArtifactVerifyWorktree("verify-runtime", sourceCommit, { ...exact, clean: false })).toThrow("source-worktree-not-clean"); + expect(() => assertPacSourceArtifactVerifyWorktree("verify-runtime", sourceCommit, { ...exact, head: "b".repeat(40), matchesSourceCommit: false })).toThrow("source-worktree-commit-mismatch"); + }); + + 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>; + for (const template of ["templates.consumers.agentrunV02", "templates.consumers.hwlabV03"]) { + const sourceArtifact = consumers.find((item) => item.extends === template && item.variables?.NODE === "NC01")?.sourceArtifact; + expect(sourceArtifact?.taskRunTemplate).toEqual({ hostNetwork: true, dnsPolicy: "ClusterFirstWithHostNet", fsGroup: 1000 }); + } + }); + test("undeclared JD01 source artifact owner fails closed", () => { const result = spawnSync("bun", [ "scripts/cli.ts", "platform-infra", "pipelines-as-code", "source-artifact", "plan", "--target", "JD01", "--consumer", "agentrun-jd01-v02", "--source-worktree", rootPath(), ], { cwd: rootPath(), encoding: "utf8", timeout: 30_000 }); expect(result.status).toBe(1); - expect(result.stdout).toContain("has no sourceArtifact owner"); + expect(result.stdout).toContain("code=source-artifact-operation-failed"); + expect(result.stdout).toContain("evidence=type=string"); }); }); @@ -167,6 +255,23 @@ describe("Tekton admission canonicalization", () => { }); }); +describe("source artifact serialization", () => { + test("remote Pipeline artifacts are deterministic reviewable multi-line YAML", () => { + const manifest = { + apiVersion: "tekton.dev/v1", + kind: "Pipeline", + metadata: { name: "fixture" }, + spec: { params: [{ name: "revision", type: "string" }], tasks: [{ name: "build", taskSpec: { steps: [{ name: "build", script: "set -eu\necho ok\n" }] } }] }, + }; + const first = pacSourceArtifactYaml(manifest); + const second = pacSourceArtifactYaml(manifest); + expect(first).toBe(second); + expect(first.split("\n").length).toBeGreaterThan(10); + expect(first).toContain("apiVersion: tekton.dev/v1\nkind: Pipeline\n"); + expect(Bun.YAML.parse(first)).toEqual(manifest); + }); +}); + describe("runtime source-commit selection", () => { test("same-commit retries select the newest only when spec and provenance agree", () => { const oldRun = pipelineRun("agentrun-nc01-v02-ci-old", "2026-07-10T00:00:00Z"); @@ -195,6 +300,51 @@ describe("runtime source-commit selection", () => { expect(observed.embedded.candidateCount).toBe(2); }); + test("embedded mode compares the complete PipelineRun wrapper", () => { + const run = pipelineRun("agentrun-nc01-v02-ci-wrapper", "2026-07-11T00:00:00Z"); + (run.spec as Record).taskRunTemplate.serviceAccountName = "wrong-service-account"; + const observed = observePacSourceArtifactRuntime(runtimeInput(), (_args: string[], label: string) => label === "pipeline-get" + ? { kind: "ok", value: pipeline() } + : { kind: "ok", value: { items: [run] } }); + expect(observed.embedded.status).toBe("drifted"); + expect(observed.embedded.firstDrift?.path).toBe("$.spec.taskRunTemplate.serviceAccountName"); + }); + + test("remote mode validates Pipeline spec live and wrapper on the exact-commit Run", () => { + const remoteProvenance = { ...provenance, mode: "remote-pipeline-annotation" }; + const remoteAnnotations = { + ...manifestAnnotations(), + "unidesk.ai/source-artifact-mode": "remote-pipeline-annotation", + }; + const wrapper = { + mode: "remote-pipeline-annotation", + executionMode: "pipeline-spec", + spec: { ...runtimeWrapperSpec, timeouts: { pipeline: "1h0m0s" } }, + }; + const run = { + metadata: { + name: "hwlab-nc01-v03-ci-poll-remote", + creationTimestamp: "2026-07-11T00:00:00Z", + labels: { "pipelinesascode.tekton.dev/sha": sourceCommit }, + annotations: remoteAnnotations, + }, + spec: { pipelineSpec: desiredSpec, ...wrapper.spec }, + }; + const input = { + ...runtimeInput(), + pipeline: "hwlab-nc01-v03-ci-image-publish", + pipelineRunPrefix: "hwlab-nc01-v03-ci-poll", + provenance: remoteProvenance, + desiredWrapper: wrapper, + }; + const observed = observePacSourceArtifactRuntime(input, (_args: string[], label: string) => label === "pipeline-get" + ? { kind: "ok", value: { metadata: { name: input.pipeline, annotations: remoteAnnotations }, spec: desiredSpec } } + : { kind: "ok", value: { items: [run] } }); + expect(observed.live.status).toBe("aligned"); + expect(observed.embedded.status).toBe("aligned"); + expect(observed.embedded.firstDrift).toBeNull(); + }); + test("NotFound, missing commit, and transport failures remain distinct", () => { const noCommit = observePacSourceArtifactRuntime(runtimeInput(null), () => ({ kind: "not-found", reason: "pipeline-get-not-found" })); expect(noCommit.live.status).toBe("missing"); @@ -202,14 +352,170 @@ describe("runtime source-commit selection", () => { expect(noCommit.embedded.status).toBe("unavailable"); expect(noCommit.embedded.reason).toBe("source-commit-not-specified"); - const transport = observePacSourceArtifactRuntime(runtimeInput(), (_args: string[], label: string) => ({ kind: "unavailable", reason: `${label}-command-failed:126:permission denied` })); + const secret = "Authorization: Bearer REVIEW_RUNTIME_SECRET https://review:password@example.invalid/path?token=QUERY_SECRET"; + const transport = observePacSourceArtifactRuntime(runtimeInput(), () => ({ kind: "unavailable", reason: secret })); expect(transport.live.status).toBe("unavailable"); expect(transport.embedded.status).toBe("unavailable"); - expect(transport.live.reason).toContain("permission denied"); - expect(transport.embedded.reason).toContain("permission denied"); + expect(transport.live.reason).toMatch(/^runtime-observer-reason:type=string,length=\d+,sha256=[0-9a-f]{64}$/u); + expect(transport.embedded.reason).toMatch(/^runtime-observer-reason:type=string,length=\d+,sha256=[0-9a-f]{64}$/u); + expect(JSON.stringify(transport)).not.toContain(secret); + }); + + test("first drift reports structural evidence without script or token values", () => { + const expectedSecret = "Authorization: Bearer EXPECTED_SUPER_SECRET"; + const actualSecret = "https://review-user:ACTUAL_SUPER_SECRET@example.invalid/path?token=QUERY_SUPER_SECRET"; + const input = { + ...runtimeInput(), + desiredSpec: { tasks: [{ taskSpec: { steps: [{ script: expectedSecret }] } }] }, + }; + const observed = observePacSourceArtifactRuntime(input, (_args: string[], label: string) => label === "pipeline-get" + ? { kind: "ok", value: { metadata: { name: "pipeline", annotations: manifestAnnotations() }, spec: { tasks: [{ taskSpec: { steps: [{ script: actualSecret }] } }] } } } + : { kind: "ok", value: { items: [pipelineRun("agentrun-nc01-v02-ci-secret", "2026-07-11T00:00:00Z", { tasks: [{ taskSpec: { steps: [{ script: actualSecret }] } }] })] } }); + const serialized = JSON.stringify(observed); + expect(observed.live.firstDrift?.path).toBe("$.tasks[0].taskSpec.steps[0].script"); + expect(observed.live.firstDrift?.expected).toBe(pacValueEvidence(expectedSecret)); + expect(observed.live.firstDrift?.actual).toBe(pacValueEvidence(actualSecret)); + expect(serialized).not.toContain(expectedSecret); + expect(serialized).not.toContain(actualSecret); + expect(serialized).not.toContain("Authorization"); + expect(serialized).not.toContain("QUERY_SUPER_SECRET"); + }); + + test("unknown runtime keys and forged paths are fingerprinted", () => { + const maliciousKey = "HEADER_QUERY_SUPER_SECRET"; + const local = firstPacSourceArtifactDrift({ tasks: [], [maliciousKey]: "expected" }, { tasks: [], [maliciousKey]: "actual" }); + expect(local?.path).not.toContain(maliciousKey); + expect(local?.path).toContain("type=string,length="); + + const maliciousInput = { ...runtimeInput(), desiredSpec: { ...desiredSpec, [maliciousKey]: "expected" } }; + const observed = observePacSourceArtifactRuntime(maliciousInput, (_args: string[], label: string) => label === "pipeline-get" + ? { kind: "ok", value: { ...pipeline(), spec: { ...desiredSpec, [maliciousKey]: "actual" } } } + : { kind: "ok", value: { items: [pipelineRun("agentrun-nc01-v02-ci-key", "2026-07-11T00:00:00Z", { ...desiredSpec, [maliciousKey]: "actual" })] } }); + expect(JSON.stringify(observed)).not.toContain(maliciousKey); + expect(pacRuntimeSafePath(`$.${maliciousKey}`)).toBe(`path(${pacValueEvidence(`$.${maliciousKey}`)})`); + }); + + test("untrusted runtime annotations and identities are never echoed", () => { + const secret = "Authorization: Bearer OBSERVER_SUPER_SECRET https://u:p@example.invalid/x?token=QUERY_SECRET"; + const annotations = { + ...manifestAnnotations(), + "unidesk.ai/owning-config-ref": secret, + "unidesk.ai/effective-config-sha256": secret, + "unidesk.ai/source-artifact-renderer": secret, + }; + const observed = observePacSourceArtifactRuntime(runtimeInput(), (_args: string[], label: string) => label === "pipeline-get" + ? { kind: "ok", value: { metadata: { name: secret, annotations }, spec: desiredSpec } } + : { kind: "ok", value: { items: [{ ...pipelineRun("agentrun-nc01-v02-ci-annotation", "2026-07-11T00:00:00Z"), metadata: { name: secret, creationTimestamp: "2026-07-11T00:00:00Z", labels: { "pipelinesascode.tekton.dev/sha": sourceCommit }, annotations } }] } }); + const serialized = JSON.stringify(observed); + expect(observed.live.provenanceAligned).toBe(false); + expect(observed.live.configRef).toBeNull(); + expect(observed.live.effectiveConfigSha256).toBeNull(); + expect(observed.live.name).toBeNull(); + expect(serialized).not.toContain(secret); + expect(serialized).not.toContain("OBSERVER_SUPER_SECRET"); + expect(serialized).not.toContain("QUERY_SECRET"); + + const forged = pacRuntimeSourceArtifactItem({ + status: "drifted", + name: "observer-super-secret", + canonicalSha256: `sha256:${"c".repeat(64)}`, + firstDrift: { path: "$.HEADER_QUERY_SUPER_SECRET", expected: secret, actual: secret }, + provenanceAligned: false, + configRef: "SECRET/TOKEN#QUERY", + effectiveConfigSha256: `sha256:${"d".repeat(64)}`, + renderer: provenance.renderer, + mode: provenance.mode, + sourceCommit: "e".repeat(40), + reason: secret, + candidateCount: 1, + }, { + configRef: provenance.configRef, + effectiveConfigSha256: `sha256:${"f".repeat(64)}`, + renderer: provenance.renderer, + mode: provenance.mode, + sourceCommit, + exactName: null, + namePrefix: "agentrun-nc01-v02-ci", + }); + const forgedSerialized = JSON.stringify(forged); + expect(forged.name).toBeNull(); + expect(forged.configRef).toBeNull(); + expect(forged.effectiveConfigSha256).toBeNull(); + expect(forged.provenanceAligned).toBe(false); + expect(forged.sourceCommit).toBeNull(); + expect(forged.firstDrift?.path).not.toContain("HEADER_QUERY_SUPER_SECRET"); + expect(forgedSerialized).not.toContain(secret); + expect(forgedSerialized).not.toContain("SECRET/TOKEN#QUERY"); + + const forgedAligned = pacRuntimeSourceArtifactItem({ + status: "aligned", + provenanceAligned: true, + configRef: provenance.configRef, + effectiveConfigSha256: provenance.effectiveConfigSha256, + renderer: "forged-renderer", + mode: provenance.mode, + sourceCommit, + candidateCount: 1, + }, { + configRef: provenance.configRef, + effectiveConfigSha256: provenance.effectiveConfigSha256, + renderer: provenance.renderer, + mode: provenance.mode, + sourceCommit, + exactName: null, + namePrefix: null, + }); + expect(forgedAligned.provenanceAligned).toBe(false); + }); + + test("typed runtime alignment never reports a synthetic pending state", () => { + const aligned = observePacSourceArtifactRuntime(runtimeInput(), (_args: string[], label: string) => label === "pipeline-get" + ? { kind: "ok", value: pipeline() } + : { kind: "ok", value: { items: [pipelineRun("agentrun-nc01-v02-ci-aligned", "2026-07-11T00:00:00Z")] } }); + const drifted = structuredClone(aligned); + drifted.embedded.status = "drifted"; + const exact = { head: sourceCommit, clean: true, matchesSourceCommit: true }; + expect(sourceArtifactRuntimeAlignment(null, exact, sourceCommit)).toBe("not-requested"); + expect(sourceArtifactRuntimeAlignment(aligned, exact, sourceCommit)).toBe("aligned"); + expect(sourceArtifactRuntimeAlignment(drifted, exact, sourceCommit)).toBe("drifted"); + expect(sourceArtifactRuntimeAlignment(aligned, { ...exact, clean: false }, sourceCommit)).toBe("unavailable"); + expect(JSON.stringify([aligned, drifted])).not.toContain("pending"); + }); + + test("local transport and remote observer failures retain only bounded evidence", () => { + const secret = "Authorization: Bearer TRANSPORT_SUPER_SECRET https://review:password@example.invalid/path?token=QUERY_SECRET"; + const localReason = pacRuntimeTransportFailureReason({ exitCode: 126, stdout: secret, stderr: secret }); + expect(localReason).toContain(pacValueEvidence(secret)); + expect(localReason).not.toContain(secret); + expect(pacRuntimeSafeReason(secret)).toBe(`runtime-observer-reason:${pacValueEvidence(secret)}`); + + const temporary = mkdtempSync(resolve(tmpdir(), "unidesk-pac-runtime-redaction-test-")); + try { + const bin = resolve(temporary, "bin"); + mkdirSync(bin); + const kubectl = resolve(bin, "kubectl"); + writeFileSync(kubectl, `#!/bin/sh\nprintf '%s\\n' '${secret}' >&2\nexit 126\n`); + chmodSync(kubectl, 0o755); + const shell = renderPacSourceArtifactRuntimeObserverShell(runtimeInput()); + const result = spawnSync("sh", [], { + input: shell, + encoding: "utf8", + timeout: 30_000, + env: { ...process.env, PATH: `${bin}:${process.env.PATH ?? ""}` }, + }); + expect(result.status).toBe(0); + expect(result.stdout).toContain("command-failed:126:type=string"); + expect(result.stdout).not.toContain(secret); + expect(result.stdout).not.toContain("Authorization"); + expect(result.stdout).not.toContain("QUERY_SECRET"); + } finally { + rmSync(temporary, { recursive: true, force: true }); + } }); test("temp-file transport handles a payload larger than the current 203 KiB HWLAB Pipeline", () => { + const observerSource = readFileSync(rootPath("scripts", "native", "cicd", "pac-source-artifact-runtime.mjs"), "utf8"); + expect(observerSource).not.toMatch(/process\.env\.PAC_SOURCE_ARTIFACT_INPUT(?!_FILE)/u); const temporary = mkdtempSync(resolve(tmpdir(), "unidesk-pac-runtime-test-")); try { const bin = resolve(temporary, "bin"); @@ -220,6 +526,7 @@ describe("runtime source-commit selection", () => { pipeline: "hwlab-nc01-v03-ci-image-publish", pipelineRunPrefix: "hwlab-nc01-v03-ci-poll", desiredSpec: hugeSpec, + desiredWrapper: { mode: "embedded-pipeline-spec", executionMode: "pipeline-spec", spec: runtimeWrapperSpec }, provenance, sourceCommit, }; @@ -258,28 +565,6 @@ describe("runtime source-commit selection", () => { }); }); -describe("HWLAB domain Pipeline contract", () => { - const requiredMarkers = [ - "unidesk-runtime-gitops-postprocess", - "unidesk-runtime-gitops-verify", - "HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED", - "HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_GROUP_PREFIX", - "HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_TIMEOUT_MS", - "HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_SCAN_LIMIT", - "HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_MATCHED_EVENT_LIMIT", - "HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_LIVE_BUFFER_LIMIT", - ]; - - test("accepts the complete renderer contract and rejects every missing critical marker", () => { - const complete = { tasks: [{ taskSpec: { steps: [{ script: requiredMarkers.join("\n") }] } }] }; - expect(() => assertHwlabPipelineContracts(complete)).not.toThrow(); - for (const marker of requiredMarkers) { - const missing = { tasks: [{ taskSpec: { steps: [{ script: requiredMarkers.filter((item) => item !== marker).join("\n") }] } }] }; - expect(() => assertHwlabPipelineContracts(missing)).toThrow(marker); - } - }); -}); - describe("shared HWLAB deploy overlay", () => { test("preserves exact undefined/null semantics without mutating its input", () => { const document = { 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 9cca8f96..ab099f76 100644 --- a/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts +++ b/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts @@ -14,11 +14,18 @@ import { nodeRuntimeRenderOverlay } from "./hwlab-node/web-probe"; import { applyNodeRuntimeDeployYamlOverlay } from "./hwlab-node/deploy-overlay"; import type { RenderedCliResult } from "./output"; import { renderedCliResult } from "./agentrun/render"; -import { stableJsonSha256 } from "./stable-json"; +import { stableJsonSha256, stableJsonValue } from "./stable-json"; export type PacSourceArtifactMode = "embedded-pipeline-spec" | "remote-pipeline-annotation"; export type PacSourceArtifactRenderer = "agentrun-control-plane" | "hwlab-runtime-lane"; export type PacSourceArtifactAction = "plan" | "check" | "write" | "status" | "verify-runtime"; +export type PacSourceArtifactRuntimeAlignment = "not-requested" | "aligned" | "drifted" | "missing" | "unavailable"; + +export interface PacSourceArtifactTaskRunTemplateSpec { + readonly hostNetwork: boolean; + readonly dnsPolicy: "ClusterFirst" | "ClusterFirstWithHostNet" | "Default" | "None"; + readonly fsGroup: number; +} export interface PacSourceArtifactSpec { readonly mode: PacSourceArtifactMode; @@ -27,6 +34,7 @@ export interface PacSourceArtifactSpec { readonly pipelineRunPath: string; readonly pipelinePath: string | null; readonly maxKeepRuns: number; + readonly taskRunTemplate: PacSourceArtifactTaskRunTemplateSpec; } export interface PacSourceArtifactBinding { @@ -42,6 +50,7 @@ export interface PacSourceArtifactBinding { }; readonly repository: { readonly id: string; + readonly url: string; readonly cloneUrl: string; readonly owner: string; readonly repo: string; @@ -87,6 +96,7 @@ export interface PacSourceArtifactDrift { export type PacSourceArtifactRuntimeObserver = (input: { readonly binding: PacSourceArtifactBinding; readonly desiredSpec: Record; + readonly desiredWrapper: Record | null; readonly provenance: PacSourceArtifactProvenance; readonly sourceCommit: string | null; }) => Promise; @@ -95,6 +105,7 @@ interface PacSourceArtifactProvenance { readonly configRef: string; readonly effectiveConfigSha256: string; readonly renderer: PacSourceArtifactRenderer; + readonly mode: PacSourceArtifactMode; } interface DesiredArtifact { @@ -114,6 +125,48 @@ interface SourceInspection { readonly provenanceAligned: boolean; } +export interface SourceWorktreeState { + readonly head: string; + readonly clean: boolean; + readonly matchesSourceCommit: boolean | null; +} + +export class PacSourceArtifactError extends Error { + readonly code: string; + readonly safeDetails: Readonly>; + + constructor(code: string, safeDetails: Readonly> = {}) { + super(code); + this.name = "PacSourceArtifactError"; + this.code = code; + this.safeDetails = safeDetails; + } +} + +export interface PacSourceArtifactSafeError { + readonly code: string; + readonly evidence: string; + readonly details: Readonly>; +} + +export function pacSourceArtifactSafeError(error: unknown): PacSourceArtifactSafeError { + const message = error instanceof Error ? error.message : String(error); + const allowedDetailKeys = new Set([ + "expectedUrlFingerprint", + "observedUrlFingerprint", + "head", + "requestedSourceCommit", + ]); + const details = error instanceof PacSourceArtifactError + ? Object.fromEntries(Object.entries(error.safeDetails).map(([key, value]) => [key, allowedDetailKeys.has(key) ? value : pacValueEvidence(value)])) + : {}; + return { + code: error instanceof PacSourceArtifactError ? error.code : "source-artifact-operation-failed", + evidence: pacValueEvidence(message), + details, + }; +} + const provenanceKeys = { configRef: "unidesk.ai/owning-config-ref", effectiveConfigSha256: "unidesk.ai/effective-config-sha256", @@ -121,6 +174,15 @@ const provenanceKeys = { mode: "unidesk.ai/source-artifact-mode", } as const; +const knownDriftPathKeys = new Set([ + "accessModes", "annotations", "apiVersion", "args", "command", "computeResources", "default", "description", "dnsPolicy", "env", "envFrom", + "executionMode", "finally", "fsGroup", "generateName", "hostNetwork", "image", "kind", "labels", "length", "metadata", "mode", "mountPath", "name", "namespace", "onError", "operator", + "optional", "params", "pipeline", "pipelineRef", "pipelineSpec", "podTemplate", "readinessProbe", "readOnly", "requests", "resources", + "results", "retries", "runAfter", "script", "secret", "secretName", "securityContext", "serviceAccountName", "sidecars", "spec", "steps", + "storage", "subPath", "taskRunTemplate", "taskSpec", "tasks", "timeout", "timeouts", "type", "value", "values", "volumeClaimTemplate", + "volumeMounts", "volumes", "when", "workspaces", "workingDir", "wrapper", +]); + export function parsePacSourceArtifactOptions(args: readonly string[]): PacSourceArtifactOptions { const action = args[0]; if (!isSourceArtifactAction(action)) { @@ -165,7 +227,7 @@ export function parsePacSourceArtifactOptions(args: readonly string[]): PacSourc if (!isAbsolute(sourceWorktree)) throw new Error("--source-worktree must be an absolute path"); if (sourceCommit !== null && !/^[0-9a-f]{40}$/u.test(sourceCommit)) throw new Error("--source-commit must be a full 40-character git SHA"); if (sourceCommit !== null && action !== "status" && action !== "verify-runtime") throw new Error("--source-commit is accepted only by source-artifact status or verify-runtime"); - if (action === "verify-runtime" && sourceCommit === null) throw new Error("source-artifact verify-runtime requires explicit --source-commit"); + if (action === "verify-runtime" && sourceCommit === null) throw new PacSourceArtifactError("source-commit-required"); if (action === "write" && !confirm) throw new Error("source-artifact write requires --confirm"); if (action !== "write" && confirm) throw new Error("--confirm is accepted only by source-artifact write"); return { action, targetId, consumerId, sourceWorktree, sourceCommit, confirm, json, full }; @@ -183,6 +245,13 @@ export async function runPacSourceArtifact( throw new Error(`source-artifact consumer ${options.consumerId} does not match resolved consumer ${binding.consumer.id}`); } const sourceWorktree = validateSourceWorktree(options.sourceWorktree, binding); + const sourceHead = git(sourceWorktree, ["rev-parse", "HEAD"]).toLowerCase(); + const sourceWorktreeState: SourceWorktreeState = { + head: sourceHead, + clean: git(sourceWorktree, ["status", "--porcelain=v1", "--untracked-files=all"]).length === 0, + matchesSourceCommit: options.sourceCommit === null ? null : sourceHead === options.sourceCommit, + }; + assertPacSourceArtifactVerifyWorktree(options.action, options.sourceCommit, sourceWorktreeState); const desired = renderDesiredArtifact(binding, sourceWorktree); let source = inspectSourceArtifact(sourceWorktree, binding, desired); const written: string[] = []; @@ -194,14 +263,17 @@ export async function runPacSourceArtifact( const runtime = needsRuntime ? observeRuntime === undefined ? unavailableRuntimeObservation() - : await observeRuntime({ binding, desiredSpec: desired.desiredSpec, provenance: desired.provenance, sourceCommit: options.sourceCommit }) + : await observeRuntime({ + binding, + desiredSpec: desired.desiredSpec, + desiredWrapper: options.sourceCommit === null ? null : desiredRuntimePipelineRunWrapper(binding, desired.pipelineRun, options.sourceCommit), + provenance: desired.provenance, + sourceCommit: options.sourceCommit, + }) : null; const sourceOk = source.aligned && source.provenanceAligned; - const runtimeOk = runtime !== null - && runtime.live.status === "aligned" - && runtime.live.provenanceAligned - && runtime.embedded.status === "aligned" - && runtime.embedded.provenanceAligned; + const runtimeAlignment = sourceArtifactRuntimeAlignment(runtime, sourceWorktreeState, options.sourceCommit); + const runtimeOk = runtimeAlignment === "aligned"; const ok = options.action === "check" ? sourceOk : options.action === "verify-runtime" @@ -233,17 +305,10 @@ export async function runPacSourceArtifact( namespace: binding.consumer.namespace, }, source, + sourceWorktreeState, runtime, runtimeTarget: { sourceCommit: options.sourceCommit }, - runtimeAlignment: runtime === null - ? "not-requested" - : runtimeOk - ? "aligned" - : runtime.live.status === "missing" || runtime.embedded.status === "missing" - ? "missing" - : runtime.live.status === "unavailable" || runtime.embedded.status === "unavailable" - ? "unavailable" - : "pending", + runtimeAlignment, boundary: { desiredAuthority: "owning YAML plus domain renderer", liveExportAllowed: false, @@ -258,32 +323,90 @@ export async function runPacSourceArtifact( ], valuesPrinted: false, }, - next: sourceOk - ? options.action === "verify-runtime" - ? null - : `After the consumer PR rolls out, run source-artifact verify-runtime for ${binding.consumer.id} with --source-commit .` - : options.action === "write" - ? "Generated files are still inconsistent; inspect firstDrift and do not publish." - : `Run source-artifact write --confirm against the explicit ${sourceWorktree} worktree.`, + next: sourceArtifactNext(binding, options, sourceOk, sourceWorktreeState, runtime, runtimeAlignment), }; } +export function assertPacSourceArtifactVerifyWorktree( + action: PacSourceArtifactAction, + sourceCommit: string | null, + worktree: SourceWorktreeState, +): void { + if (action !== "verify-runtime") return; + if (sourceCommit === null) throw new PacSourceArtifactError("source-commit-required"); + if (!worktree.clean) throw new PacSourceArtifactError("source-worktree-not-clean", { head: worktree.head }); + if (worktree.matchesSourceCommit !== true) { + throw new PacSourceArtifactError("source-worktree-commit-mismatch", { + head: worktree.head, + requestedSourceCommit: sourceCommit, + }); + } +} + +export function sourceArtifactRuntimeAlignment( + runtime: PacSourceArtifactRuntimeObservation | null, + worktree: SourceWorktreeState, + sourceCommit: string | null, +): PacSourceArtifactRuntimeAlignment { + if (runtime === null) return "not-requested"; + if (sourceCommit !== null && (!worktree.clean || worktree.matchesSourceCommit !== true)) return "unavailable"; + if (runtime.live.status === "unavailable" || runtime.embedded.status === "unavailable") return "unavailable"; + if (runtime.live.status === "missing" || runtime.embedded.status === "missing") return "missing"; + if (runtime.live.status === "drifted" || runtime.embedded.status === "drifted") return "drifted"; + if (!runtime.live.provenanceAligned || !runtime.embedded.provenanceAligned) return "drifted"; + return "aligned"; +} + +function sourceArtifactNext( + binding: PacSourceArtifactBinding, + options: PacSourceArtifactOptions, + sourceOk: boolean, + worktree: SourceWorktreeState, + runtime: PacSourceArtifactRuntimeObservation | null, + runtimeAlignment: PacSourceArtifactRuntimeAlignment, +): string { + if (!sourceOk) { + return options.action === "write" + ? "Generated files remain inconsistent; inspect the safe firstDrift evidence and do not publish." + : `Run source-artifact write --confirm for ${binding.consumer.id}, then rerun check.`; + } + if (runtime === null) return `After the consumer PR rolls out, use a clean exact-commit worktree and run verify-runtime for ${binding.consumer.id} with --source-commit .`; + if (!worktree.clean || (options.sourceCommit !== null && worktree.matchesSourceCommit !== true)) { + return "Create a clean worktree at the exact GitHub merge commit, then rerun status or verify-runtime with that full source commit."; + } + if (runtime.live.status === "unavailable") return "Repair the owning control-plane runtime observer or RBAC, then rerun status before verification."; + if (runtime.live.status === "missing") return "Apply the owning YAML control plane, confirm the live Pipeline exists, then rerun verify-runtime."; + if (runtime.live.status === "drifted" || !runtime.live.provenanceAligned) return "Apply the owning YAML control plane so the live Pipeline spec and provenance align, then rerun verify-runtime."; + if (runtime.embedded.status === "unavailable") return "Inspect the exact-commit PaC PipelineRun identity and observer access, then rerun verify-runtime."; + if (runtime.embedded.status === "missing") return "Wait for or repair the exact-commit PaC PipelineRun; do not validate a prefix-selected run."; + if (runtime.embedded.status === "drifted" || !runtime.embedded.provenanceAligned) return "Regenerate and merge the consumer source artifact, then verify the new exact-commit PipelineRun."; + return runtimeAlignment === "aligned" + ? "Exact source, live Pipeline, embedded PipelineRun, and provenance are aligned; continue consumer closeout." + : "Resolve the reported runtime layer and rerun verify-runtime."; +} + export function renderPacSourceArtifactResult(result: Record): RenderedCliResult { const files = record(result.files); const desired = record(result.desired); const source = record(result.source); + const sourceWorktreeState = record(result.sourceWorktreeState); const runtime = result.runtime === null ? null : record(result.runtime); const live = runtime === null ? null : record(runtime.live); const embedded = runtime === null ? null : record(runtime.embedded); const firstDrift = source.firstDrift === null ? null : record(source.firstDrift); + const liveDrift = live?.firstDrift === null ? null : record(live?.firstDrift); + const embeddedDrift = embedded?.firstDrift === null ? null : record(embedded?.firstDrift); const lines = [ "PAC SOURCE ARTIFACT", `target=${text(result.target)} consumer=${text(result.consumer)} node=${text(result.node)} lane=${text(result.lane)}`, `mode=${text(result.mode)} renderer=${text(result.renderer)} mutation=${text(result.mutation)}`, `pipelineRun=${text(files.pipelineRun)} pipeline=${text(files.pipeline ?? "-")}`, `desired=${shortSha(desired.canonicalSha256)} source=${shortSha(source.pipelineCanonicalSha256)} aligned=${text(source.aligned)} provenance=${text(source.provenanceAligned)}`, - `firstDrift=${firstDrift === null ? "-" : `${text(firstDrift.path)} expected=${text(firstDrift.expected)} actual=${text(firstDrift.actual)}`}`, - `runtime=${text(result.runtimeAlignment)} live=${text(live?.name)}@${shortSha(live?.canonicalSha256)} embedded=${text(embedded?.name)}@${shortSha(embedded?.canonicalSha256)} commit=${text(embedded?.sourceCommit ?? record(result.runtimeTarget).sourceCommit)} candidates=${text(embedded?.candidateCount)}`, + `sourceWorktree=head:${shortSha(sourceWorktreeState.head)} clean:${text(sourceWorktreeState.clean)} commitMatch:${text(sourceWorktreeState.matchesSourceCommit)}`, + `sourceDrift=${formatDrift(firstDrift)}`, + `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)}`, `runtimeReason=live:${text(live?.reason)} embedded:${text(embedded?.reason)}`, `written=${Array.isArray(files.written) && files.written.length > 0 ? files.written.join(",") : "-"}`, ]; @@ -319,18 +442,17 @@ function renderDesiredArtifact(binding: PacSourceArtifactBinding, sourceWorktree const rendered = sourceArtifact.renderer === "agentrun-control-plane" ? renderAgentRunDesiredPipeline(binding) : renderHwlabDesiredPipeline(binding, sourceWorktree); - const pipeline = withPipelineProvenance(rendered.pipeline, rendered.provenance, sourceArtifact.mode); + const pipeline = withPipelineProvenance(rendered.pipeline, rendered.provenance); const desiredSpec = requiredRecord(pipeline.spec, "rendered Pipeline.spec"); assertPipelineIdentity(pipeline, binding); - if (sourceArtifact.renderer === "hwlab-runtime-lane") assertHwlabPipelineContracts(desiredSpec); const pipelineRun = sourceArtifact.mode === "embedded-pipeline-spec" ? embeddedPipelineRun(binding, desiredSpec, rendered.provenance) : remotePipelineRun(binding, pipeline, rendered.provenance); const files = sourceArtifact.mode === "embedded-pipeline-spec" - ? [{ path: sourceArtifact.pipelineRunPath, content: yaml(pipelineRun) }] + ? [{ path: sourceArtifact.pipelineRunPath, content: pacSourceArtifactYaml(pipelineRun) }] : [ - { path: requiredString(sourceArtifact.pipelinePath, "sourceArtifact.pipelinePath"), content: `${JSON.stringify(pipeline)}\n` }, - { path: sourceArtifact.pipelineRunPath, content: yaml(pipelineRun) }, + { path: requiredString(sourceArtifact.pipelinePath, "sourceArtifact.pipelinePath"), content: pacSourceArtifactYaml(pipeline) }, + { path: sourceArtifact.pipelineRunPath, content: pacSourceArtifactYaml(pipelineRun) }, ]; return { pipeline, pipelineRun, provenance: rendered.provenance, desiredSpec, files }; } @@ -355,6 +477,7 @@ function renderAgentRunDesiredPipeline(binding: PacSourceArtifactBinding): { pip configRef: expectedConfigRef, effectiveConfigSha256: stableJsonSha256(spec), renderer: "agentrun-control-plane", + mode: binding.consumer.sourceArtifact.mode, }, }; } @@ -381,6 +504,7 @@ function renderHwlabDesiredPipeline(binding: PacSourceArtifactBinding, sourceWor configRef: expectedConfigRef, effectiveConfigSha256: stableJsonSha256(spec), renderer: "hwlab-runtime-lane", + mode: binding.consumer.sourceArtifact.mode, }, }; } @@ -540,16 +664,52 @@ function pipelineRunLabels(binding: PacSourceArtifactBinding, partOf: "agentrun" } function taskRunTemplate(binding: PacSourceArtifactBinding): Record { + const declared = binding.consumer.sourceArtifact.taskRunTemplate; return { serviceAccountName: `{{ service_account }}`, podTemplate: { - hostNetwork: true, - dnsPolicy: "ClusterFirstWithHostNet", - securityContext: { fsGroup: 1000 }, + hostNetwork: declared.hostNetwork, + dnsPolicy: declared.dnsPolicy, + securityContext: { fsGroup: declared.fsGroup }, }, }; } +function desiredRuntimePipelineRunWrapper( + binding: PacSourceArtifactBinding, + pipelineRun: Record, + sourceCommit: string, +): Record { + const sourceSpec = structuredClone(requiredRecord(pipelineRun.spec, "generated PipelineRun.spec")); + delete sourceSpec.pipelineSpec; + delete sourceSpec.pipelineRef; + return { + mode: binding.consumer.sourceArtifact.mode, + executionMode: "pipeline-spec", + spec: resolvePacRuntimeTemplates(sourceSpec, binding, sourceCommit), + }; +} + +function resolvePacRuntimeTemplates(value: unknown, binding: PacSourceArtifactBinding, sourceCommit: string): unknown { + if (Array.isArray(value)) return value.map((item) => resolvePacRuntimeTemplates(item, binding, sourceCommit)); + if (isRecord(value)) { + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, resolvePacRuntimeTemplates(item, binding, sourceCommit)])); + } + if (typeof value !== "string") return value; + const templateValues: Readonly> = { + ...binding.repository.params, + revision: sourceCommit, + repo_url: binding.repository.url, + }; + const resolved = value.replace(/\{\{\s*([A-Za-z0-9_]+)\s*\}\}/gu, (_match, key: string) => { + const replacement = templateValues[key]; + if (replacement === undefined) throw new PacSourceArtifactError("source-artifact-runtime-template-unresolved", { template: pacValueEvidence(key) }); + return replacement; + }); + if (/\{\{|\}\}/u.test(resolved)) throw new PacSourceArtifactError("source-artifact-runtime-template-invalid"); + return resolved; +} + function pipelineRunParams(binding: PacSourceArtifactBinding, desiredSpec: Record): Record[] { const params = arrayRecords(desiredSpec.params, "Pipeline.spec.params"); return params.map((param) => { @@ -585,7 +745,7 @@ function pipelineRunWorkspaces(binding: PacSourceArtifactBinding, desiredSpec: R }); } -function withPipelineProvenance(pipeline: Record, provenance: PacSourceArtifactProvenance, mode: PacSourceArtifactMode): Record { +function withPipelineProvenance(pipeline: Record, provenance: PacSourceArtifactProvenance): Record { const next = structuredClone(pipeline); const metadata = requiredRecord(next.metadata, "rendered Pipeline.metadata"); const annotations = isRecord(metadata.annotations) ? metadata.annotations : {}; @@ -594,7 +754,7 @@ function withPipelineProvenance(pipeline: Record, provenance: P [provenanceKeys.configRef]: provenance.configRef, [provenanceKeys.effectiveConfigSha256]: provenance.effectiveConfigSha256, [provenanceKeys.renderer]: provenance.renderer, - [provenanceKeys.mode]: mode, + [provenanceKeys.mode]: provenance.mode, }; return next; } @@ -604,7 +764,7 @@ function inspectSourceArtifact(sourceWorktree: string, binding: PacSourceArtifac const pipelineRunPath = safeArtifactPath(sourceWorktree, sourceArtifact.pipelineRunPath); const pipelinePath = sourceArtifact.pipelinePath === null ? null : safeArtifactPath(sourceWorktree, sourceArtifact.pipelinePath); if (!existsSync(pipelineRunPath) || (pipelinePath !== null && !existsSync(pipelinePath))) { - return { status: "missing", aligned: false, pipelineCanonicalSha256: null, pipelineRunCanonicalSha256: null, firstDrift: { path: "$", expected: "generated artifact", actual: "missing" }, provenanceAligned: false }; + return { status: "missing", aligned: false, pipelineCanonicalSha256: null, pipelineRunCanonicalSha256: null, firstDrift: drift("$", { artifact: "generated" }, undefined), provenanceAligned: false }; } const actualPipelineRun = parseYamlRecord(readFileSync(pipelineRunPath, "utf8"), sourceArtifact.pipelineRunPath); const actualPipeline = sourceArtifact.mode === "remote-pipeline-annotation" @@ -628,7 +788,7 @@ function inspectSourceArtifact(sourceWorktree: string, binding: PacSourceArtifac aligned, pipelineCanonicalSha256: canonicalSha256(requiredRecord(actualPipeline.spec, "source Pipeline.spec")), pipelineRunCanonicalSha256: canonicalSha256(actualPipelineRun), - firstDrift: pipelineDrift ?? pipelineRunDrift ?? (provenanceAligned ? null : { path: "$.metadata.annotations.provenance", expected: JSON.stringify(desired.provenance), actual: JSON.stringify(actualProvenance) }), + firstDrift: pipelineDrift ?? pipelineRunDrift ?? (provenanceAligned ? null : drift("$.metadata.annotations.provenance", desired.provenance, actualProvenance)), provenanceAligned, }; } @@ -639,34 +799,51 @@ function validateSourceWorktree(input: string, binding: PacSourceArtifactBinding const top = realpathSync(git(worktree, ["rev-parse", "--show-toplevel"])); if (top !== worktree) throw new Error(`--source-worktree must be the git worktree root; resolved ${top}`); const remote = git(worktree, ["remote", "get-url", "origin"]); - const repoIdentity = binding.repository.repo.toLowerCase().replace(/\.git$/u, ""); - const canonicalIdentity = repoIdentity.replace(/^pikastech-/u, "pikastech/"); - const allowedIdentities = new Set([ - canonicalIdentity, - `${binding.repository.owner}/${repoIdentity}`.toLowerCase(), - remoteRepositoryIdentity(binding.repository.cloneUrl), - ]); + const expectedRemote = owningSourceRemote(binding); + const expectedIdentity = remoteRepositoryIdentity(expectedRemote); const observedIdentity = remoteRepositoryIdentity(remote); - if (!allowedIdentities.has(observedIdentity)) { - throw new Error(`source worktree origin does not match PaC repository ${binding.repository.repo}; observed ${remote}`); + if (expectedIdentity === null || observedIdentity === null + || expectedIdentity.host !== observedIdentity.host + || expectedIdentity.ownerRepo !== observedIdentity.ownerRepo) { + throw new PacSourceArtifactError("source-worktree-origin-mismatch", { + expectedUrlFingerprint: fingerprint(expectedRemote), + observedUrlFingerprint: fingerprint(remote), + }); } safeArtifactPath(worktree, binding.consumer.sourceArtifact.pipelineRunPath); if (binding.consumer.sourceArtifact.pipelinePath !== null) safeArtifactPath(worktree, binding.consumer.sourceArtifact.pipelinePath); return worktree; } -function remoteRepositoryIdentity(remote: string): string { +function owningSourceRemote(binding: PacSourceArtifactBinding): string { + if (binding.consumer.sourceArtifact.renderer === "agentrun-control-plane") { + return resolveAgentRunLaneTarget({ node: binding.consumer.node, lane: binding.consumer.lane }).spec.source.worktreeRemote; + } + if (!isHwlabRuntimeLane(binding.consumer.lane)) throw new PacSourceArtifactError("owning-source-lane-unresolved"); + return hwlabRuntimeLaneSpecForNode(binding.consumer.lane, binding.consumer.node).gitUrl; +} + +function remoteRepositoryIdentity(remote: string): { readonly host: string; readonly ownerRepo: string } | null { const trimmed = remote.trim(); - const scp = /^[^/@\s]+@[^:\s]+:(.+)$/u.exec(trimmed); - let path = scp?.[1] ?? ""; - if (path.length === 0) { + const scp = /^[^/@\s]+@([^:\s]+):(.+)$/u.exec(trimmed); + let host = scp?.[1] ?? ""; + let path = scp?.[2] ?? ""; + if (host.length === 0 || path.length === 0) { try { - path = new URL(trimmed).pathname; + const parsed = new URL(trimmed); + if (parsed.username.length > 0 || parsed.password.length > 0 || parsed.search.length > 0 || parsed.hash.length > 0) return null; + host = parsed.hostname; + path = parsed.pathname; } catch { - throw new Error(`git remote must be an SSH or URL repository identity; observed ${remote}`); + return null; } } - return path.replace(/^\/+|\/+$/gu, "").replace(/\.git$/iu, "").toLowerCase(); + const segments = path.replace(/^\/+|\/+$/gu, "").split("/").filter(Boolean); + if (segments.length !== 2) return null; + const owner = segments[0]?.toLowerCase(); + const repo = segments[1]?.replace(/\.git$/iu, "").toLowerCase(); + if (!host || !owner || !repo || !/^[a-z0-9_.-]+$/u.test(owner) || !/^[a-z0-9_.-]+$/u.test(repo)) return null; + return { host: host.toLowerCase(), ownerRepo: `${owner}/${repo}` }; } function writeArtifactFilesAtomically(sourceWorktree: string, files: readonly { readonly path: string; readonly content: string }[]): string[] { @@ -754,37 +931,23 @@ function assertPipelineIdentity(pipeline: Record, binding: PacS if (metadata.namespace !== binding.consumer.namespace) throw new Error(`rendered Pipeline namespace ${String(metadata.namespace)} does not match consumer ${binding.consumer.namespace}`); } -export function assertHwlabPipelineContracts(spec: Record): void { - const text = JSON.stringify(spec); - const required = [ - "unidesk-runtime-gitops-postprocess", - "unidesk-runtime-gitops-verify", - "HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED", - "HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_GROUP_PREFIX", - "HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_TIMEOUT_MS", - "HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_SCAN_LIMIT", - "HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_MATCHED_EVENT_LIMIT", - "HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_LIVE_BUFFER_LIMIT", - ]; - const missing = required.filter((value) => !text.includes(value)); - if (missing.length > 0) throw new Error(`HWLAB domain renderer missing required postprocess/verify refresh contract: ${missing.join(", ")}`); -} - function provenanceFromManifest(manifest: Record): PacSourceArtifactProvenance | null { const metadata = isRecord(manifest.metadata) ? manifest.metadata : {}; const annotations = isRecord(metadata.annotations) ? metadata.annotations : {}; const configRef = annotations[provenanceKeys.configRef]; const effectiveConfigSha256 = annotations[provenanceKeys.effectiveConfigSha256]; const renderer = annotations[provenanceKeys.renderer]; - if (typeof configRef !== "string" || typeof effectiveConfigSha256 !== "string" || (renderer !== "agentrun-control-plane" && renderer !== "hwlab-runtime-lane")) return null; - return { configRef, effectiveConfigSha256, renderer }; + const mode = annotations[provenanceKeys.mode]; + if (typeof configRef !== "string" || typeof effectiveConfigSha256 !== "string" || (renderer !== "agentrun-control-plane" && renderer !== "hwlab-runtime-lane") || (mode !== "embedded-pipeline-spec" && mode !== "remote-pipeline-annotation")) return null; + return { configRef, effectiveConfigSha256, renderer, mode }; } function provenanceEquals(actual: PacSourceArtifactProvenance | null, expected: PacSourceArtifactProvenance): boolean { return actual !== null && actual.configRef === expected.configRef && actual.effectiveConfigSha256 === expected.effectiveConfigSha256 - && actual.renderer === expected.renderer; + && actual.renderer === expected.renderer + && actual.mode === expected.mode; } function firstCanonicalDrift(expected: unknown, actual: unknown, path: string): PacSourceArtifactDrift | null { @@ -802,8 +965,11 @@ function firstCanonicalDrift(expected: unknown, actual: unknown, path: string): if (!isRecord(expected) || !isRecord(actual)) return drift(path, expected, actual); const keys = [...new Set([...Object.keys(expected), ...Object.keys(actual)])].sort(); for (const key of keys) { - if (!Object.prototype.hasOwnProperty.call(expected, key) || !Object.prototype.hasOwnProperty.call(actual, key)) return drift(`${path}.${key}`, expected[key], actual[key]); - const child = firstCanonicalDrift(expected[key], actual[key], `${path}.${key}`); + const expectedOwnsKey = Object.prototype.hasOwnProperty.call(expected, key); + const actualOwnsKey = Object.prototype.hasOwnProperty.call(actual, key); + const childPath = knownDriftPathKeys.has(key) ? `${path}.${key}` : `${path}.[key:${pacValueEvidence(key)}]`; + if (!expectedOwnsKey || !actualOwnsKey) return drift(childPath, expected[key], actual[key]); + const child = firstCanonicalDrift(expected[key], actual[key], childPath); if (child !== null) return child; } return null; @@ -829,13 +995,29 @@ function isProvenTektonAdmissionDefault(path: readonly (string | number)[], valu } function drift(path: string, expected: unknown, actual: unknown): PacSourceArtifactDrift { - return { path, expected: compactValue(expected), actual: compactValue(actual) }; + return { path, expected: pacValueEvidence(expected), actual: pacValueEvidence(actual) }; } -function compactValue(value: unknown): string { - const raw = JSON.stringify(value); - if (raw === undefined) return String(value); - return raw.length <= 160 ? raw : `${raw.slice(0, 157)}...`; +export function pacValueEvidence(value: unknown): string { + const type = value === null ? "null" : Array.isArray(value) ? "array" : typeof value; + const stable = stableJsonValue(value); + const serialized = JSON.stringify(stable) ?? String(stable); + const length = typeof value === "string" + ? Buffer.byteLength(value, "utf8") + : Array.isArray(value) + ? value.length + : isRecord(value) + ? Object.keys(value).length + : Buffer.byteLength(serialized, "utf8"); + return `type=${type},length=${length},sha256=${fingerprint(serialized).replace(/^sha256:/u, "")}`; +} + +function fingerprint(value: string): string { + return `sha256:${createHash("sha256").update(value).digest("hex")}`; +} + +function formatDrift(value: Record | null): string { + return value === null ? "-" : `${text(value.path)} expected:${text(value.expected)} actual:${text(value.actual)}`; } function unavailableRuntimeObservation(): PacSourceArtifactRuntimeObservation { @@ -849,8 +1031,8 @@ function parseYamlRecord(text: string, label: string): Record { return parsed; } -function yaml(value: Record): string { - return `${Bun.YAML.stringify(value).trim()}\n`; +export function pacSourceArtifactYaml(value: Record): string { + return `${Bun.YAML.stringify(value, null, 2).trim()}\n`; } function requiredParam(binding: PacSourceArtifactBinding, key: string): string { diff --git a/scripts/src/platform-infra-pipelines-as-code.ts b/scripts/src/platform-infra-pipelines-as-code.ts index 68e3a79a..5293cd28 100644 --- a/scripts/src/platform-infra-pipelines-as-code.ts +++ b/scripts/src/platform-infra-pipelines-as-code.ts @@ -19,6 +19,8 @@ import { import { materializeYamlComposition } from "./yaml-composition"; import { canonicalizePacPipelineSpec, + pacSourceArtifactSafeError, + pacValueEvidence, parsePacSourceArtifactOptions, renderPacSourceArtifactResult, runPacSourceArtifact, @@ -251,17 +253,26 @@ export async function runPlatformInfraPipelinesAsCodeCommand(config: UniDeskConf }, repository: { id: repository.id, + url: repository.url, cloneUrl: repository.cloneUrl, owner: repository.owner, repo: repository.repo, params: repository.params, }, }; - const result = await runPacSourceArtifact(binding, options, async ({ desiredSpec, provenance, sourceCommit }) => await observeSourceArtifactRuntime(config, target, consumer, desiredSpec, provenance, sourceCommit)); + const result = await runPacSourceArtifact(binding, options, async ({ desiredSpec, desiredWrapper, provenance, sourceCommit }) => await observeSourceArtifactRuntime(config, target, consumer, desiredSpec, desiredWrapper, provenance, sourceCommit)); return options.json || options.full ? result : renderPacSourceArtifactResult(result); } catch (error) { - if (structuredError) throw error; - return sourceArtifactValidationError(error); + const safeError = pacSourceArtifactSafeError(error); + if (structuredError) { + return { + ok: false, + action: "platform-infra-pipelines-as-code-source-artifact-validation", + error: safeError, + next: "Fix the declared owner, exact source identity, worktree proof, or CLI arguments; rerun plan before write.", + }; + } + return sourceArtifactValidationError(safeError); } } return { ok: false, error: "unsupported-platform-infra-pipelines-as-code-command", args, help: help() }; @@ -286,12 +297,12 @@ function sourceArtifactHelp(): RenderedCliResult { return { ok: true, command: "platform-infra-pipelines-as-code-source-artifact-help", renderedText: `${lines.join("\n")}\n`, contentType: "text/plain" }; } -function sourceArtifactValidationError(error: unknown): RenderedCliResult { - const reason = String(error instanceof Error ? error.message : error).replace(/[\r\n]+/gu, " ").slice(0, 1000); +function sourceArtifactValidationError(error: ReturnType): RenderedCliResult { + const details = Object.entries(error.details).map(([key, value]) => `${key}=${value}`).join(" "); return { ok: false, command: "platform-infra-pipelines-as-code-source-artifact-validation", - renderedText: `PAC SOURCE ARTIFACT ERROR\nreason=${reason}\nnext=Fix the explicit target, consumer, source worktree, or owning YAML declaration; rerun plan before write.\n`, + renderedText: `PAC SOURCE ARTIFACT ERROR\ncode=${error.code} evidence=${error.evidence}${details ? ` details=${details}` : ""}\nnext=Fix the declared owner, exact source identity, worktree proof, or CLI arguments; rerun plan before write.\n`, contentType: "text/plain", }; } @@ -442,6 +453,7 @@ function parseSourceArtifact(value: Record, path: string): PacS const renderer = y.enumField(value, "renderer", path, ["agentrun-control-plane", "hwlab-runtime-lane"] as const); const pipelineRunPath = sourceArtifactPath(y.stringField(value, "pipelineRunPath", path), `${path}.pipelineRunPath`); const pipelinePath = value.pipelinePath === undefined ? null : sourceArtifactPath(y.stringField(value, "pipelinePath", path), `${path}.pipelinePath`); + const taskRunTemplate = y.objectField(value, "taskRunTemplate", path); if (mode === "embedded-pipeline-spec" && pipelinePath !== null) throw new Error(`${path}.pipelinePath is forbidden for embedded-pipeline-spec`); if (mode === "remote-pipeline-annotation" && pipelinePath === null) throw new Error(`${path}.pipelinePath is required for remote-pipeline-annotation`); if (renderer === "agentrun-control-plane" && mode !== "embedded-pipeline-spec") throw new Error(`${path}.renderer agentrun-control-plane requires embedded-pipeline-spec`); @@ -453,6 +465,11 @@ function parseSourceArtifact(value: Record, path: string): PacS pipelineRunPath, pipelinePath, maxKeepRuns: positiveInteger(value, "maxKeepRuns", path), + taskRunTemplate: { + hostNetwork: y.booleanField(taskRunTemplate, "hostNetwork", `${path}.taskRunTemplate`), + dnsPolicy: y.enumField(taskRunTemplate, "dnsPolicy", `${path}.taskRunTemplate`, ["ClusterFirst", "ClusterFirstWithHostNet", "Default", "None"] as const), + fsGroup: positiveInteger(taskRunTemplate, "fsGroup", `${path}.taskRunTemplate`), + }, }; } @@ -466,7 +483,8 @@ async function observeSourceArtifactRuntime( target: PacTarget, consumer: PacConsumer, desiredSpec: Record, - provenance: { readonly configRef: string; readonly effectiveConfigSha256: string; readonly renderer: string }, + desiredWrapper: Record | null, + provenance: { readonly configRef: string; readonly effectiveConfigSha256: string; readonly renderer: string; readonly mode: string }, sourceCommit: string | null, ): Promise { const script = renderPacSourceArtifactRuntimeObserverShell({ @@ -474,24 +492,43 @@ async function observeSourceArtifactRuntime( pipeline: consumer.pipeline, pipelineRunPrefix: consumer.pipelineRunPrefix, desiredSpec: canonicalizePacPipelineSpec(desiredSpec), + desiredWrapper: desiredWrapper === null ? null : canonicalizePacPipelineSpec(desiredWrapper), provenance, sourceCommit, }); const captured = await capture(config, target.route, ["sh"], script); if (captured.exitCode !== 0) { - const compact = compactCapture(captured); - const detail = String(compact.stderrTail ?? compact.stdoutTail ?? "").trim().split(/\r?\n/u).filter(Boolean)[0]?.replace(/\s+/gu, " ").slice(0, 240); - return unavailableSourceArtifactRuntime(`runtime-transport-exit-${captured.exitCode}${detail ? `:${detail}` : ""}`, sourceCommit); + return unavailableSourceArtifactRuntime(pacRuntimeTransportFailureReason(captured), sourceCommit); } const parsed = parseJsonOutput(captured.stdout); if (parsed === null) return unavailableSourceArtifactRuntime("runtime-observer-invalid-json", sourceCommit); return { - live: runtimeSourceArtifactItem(parsed.live), - embedded: runtimeSourceArtifactItem(parsed.embedded), + live: pacRuntimeSourceArtifactItem(parsed.live, { + configRef: provenance.configRef, + effectiveConfigSha256: provenance.effectiveConfigSha256, + renderer: provenance.renderer, + mode: provenance.mode, + sourceCommit: null, + exactName: consumer.pipeline, + namePrefix: null, + }), + embedded: pacRuntimeSourceArtifactItem(parsed.embedded, { + configRef: provenance.configRef, + effectiveConfigSha256: provenance.effectiveConfigSha256, + renderer: provenance.renderer, + mode: provenance.mode, + sourceCommit, + exactName: null, + namePrefix: consumer.pipelineRunPrefix, + }), }; } +export function pacRuntimeTransportFailureReason(captured: { readonly exitCode: number; readonly stdout: string; readonly stderr: string }): string { + return `runtime-transport-exit-${captured.exitCode}:stderr(${pacValueEvidence(captured.stderr)}):stdout(${pacValueEvidence(captured.stdout)})`; +} + export function renderPacSourceArtifactRuntimeObserverShell(inputValue: Record): string { const input = JSON.stringify(inputValue); const observer = readFileSync(sourceArtifactRuntimeObserverFile, "utf8").trimEnd(); @@ -508,32 +545,97 @@ NODE_PAC_SOURCE_ARTIFACT_STATUS `; } -function runtimeSourceArtifactItem(value: unknown): PacSourceArtifactRuntimeObservation["live"] { +export function pacRuntimeSourceArtifactItem(value: unknown, expected: { + readonly configRef: string; + readonly effectiveConfigSha256: string; + readonly renderer: string; + readonly mode: string; + readonly sourceCommit: string | null; + readonly exactName: string | null; + readonly namePrefix: string | null; +}): PacSourceArtifactRuntimeObservation["live"] { if (typeof value !== "object" || value === null || Array.isArray(value)) return unavailableSourceArtifactRuntime("runtime-observer-invalid-item", null).live; const item = value as Record; const status = item.status; if (status !== "aligned" && status !== "drifted" && status !== "missing" && status !== "unavailable") return unavailableSourceArtifactRuntime("runtime-observer-invalid-status", null).live; const drift = item.firstDrift; + const observedName = typeof item.name === "string" && item.name.length <= 253 && /^[a-z0-9](?:[-a-z0-9.]*[a-z0-9])?$/u.test(item.name) ? item.name : null; + const name = observedName !== null + && (expected.exactName === null || observedName === expected.exactName) + && (expected.namePrefix === null || observedName.startsWith(expected.namePrefix)) + ? observedName + : null; + const provenanceAligned = item.configRef === expected.configRef + && item.effectiveConfigSha256 === expected.effectiveConfigSha256 + && item.renderer === expected.renderer + && item.mode === expected.mode; return { status, - name: typeof item.name === "string" ? item.name : null, - canonicalSha256: typeof item.canonicalSha256 === "string" ? item.canonicalSha256 : null, + name, + canonicalSha256: typeof item.canonicalSha256 === "string" && /^sha256:[0-9a-f]{64}$/u.test(item.canonicalSha256) ? item.canonicalSha256 : null, firstDrift: typeof drift === "object" && drift !== null && !Array.isArray(drift) ? { - path: String((drift as Record).path ?? "$"), - expected: String((drift as Record).expected ?? ""), - actual: String((drift as Record).actual ?? ""), + path: pacRuntimeSafePath((drift as Record).path), + expected: pacRuntimeSafeEvidence((drift as Record).expected), + actual: pacRuntimeSafeEvidence((drift as Record).actual), } : null, - provenanceAligned: item.provenanceAligned === true, - configRef: typeof item.configRef === "string" ? item.configRef : null, - effectiveConfigSha256: typeof item.effectiveConfigSha256 === "string" ? item.effectiveConfigSha256 : null, - sourceCommit: typeof item.sourceCommit === "string" ? item.sourceCommit : null, - reason: typeof item.reason === "string" ? item.reason : null, + provenanceAligned, + configRef: item.configRef === expected.configRef && expected.configRef.length <= 512 && /^[A-Za-z0-9_./-]+#[A-Za-z0-9_.-]+$/u.test(expected.configRef) ? expected.configRef : null, + effectiveConfigSha256: item.effectiveConfigSha256 === expected.effectiveConfigSha256 && /^sha256:[0-9a-f]{64}$/u.test(expected.effectiveConfigSha256) ? expected.effectiveConfigSha256 : null, + sourceCommit: item.sourceCommit === expected.sourceCommit && (expected.sourceCommit === null || /^[0-9a-f]{40}$/u.test(expected.sourceCommit)) ? expected.sourceCommit : null, + reason: typeof item.reason === "string" ? pacRuntimeSafeReason(item.reason) : null, candidateCount: typeof item.candidateCount === "number" && Number.isInteger(item.candidateCount) && item.candidateCount >= 0 ? item.candidateCount : 0, }; } +export function pacRuntimeSafePath(value: unknown): string { + const path = typeof value === "string" ? value : "$"; + const allowedSegments = new Set([ + "accessModes", "annotations", "apiVersion", "args", "command", "computeResources", "default", "description", "dnsPolicy", "env", "envFrom", + "executionMode", "finally", "fsGroup", "generateName", "hostNetwork", "image", "kind", "labels", "length", "metadata", "mode", "mountPath", "name", "namespace", "onError", "operator", + "optional", "params", "pipeline", "pipelineRef", "pipelineSpec", "podTemplate", "readinessProbe", "readOnly", "requests", "resources", + "results", "retries", "runAfter", "script", "secret", "secretName", "securityContext", "serviceAccountName", "sidecars", "spec", "steps", + "storage", "subPath", "taskRunTemplate", "taskSpec", "tasks", "timeout", "timeouts", "type", "value", "values", "volumeClaimTemplate", + "volumeMounts", "volumes", "when", "workspaces", "workingDir", "wrapper", + ]); + if (!path.startsWith("$")) return `path(${pacValueEvidence(path)})`; + const token = /(?:\.([A-Za-z0-9_-]+))|(?:\[(\d+)\])/gu; + let offset = 1; + for (const match of path.slice(1).matchAll(token)) { + if (match.index !== offset - 1) return `path(${pacValueEvidence(path)})`; + if (match[1] !== undefined && !allowedSegments.has(match[1])) return `path(${pacValueEvidence(path)})`; + offset = 1 + (match.index ?? 0) + match[0].length; + } + return offset === path.length ? path : `path(${pacValueEvidence(path)})`; +} + +function pacRuntimeSafeEvidence(value: unknown): string { + return typeof value === "string" && /^type=[a-z]+,length=\d+,sha256=[0-9a-f]{64}$/u.test(value) + ? value + : pacValueEvidence(value); +} + +export function pacRuntimeSafeReason(value: string): string { + const fixed = new Set([ + "source-commit-not-specified", + "source-commit-run-not-found", + "source-commit-identity-conflict", + "pipeline-get-not-found", + "pipelinerun-list-not-found", + "pipelinerun-list-invalid-shape", + "runtime-observer-invalid-json", + "runtime-observer-invalid-item", + "runtime-observer-invalid-status", + "runtime-observer-not-configured", + ]); + if (fixed.has(value)) return value; + if (/^source-commit-run-conflict:\d+$/u.test(value)) return value; + if (/^(?:pipeline-get|pipelinerun-list)-command-failed:(?:unknown|\d+):type=string,length=\d+,sha256=[0-9a-f]{64}$/u.test(value)) return value; + if (/^(?:runtime-observer-input-error|runtime-observer-reason):type=string,length=\d+,sha256=[0-9a-f]{64}$/u.test(value)) return value; + return `runtime-observer-reason:${pacValueEvidence(value)}`; +} + function unavailableSourceArtifactRuntime(reason: string, sourceCommit: string | null): PacSourceArtifactRuntimeObservation { const item = { status: "unavailable" as const, name: null, canonicalSha256: null, firstDrift: null, provenanceAligned: false, configRef: null, effectiveConfigSha256: null, sourceCommit, reason, candidateCount: 0 }; return { live: item, embedded: item };