diff --git a/config/hwlab-node-lanes.yaml b/config/hwlab-node-lanes.yaml index 737053f1..c474c40f 100644 --- a/config/hwlab-node-lanes.yaml +++ b/config/hwlab-node-lanes.yaml @@ -119,6 +119,10 @@ lanes: pipelineRunPrefix: hwlab-nc01-v03-ci-poll serviceAccountName: hwlab-nc01-v03-tekton-runner controlPlaneFieldManager: unidesk-hwlab-nc01-v03-control-plane + pipelineProvenance: + configRef: config/hwlab-node-lanes.yaml#lanes.v03.targets.NC01 + renderer: hwlab-runtime-lane + mode: remote-pipeline-annotation git: url: git@github.com:pikasTech/HWLAB.git readUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-HWLAB.git diff --git a/docs/MDTODO/details/hwlab-pac-live-provenance/R1.1_Task_Report.md b/docs/MDTODO/details/hwlab-pac-live-provenance/R1.1_Task_Report.md new file mode 100644 index 00000000..13aff105 --- /dev/null +++ b/docs/MDTODO/details/hwlab-pac-live-provenance/R1.1_Task_Report.md @@ -0,0 +1,6 @@ +# R1.1 调查报告 + +- 来源:[UniDesk #1756](https://github.com/pikasTech/unidesk/issues/1756) +- 结论:旧实现分别在 `hwlab-node/web-probe.ts`、`hwlab-node/render.ts` 和 source-artifact renderer 内硬编码 provenance,target YAML 未声明 owner 合同。 +- 阻塞:source-artifact 的正常 postprocess 使用 `sh -c` 传递大脚本,触发 argv 上限后又因空输出执行 `null.trim()`;已纳入同一闭环修复。 +- 边界:不放宽 verifier,不从 live runtime 反向导出,不手工修改运行面。 diff --git a/docs/MDTODO/details/hwlab-pac-live-provenance/R1.2_Task_Report.md b/docs/MDTODO/details/hwlab-pac-live-provenance/R1.2_Task_Report.md new file mode 100644 index 00000000..8b9f0d3e --- /dev/null +++ b/docs/MDTODO/details/hwlab-pac-live-provenance/R1.2_Task_Report.md @@ -0,0 +1,6 @@ +# R1.2 YAML 与 parser 报告 + +- 来源:[UniDesk #1756](https://github.com/pikasTech/unidesk/issues/1756) +- owner:`config/hwlab-node-lanes.yaml#lanes.v03.targets.NC01.pipelineProvenance`。 +- parser:target 必须显式声明合同,并校验精确 `configRef`、`hwlab-runtime-lane` renderer 与 `remote-pipeline-annotation` mode。 +- effective config hash:由共享 helper 对已解析 target spec 做稳定摘要,避免 YAML 静态 hash 的循环依赖。 diff --git a/docs/MDTODO/details/hwlab-pac-live-provenance/R1.3_Task_Report.md b/docs/MDTODO/details/hwlab-pac-live-provenance/R1.3_Task_Report.md new file mode 100644 index 00000000..ad975112 --- /dev/null +++ b/docs/MDTODO/details/hwlab-pac-live-provenance/R1.3_Task_Report.md @@ -0,0 +1,7 @@ +# R1.3 共享渲染报告 + +- 来源:[UniDesk #1756](https://github.com/pikasTech/unidesk/issues/1756) +- 共享合同:`scripts/src/pipeline-provenance.ts` 统一持有四个 annotation key、annotations 生成与 manifest 读取。 +- 正常路径:HWLAB overlay 携带 YAML-derived annotations,正常 control-plane postprocess 使用可复用 native 脚本写入最终 Pipeline。 +- source-artifact:复用同一 HWLAB contract,并要求正常 postprocess 已写入一致 provenance;不再二次硬编码或补写 Pipeline。 +- 工具修复:大 postprocess 改走 stdin,失败状态不再对空输出调用 `trim()`。 diff --git a/docs/MDTODO/details/hwlab-pac-live-provenance/R1.4_Task_Report.md b/docs/MDTODO/details/hwlab-pac-live-provenance/R1.4_Task_Report.md new file mode 100644 index 00000000..9eb661e5 --- /dev/null +++ b/docs/MDTODO/details/hwlab-pac-live-provenance/R1.4_Task_Report.md @@ -0,0 +1,9 @@ +# R1.4 测试与 PR 报告 + +- 来源:[UniDesk #1756](https://github.com/pikasTech/unidesk/issues/1756) +- PR:[UniDesk #1758](https://github.com/pikasTech/unidesk/pull/1758),非 draft;未合并、未部署。 +- 定向测试:29 pass、0 fail、314 assertions。 +- 正常路径测试:实际执行 control-plane provenance postprocess,最终 Pipeline 精确包含共享四字段,并保留非 provenance annotation。 +- fail-closed:覆盖 target YAML 缺失、错误 owner、错误 renderer、错误 mode 与任意四键伪造。 +- 只读原入口:source-artifact `plan` 成功,desired hash 为 `sha256:3fb6e00ba833...`,旧 source artifact 仍正确判为 drifted。 +- 自动交付边界:未手工触发 CI/CD;合并后的交付只允许走 GitHub webhook → Gitea mirror → PaC → CI/CD。 diff --git a/docs/MDTODO/details/hwlab-pac-live-provenance/R1_Task_Report.md b/docs/MDTODO/details/hwlab-pac-live-provenance/R1_Task_Report.md new file mode 100644 index 00000000..67f7a79a --- /dev/null +++ b/docs/MDTODO/details/hwlab-pac-live-provenance/R1_Task_Report.md @@ -0,0 +1,7 @@ +# R1 总结报告 + +- 来源:[UniDesk #1756](https://github.com/pikasTech/unidesk/issues/1756) +- 交付:[UniDesk PR #1758](https://github.com/pikasTech/unidesk/pull/1758)。 +- YAML owner、parser、正常 control-plane renderer 与 source-artifact renderer 已统一到一份 provenance 合同。 +- verifier 保持 fail-closed;没有运行面手工 annotation、source write、部署或 CI/CD 手工触发。 +- 剩余工作由主代理 review/preflight/merge,并在合并后观察自动交付链和运行面 alignment。 diff --git a/docs/MDTODO/hwlab-pac-live-provenance.md b/docs/MDTODO/hwlab-pac-live-provenance.md new file mode 100644 index 00000000..742e2431 --- /dev/null +++ b/docs/MDTODO/hwlab-pac-live-provenance.md @@ -0,0 +1,25 @@ +# HWLAB PaC 实时 Pipeline 来源合同 + +- 来源:[UniDesk #1756](https://github.com/pikasTech/unidesk/issues/1756) +- 范围:只修 YAML-owned provenance 合同、共享渲染与定向验证;不部署、不手工触发 CI/CD。 + + +## R1 [completed] + +修复 [UniDesk #1756](https://github.com/pikasTech/unidesk/issues/1756) 的 HWLAB PaC live Pipeline provenance 永久漂移,完成任务后将详细报告写入[任务报告](./details/hwlab-pac-live-provenance/R1_Task_Report.md)。 + +### R1.1 [completed] + +调查 [UniDesk #1756](https://github.com/pikasTech/unidesk/issues/1756) 的 YAML owner、parser、正常 renderer 与 source-artifact 复用边界,完成任务后将详细报告写入[任务报告](./details/hwlab-pac-live-provenance/R1.1_Task_Report.md)。 + +### R1.2 [completed] + +在 owning YAML 与 parser 定义四字段 provenance 合同,禁止代码隐藏默认值,完成任务后将详细报告写入[任务报告](./details/hwlab-pac-live-provenance/R1.2_Task_Report.md)。 + +### R1.3 [completed] + +让正常 GitOps/control-plane renderer 与 source-artifact renderer复用共享 provenance overlay/postprocess,完成任务后将详细报告写入[任务报告](./details/hwlab-pac-live-provenance/R1.3_Task_Report.md)。 + +### R1.4 [completed] + +补齐正常 render 精确四字段及 source-artifact/live/embedded aligned 验证,提交非 draft PR 并 preflight,完成任务后将详细报告写入[任务报告](./details/hwlab-pac-live-provenance/R1.4_Task_Report.md)。 diff --git a/scripts/native/hwlab/runtime-pipeline-provenance.mjs b/scripts/native/hwlab/runtime-pipeline-provenance.mjs new file mode 100644 index 00000000..abb0c80e --- /dev/null +++ b/scripts/native/hwlab/runtime-pipeline-provenance.mjs @@ -0,0 +1,55 @@ +#!/usr/bin/env node +// Responsibility: apply the YAML-owned HWLAB Pipeline provenance contract during the normal control-plane postprocess. +import { readFileSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; + +const require = createRequire(`${process.cwd()}/package.json`); +const YAML = require("yaml"); + +export function applyHwlabPipelineProvenance(manifest, annotations, expectedKeys) { + if (!isRecord(manifest) || manifest.kind !== "Pipeline") return false; + const entries = isRecord(annotations) ? Object.entries(annotations) : []; + const actualKeys = entries.map(([key]) => key).sort(); + const sortedExpectedKeys = Array.isArray(expectedKeys) ? [...expectedKeys].sort() : []; + if (JSON.stringify(actualKeys) !== JSON.stringify(sortedExpectedKeys) || entries.some(([, value]) => typeof value !== "string" || value.length === 0)) { + throw new Error("overlay.pipelineProvenanceAnnotations must match the shared four-key contract"); + } + manifest.metadata = isRecord(manifest.metadata) ? manifest.metadata : {}; + manifest.metadata.annotations = { ...(isRecord(manifest.metadata.annotations) ? manifest.metadata.annotations : {}), ...annotations }; + return true; +} + +export function postprocessHwlabPipelineProvenance({ pipelinePath, overlay, expectedKeys }) { + const documents = YAML.parseAllDocuments(readFileSync(pipelinePath, "utf8")); + const manifests = documents.map((document) => document.toJS()).filter((document) => document !== null); + const changed = manifests.filter((manifest) => applyHwlabPipelineProvenance(manifest, overlay.pipelineProvenanceAnnotations, expectedKeys)); + if (changed.length !== 1) throw new Error(`expected exactly one Pipeline in ${pipelinePath}; observed ${changed.length}`); + writeFileSync(pipelinePath, `${manifests.map((manifest) => YAML.stringify(manifest).trimEnd()).join("\n---\n")}\n`); + return { pipelinePath, pipelineName: changed[0]?.metadata?.name ?? null, annotationCount: expectedKeys.length }; +} + +function isRecord(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function requiredArgument(name) { + const index = process.argv.indexOf(name); + const value = index === -1 ? undefined : process.argv[index + 1]; + if (typeof value !== "string" || value.length === 0 || value.startsWith("--")) throw new Error(`${name} is required`); + return value; +} + +function decodedJsonEnv(name) { + const encoded = process.env[name]; + if (typeof encoded !== "string" || encoded.length === 0) throw new Error(`${name} is required`); + return JSON.parse(Buffer.from(encoded, "base64").toString("utf8")); +} + +if (process.argv.includes("--pipeline")) { + const result = postprocessHwlabPipelineProvenance({ + pipelinePath: requiredArgument("--pipeline"), + overlay: decodedJsonEnv("UNIDESK_RUNTIME_GITOPS_OVERLAY_B64"), + expectedKeys: decodedJsonEnv("UNIDESK_PIPELINE_PROVENANCE_KEYS_B64"), + }); + console.error(JSON.stringify({ event: "unidesk-runtime-pipeline-provenance", ok: true, ...result })); +} diff --git a/scripts/src/hwlab-node-lanes.ts b/scripts/src/hwlab-node-lanes.ts index 685a0516..b67d9713 100644 --- a/scripts/src/hwlab-node-lanes.ts +++ b/scripts/src/hwlab-node-lanes.ts @@ -559,6 +559,12 @@ export interface HwlabRuntimeSourceWorkspaceHostDependenciesSpec { }; } +export interface HwlabRuntimePipelineProvenanceSpec { + readonly configRef: string; + readonly renderer: "hwlab-runtime-lane"; + readonly mode: "remote-pipeline-annotation"; +} + export interface HwlabRuntimeLaneSpec { readonly lane: HwlabRuntimeLane; readonly nodeId: string; @@ -578,6 +584,7 @@ export interface HwlabRuntimeLaneSpec { readonly pipelineRunPrefix: string; readonly serviceAccountName: string; readonly controlPlaneFieldManager: string; + readonly pipelineProvenance?: HwlabRuntimePipelineProvenanceSpec; readonly gitUrl: string; readonly gitReadUrl: string; readonly gitWriteUrl: string; @@ -647,6 +654,7 @@ interface HwlabLaneConfig { readonly pipelineRunPrefix: string; readonly serviceAccountName: string; readonly controlPlaneFieldManager: string; + readonly pipelineProvenance?: HwlabRuntimePipelineProvenanceSpec; readonly git: { readonly url: string; readonly readUrl: string; readonly writeUrl: string }; readonly argo: { readonly repoURL?: string }; readonly gitopsBranch: string; @@ -886,6 +894,7 @@ function laneConfig(id: HwlabRuntimeLane, raw: Record): HwlabLa pipelineRunPrefix: stringField(raw, "pipelineRunPrefix", `lanes.${id}`), serviceAccountName: stringField(raw, "serviceAccountName", `lanes.${id}`), controlPlaneFieldManager: stringField(raw, "controlPlaneFieldManager", `lanes.${id}`), + pipelineProvenance: pipelineProvenanceConfig(raw.pipelineProvenance, `lanes.${id}.pipelineProvenance`), git: { url: stringField(git, "url", `lanes.${id}.git`), readUrl: stringField(git, "readUrl", `lanes.${id}.git`), @@ -928,6 +937,8 @@ function laneConfig(id: HwlabRuntimeLane, raw: Record): HwlabLa } function laneTargetConfig(id: HwlabRuntimeLane, nodeId: string, baseRaw: Record, targetRaw: Record): HwlabLaneConfig { + const expectedConfigRef = `${HWLAB_NODE_LANE_CONFIG_PATH}#lanes.${id}.targets.${nodeId}`; + parseHwlabRuntimePipelineProvenance(targetRaw.pipelineProvenance, `lanes.${id}.targets.${nodeId}.pipelineProvenance`, expectedConfigRef); const merged: Record = { ...baseRaw, ...targetRaw, @@ -952,7 +963,34 @@ function laneTargetConfig(id: HwlabRuntimeLane, nodeId: string, baseRaw: Record< }; delete merged.targets; delete merged.activeTarget; - return laneConfig(id, merged); + const config = laneConfig(id, merged); + return config; +} + +function pipelineProvenanceConfig(value: unknown, path: string): HwlabRuntimePipelineProvenanceSpec | undefined { + if (value === undefined) return undefined; + return parseHwlabRuntimePipelineProvenance(value, path); +} + +export function parseHwlabRuntimePipelineProvenance( + value: unknown, + path: string, + expectedConfigRef?: string, +): HwlabRuntimePipelineProvenanceSpec { + if (value === undefined) throw new Error(`${path} must be declared by the target owner`); + const raw = asRecord(value, path); + const configRef = stringField(raw, "configRef", path); + if (!/^config\/hwlab-node-lanes\.yaml#lanes\.[A-Za-z0-9._-]+\.targets\.[A-Za-z0-9._-]+$/u.test(configRef)) { + throw new Error(`${path}.configRef must select a target owner in ${HWLAB_NODE_LANE_CONFIG_PATH}`); + } + if (expectedConfigRef !== undefined && configRef !== expectedConfigRef) { + throw new Error(`${path}.configRef must equal ${expectedConfigRef}`); + } + return { + configRef, + renderer: enumStringField(raw, "renderer", path, ["hwlab-runtime-lane"]), + mode: enumStringField(raw, "mode", path, ["remote-pipeline-annotation"]), + }; } function sourceAuthorityConfig(value: unknown, path: string): HwlabRuntimeSourceAuthoritySpec | undefined { @@ -2094,6 +2132,7 @@ function buildRuntimeLaneSpec(config: HwlabLaneConfig): HwlabRuntimeLaneSpec { pipelineRunPrefix: config.pipelineRunPrefix, serviceAccountName: config.serviceAccountName, controlPlaneFieldManager: config.controlPlaneFieldManager, + ...(config.pipelineProvenance === undefined ? {} : { pipelineProvenance: config.pipelineProvenance }), gitUrl: config.git.url, gitReadUrl: config.git.readUrl, gitWriteUrl: config.git.writeUrl, diff --git a/scripts/src/hwlab-node/pipeline-provenance.ts b/scripts/src/hwlab-node/pipeline-provenance.ts new file mode 100644 index 00000000..8a2bc50d --- /dev/null +++ b/scripts/src/hwlab-node/pipeline-provenance.ts @@ -0,0 +1,29 @@ +import { type HwlabRuntimeLaneSpec, hwlabRuntimeLaneConfigPath } from "../hwlab-node-lanes"; +import { pipelineProvenanceAnnotations, type PipelineProvenanceContract } from "../pipeline-provenance"; +import { stableJsonSha256 } from "../stable-json"; + +export interface HwlabRuntimePipelineProvenance extends PipelineProvenanceContract { + readonly renderer: "hwlab-runtime-lane"; + readonly mode: "remote-pipeline-annotation"; +} + +export function hwlabRuntimePipelineProvenance(spec: HwlabRuntimeLaneSpec): HwlabRuntimePipelineProvenance { + const declared = spec.pipelineProvenance; + if (declared === undefined) { + throw new Error(`${hwlabRuntimeLaneConfigPath()}#lanes.${spec.lane}.targets.${spec.nodeId}.pipelineProvenance is required`); + } + const expectedConfigRef = `${hwlabRuntimeLaneConfigPath()}#lanes.${spec.lane}.targets.${spec.nodeId}`; + if (declared.configRef !== expectedConfigRef) { + throw new Error(`pipelineProvenance.configRef must equal selected target owner ${expectedConfigRef}`); + } + return { + configRef: declared.configRef, + effectiveConfigSha256: stableJsonSha256(spec), + renderer: declared.renderer, + mode: declared.mode, + }; +} + +export function hwlabRuntimePipelineProvenanceAnnotations(spec: HwlabRuntimeLaneSpec): Record { + return pipelineProvenanceAnnotations(hwlabRuntimePipelineProvenance(spec)); +} diff --git a/scripts/src/hwlab-node/render.ts b/scripts/src/hwlab-node/render.ts index 8de169af..cfe81af2 100644 --- a/scripts/src/hwlab-node/render.ts +++ b/scripts/src/hwlab-node/render.ts @@ -41,11 +41,13 @@ import { webObserveShort, webObserveText } from "./web-probe-observe"; import { readNodeRuntimeStatusPolicy, runNodeRuntimeStatusProbe, skippedNodeRuntimeStatusCommand, type NodeRuntimeStatusPolicy, type NodeRuntimeStatusWorkerEvent } from "./control-plane-status-observed"; import { hwlabRuntimeActiveExternalPostgres } from "../hwlab-node-lanes"; import { nodeRuntimeDeployYamlOverlayShellScript } from "./deploy-overlay"; +import { pipelineProvenanceAnnotationKeys } from "../pipeline-provenance"; const runtimeGitopsObservabilityNativeScript = readFileSync(rootPath("scripts/native/hwlab/runtime-gitops-observability.mjs"), "utf8").trimEnd(); const runtimeGitopsPipelineGuardNativeScript = readFileSync(rootPath("scripts/native/hwlab/runtime-gitops-pipeline-guard.mjs"), "utf8").trimEnd(); const runtimeGitopsPostprocessNativeScript = readFileSync(rootPath("scripts/native/hwlab/runtime-gitops-postprocess.mjs"), "utf8").trimEnd(); const runtimeGitopsVerifyNativeScript = readFileSync(rootPath("scripts/native/hwlab/runtime-gitops-verify.mjs"), "utf8").trimEnd(); +const runtimePipelineProvenanceNativeScript = readFileSync(rootPath("scripts/native/hwlab/runtime-pipeline-provenance.mjs"), "utf8").trimEnd(); export function nodeRuntimeGitMirrorJobName(mirror: NodeRuntimeGitMirrorTargetSpec, action: "sync" | "flush"): string { const prefix = action === "sync" ? mirror.syncJobPrefix : mirror.flushJobPrefix; @@ -2521,11 +2523,6 @@ export function nodeRuntimePipelinePostprocessScript(): string[] { " doc.metadata = doc.metadata || {};", " if (typeof overlay.pipelineName === 'string' && overlay.pipelineName.length > 0) doc.metadata.name = overlay.pipelineName;", " doc.metadata.annotations = doc.metadata.annotations || {};", - " const provenance = overlay.sourceArtifactProvenance || {};", - " if (provenance.configRef) doc.metadata.annotations['unidesk.ai/owning-config-ref'] = provenance.configRef;", - " if (provenance.effectiveConfigSha256) doc.metadata.annotations['unidesk.ai/effective-config-sha256'] = provenance.effectiveConfigSha256;", - " if (provenance.renderer) doc.metadata.annotations['unidesk.ai/source-artifact-renderer'] = provenance.renderer;", - " if (provenance.mode) doc.metadata.annotations['unidesk.ai/source-artifact-mode'] = provenance.mode;", " doc.metadata.annotations['hwlab.pikastech.local/download-profile'] = overlay.downloadProfileId;", " doc.metadata.annotations['hwlab.pikastech.local/network-profile'] = overlay.networkProfileId;", " for (const task of doc.spec?.tasks || []) {", @@ -2836,10 +2833,26 @@ export function nodeRuntimePipelinePostprocessScript(): string[] { "patchArgoYaml(path.join(renderDir, 'argocd', 'project.yaml'));", "patchArgoYaml(path.join(renderDir, 'argocd', overlay.argoApplicationFile));", "NODE", + ...nodeRuntimePipelineProvenancePostprocessScript(), ...runtimeGitopsPipelineGuardScript(), ]; } +export function nodeRuntimePipelineProvenancePostprocessScript(): string[] { + const expectedKeys = Buffer.from(JSON.stringify(Object.values(pipelineProvenanceAnnotationKeys).sort()), "utf8").toString("base64"); + return [ + "runtime_gitops_guard_dir=\"$render_dir/.unidesk-runtime-gitops\"", + "mkdir -p \"$runtime_gitops_guard_dir\"", + ...writeRuntimeGitopsNativeScript("runtime-pipeline-provenance.mjs", runtimePipelineProvenanceNativeScript, "UNIDESK_RUNTIME_PIPELINE_PROVENANCE_MJS"), + [ + "UNIDESK_RUNTIME_GITOPS_OVERLAY_B64=\"$overlay_b64\"", + `UNIDESK_PIPELINE_PROVENANCE_KEYS_B64=${shellQuote(expectedKeys)}`, + "node \"$runtime_gitops_guard_dir/runtime-pipeline-provenance.mjs\"", + "--pipeline \"$render_dir/$(node -e 'const o=JSON.parse(Buffer.from(process.argv[1],\"base64\").toString(\"utf8\")); process.stdout.write(o.tektonDir)' \"$overlay_b64\")/pipeline.yaml\"", + ].join(" "), + ]; +} + function runtimeGitopsPipelineGuardScript(): string[] { return [ "runtime_gitops_guard_dir=\"$render_dir/.unidesk-runtime-gitops\"", diff --git a/scripts/src/hwlab-node/web-probe.ts b/scripts/src/hwlab-node/web-probe.ts index ff37f7bc..74e3411b 100644 --- a/scripts/src/hwlab-node/web-probe.ts +++ b/scripts/src/hwlab-node/web-probe.ts @@ -13,7 +13,7 @@ import { runCommand, type CommandResult } from "../command"; import { startJob } from "../jobs"; import { classifySshTcpPoolFailure } from "../ssh"; import { HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH, hwlabNodeControlPlaneCiGitWorkspaceSecret, hwlabNodeControlPlaneInfraHelp, runHwlabNodeControlPlaneInfra } from "../hwlab-node-control-plane"; -import { hwlabRuntimeLaneConfigPath, hwlabRuntimeLaneIds, hwlabRuntimeLaneSpec, hwlabRuntimeLaneSpecForNode, hwlabRuntimeNodeIds, isHwlabRuntimeLane, type HwlabRuntimeLane, type HwlabRuntimeLaneSpec, type HwlabRuntimeObservabilityRecordingRuleSpec, type HwlabRuntimeObservabilitySpec, type HwlabRuntimeObservabilityWarningAlertSpec, type HwlabRuntimePublicExposureSpec, type HwlabRuntimeWebProbeAlertThresholdsSpec, type HwlabRuntimeWebProbeProjectManagementSpec } from "../hwlab-node-lanes"; +import { hwlabRuntimeLaneIds, hwlabRuntimeLaneSpec, hwlabRuntimeLaneSpecForNode, hwlabRuntimeNodeIds, isHwlabRuntimeLane, type HwlabRuntimeLane, type HwlabRuntimeLaneSpec, type HwlabRuntimeObservabilityRecordingRuleSpec, type HwlabRuntimeObservabilitySpec, type HwlabRuntimeObservabilityWarningAlertSpec, type HwlabRuntimePublicExposureSpec, type HwlabRuntimeWebProbeAlertThresholdsSpec, type HwlabRuntimeWebProbeProjectManagementSpec } from "../hwlab-node-lanes"; import { nodeWebProbeScriptRunnerSource } from "../hwlab-node-web-probe-runner-source"; import { nodeWebObserveAnalyzerSource } from "../hwlab-node-web-observe-analyzer-source"; import { nodeWebObserveRunnerSource } from "../hwlab-node-web-observe-runner-source"; @@ -40,7 +40,7 @@ import { assertKnownOptions, nodeWebProbeAutoCommandTimeoutSeconds, normalizeNod import { resolveNodeWebProbeCliOrigin } from "./web-probe-origin"; import { hwlabRuntimeActiveExternalPostgres } from "../hwlab-node-lanes"; import { resolveEgressProxySourceRef } from "../egress-proxy-sources"; -import { stableJsonSha256 } from "../stable-json"; +import { hwlabRuntimePipelineProvenanceAnnotations } from "./pipeline-provenance"; export function nodeRuntimeRenderOverlay(spec: HwlabRuntimeLaneSpec): Record { const gitSshProxy = httpProxyEndpoint(spec.networkProfile.proxy.http); @@ -75,12 +75,7 @@ export function nodeRuntimeRenderOverlay(spec: HwlabRuntimeLaneSpec): Record { + return { + [pipelineProvenanceAnnotationKeys.configRef]: provenance.configRef, + [pipelineProvenanceAnnotationKeys.effectiveConfigSha256]: provenance.effectiveConfigSha256, + [pipelineProvenanceAnnotationKeys.renderer]: provenance.renderer, + [pipelineProvenanceAnnotationKeys.mode]: provenance.mode, + }; +} + +export function withPipelineProvenanceAnnotations( + manifest: Record, + provenance: PipelineProvenanceContract, +): Record { + const next = structuredClone(manifest); + const metadata = asRecord(next.metadata, "manifest.metadata"); + const annotations = metadata.annotations === undefined ? {} : asRecord(metadata.annotations, "manifest.metadata.annotations"); + metadata.annotations = { ...annotations, ...pipelineProvenanceAnnotations(provenance) }; + return next; +} + +export function pipelineProvenanceFromManifest(manifest: Record): PipelineProvenanceContract | null { + const metadata = isRecord(manifest.metadata) ? manifest.metadata : {}; + const annotations = isRecord(metadata.annotations) ? metadata.annotations : {}; + const configRef = annotations[pipelineProvenanceAnnotationKeys.configRef]; + const effectiveConfigSha256 = annotations[pipelineProvenanceAnnotationKeys.effectiveConfigSha256]; + const renderer = annotations[pipelineProvenanceAnnotationKeys.renderer]; + const mode = annotations[pipelineProvenanceAnnotationKeys.mode]; + if ([configRef, effectiveConfigSha256, renderer, mode].some((value) => typeof value !== "string" || value.length === 0)) return null; + return { configRef, effectiveConfigSha256, renderer, mode } as PipelineProvenanceContract; +} + +function asRecord(value: unknown, path: string): Record { + if (!isRecord(value)) throw new Error(`${path} must be an object`); + return value; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/scripts/src/platform-infra-pipelines-as-code-source-artifact.test.ts b/scripts/src/platform-infra-pipelines-as-code-source-artifact.test.ts index 6891b0c6..20544ec8 100644 --- a/scripts/src/platform-infra-pipelines-as-code-source-artifact.test.ts +++ b/scripts/src/platform-infra-pipelines-as-code-source-artifact.test.ts @@ -6,6 +6,11 @@ import { resolve } from "node:path"; import { rootPath } from "./config"; import { applyNodeRuntimeDeployYamlOverlay, nodeRuntimeDeployYamlOverlayShellScript } from "./hwlab-node/deploy-overlay"; +import { hwlabRuntimePipelineProvenance } from "./hwlab-node/pipeline-provenance"; +import { nodeRuntimePipelineProvenancePostprocessScript } from "./hwlab-node/render"; +import { nodeRuntimeRenderOverlay } from "./hwlab-node/web-probe"; +import { hwlabRuntimeLaneSpecForNode, parseHwlabRuntimePipelineProvenance } from "./hwlab-node-lanes"; +import { pipelineProvenanceAnnotationKeys, pipelineProvenanceAnnotations } from "./pipeline-provenance"; import { assertPacSourceArtifactVerifyWorktree, canonicalSha256, @@ -206,6 +211,74 @@ describe("PaC source artifact CLI contract", () => { }); }); +describe("HWLAB YAML-owned Pipeline provenance", () => { + const ownerPath = "lanes.v03.targets.NC01.pipelineProvenance"; + const configRef = "config/hwlab-node-lanes.yaml#lanes.v03.targets.NC01"; + + test("target owner parser fails closed for missing or inconsistent contracts", () => { + expect(() => parseHwlabRuntimePipelineProvenance(undefined, ownerPath, configRef)).toThrow("must be declared by the target owner"); + expect(() => parseHwlabRuntimePipelineProvenance({ + configRef: "config/hwlab-node-lanes.yaml#lanes.v03.targets.JD01", + renderer: "hwlab-runtime-lane", + mode: "remote-pipeline-annotation", + }, ownerPath, configRef)).toThrow(`must equal ${configRef}`); + expect(() => parseHwlabRuntimePipelineProvenance({ + configRef, + renderer: "another-renderer", + mode: "remote-pipeline-annotation", + }, ownerPath, configRef)).toThrow("renderer must be one of hwlab-runtime-lane"); + expect(() => parseHwlabRuntimePipelineProvenance({ + configRef, + renderer: "hwlab-runtime-lane", + mode: "embedded-pipeline-spec", + }, ownerPath, configRef)).toThrow("mode must be one of remote-pipeline-annotation"); + }); + + test("normal control-plane postprocess writes exactly the shared four provenance fields", () => { + const temporary = mkdtempSync(resolve(tmpdir(), "unidesk-hwlab-pipeline-provenance-")); + try { + const spec = hwlabRuntimeLaneSpecForNode("v03", "NC01"); + const overlay = nodeRuntimeRenderOverlay(spec); + const pipelineDir = resolve(temporary, spec.tektonDir); + const pipelinePath = resolve(pipelineDir, "pipeline.yaml"); + mkdirSync(pipelineDir, { recursive: true }); + writeFileSync(pipelinePath, Bun.YAML.stringify({ + apiVersion: "tekton.dev/v1", + kind: "Pipeline", + metadata: { name: spec.pipeline, namespace: "hwlab-ci", annotations: { "fixture.example/keep": "true" } }, + spec: { params: [], tasks: [] }, + })); + const overlayBase64 = Buffer.from(JSON.stringify(overlay), "utf8").toString("base64"); + const shell = [ + `render_dir=${JSON.stringify(temporary)}`, + `overlay_b64=${JSON.stringify(overlayBase64)}`, + ...nodeRuntimePipelineProvenancePostprocessScript(), + ].join("\n"); + const result = spawnSync("sh", [], { cwd: rootPath(), input: shell, encoding: "utf8", timeout: 30_000 }); + expect(result.status).toBe(0); + const rendered = Bun.YAML.parse(readFileSync(pipelinePath, "utf8")) as Record; + const annotations = rendered.metadata.annotations as Record; + const provenanceKeySet = new Set(Object.values(pipelineProvenanceAnnotationKeys)); + const renderedProvenance = Object.fromEntries(Object.entries(annotations).filter(([key]) => provenanceKeySet.has(key))); + expect(renderedProvenance).toEqual(overlay.pipelineProvenanceAnnotations); + expect(Object.keys(renderedProvenance).sort()).toEqual(Object.values(pipelineProvenanceAnnotationKeys).sort()); + expect(annotations["fixture.example/keep"]).toBe("true"); + + const invalidOverlay = { ...overlay, pipelineProvenanceAnnotations: { a: "1", b: "2", c: "3", d: "4" } }; + const invalidShell = [ + `render_dir=${JSON.stringify(temporary)}`, + `overlay_b64=${JSON.stringify(Buffer.from(JSON.stringify(invalidOverlay), "utf8").toString("base64"))}`, + ...nodeRuntimePipelineProvenancePostprocessScript(), + ].join("\n"); + const invalid = spawnSync("sh", [], { cwd: rootPath(), input: invalidShell, encoding: "utf8", timeout: 30_000 }); + expect(invalid.status).not.toBe(0); + expect(`${invalid.stdout}\n${invalid.stderr}`).toContain("shared four-key contract"); + } finally { + rmSync(temporary, { recursive: true, force: true }); + } + }); +}); + describe("Tekton admission canonicalization", () => { const admitted = { tasks: [{ @@ -381,11 +454,19 @@ describe("runtime source-commit selection", () => { }); 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 spec = hwlabRuntimeLaneSpecForNode("v03", "NC01"); + const remoteProvenance = hwlabRuntimePipelineProvenance(spec); + const overlay = nodeRuntimeRenderOverlay(spec); + const remoteAnnotations = overlay.pipelineProvenanceAnnotations as Record; + expect(spec.pipelineProvenance).toEqual({ + configRef: "config/hwlab-node-lanes.yaml#lanes.v03.targets.NC01", + renderer: "hwlab-runtime-lane", + mode: "remote-pipeline-annotation", + }); + expect(remoteAnnotations).toEqual(pipelineProvenanceAnnotations(remoteProvenance)); + expect(Object.keys(remoteAnnotations).sort()).toEqual(Object.values(pipelineProvenanceAnnotationKeys).sort()); + expect(readFileSync(rootPath("scripts", "src", "hwlab-node", "render.ts"), "utf8")).not.toContain("overlay.sourceArtifactProvenance"); + expect(readFileSync(rootPath("scripts", "src", "platform-infra-pipelines-as-code-source-artifact.ts"), "utf8")).not.toContain('"unidesk.ai/owning-config-ref"'); const wrapper = { mode: "remote-pipeline-annotation", executionMode: "pipeline-spec", @@ -411,7 +492,9 @@ describe("runtime source-commit selection", () => { ? { kind: "ok", value: { metadata: { name: input.pipeline, annotations: remoteAnnotations }, spec: desiredSpec } } : { kind: "ok", value: { items: [run] } }); expect(observed.live.status).toBe("aligned"); + expect(observed.live.provenanceAligned).toBe(true); expect(observed.embedded.status).toBe("aligned"); + expect(observed.embedded.provenanceAligned).toBe(true); expect(observed.embedded.firstDrift).toBeNull(); }); 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 1f81253d..e67af09d 100644 --- a/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts +++ b/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts @@ -11,10 +11,12 @@ import { hwlabRuntimeLaneSpecForNode, isHwlabRuntimeLane, type HwlabRuntimeLaneS import { nodeRuntimeGitopsRoot } from "./hwlab-node/cleanup"; import { nodeRuntimePipelinePostprocessScript } from "./hwlab-node/render"; import { nodeRuntimeRenderOverlay } from "./hwlab-node/web-probe"; +import { hwlabRuntimePipelineProvenance } from "./hwlab-node/pipeline-provenance"; import { applyNodeRuntimeDeployYamlOverlay } from "./hwlab-node/deploy-overlay"; import type { RenderedCliResult } from "./output"; import { renderedCliResult } from "./agentrun/render"; import { stableJsonSha256, stableJsonValue } from "./stable-json"; +import { pipelineProvenanceAnnotations, pipelineProvenanceFromManifest, withPipelineProvenanceAnnotations } from "./pipeline-provenance"; export type PacSourceArtifactMode = "embedded-pipeline-spec" | "remote-pipeline-annotation"; export type PacSourceArtifactRenderer = "agentrun-control-plane" | "hwlab-runtime-lane"; @@ -167,13 +169,6 @@ export function pacSourceArtifactSafeError(error: unknown): PacSourceArtifactSaf }; } -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", -} 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", @@ -442,7 +437,12 @@ function renderDesiredArtifact(binding: PacSourceArtifactBinding, sourceWorktree const rendered = sourceArtifact.renderer === "agentrun-control-plane" ? renderAgentRunDesiredPipeline(binding) : renderHwlabDesiredPipeline(binding, sourceWorktree); - const pipeline = withPipelineProvenance(rendered.pipeline, rendered.provenance); + const pipeline = sourceArtifact.renderer === "hwlab-runtime-lane" + ? rendered.pipeline + : withPipelineProvenanceAnnotations(rendered.pipeline, rendered.provenance); + if (!provenanceEquals(provenanceFromManifest(pipeline), rendered.provenance)) { + throw new Error(`${sourceArtifact.renderer} did not render the declared Pipeline provenance contract`); + } const desiredSpec = requiredRecord(pipeline.spec, "rendered Pipeline.spec"); assertPipelineIdentity(pipeline, binding); const pipelineRun = sourceArtifact.mode === "embedded-pipeline-spec" @@ -485,8 +485,11 @@ function renderAgentRunDesiredPipeline(binding: PacSourceArtifactBinding): { pip function renderHwlabDesiredPipeline(binding: PacSourceArtifactBinding, sourceWorktree: string): { pipeline: Record; provenance: PacSourceArtifactProvenance } { if (!isHwlabRuntimeLane(binding.consumer.lane)) throw new Error(`HWLAB source artifact lane ${binding.consumer.lane} is not declared`); const spec = hwlabRuntimeLaneSpecForNode(binding.consumer.lane, binding.consumer.node); - const expectedConfigRef = `config/hwlab-node-lanes.yaml#lanes.${spec.lane}.targets.${spec.nodeId}`; - assertConfigRef(binding.consumer.sourceArtifact.configRef, expectedConfigRef); + const provenance = hwlabRuntimePipelineProvenance(spec); + if (binding.consumer.sourceArtifact.renderer !== provenance.renderer || binding.consumer.sourceArtifact.mode !== provenance.mode) { + throw new Error(`sourceArtifact renderer/mode must match ${provenance.configRef}.pipelineProvenance`); + } + assertConfigRef(binding.consumer.sourceArtifact.configRef, provenance.configRef); assertBindingIdentity(binding, { node: spec.nodeId, lane: spec.lane, @@ -500,12 +503,7 @@ function renderHwlabDesiredPipeline(binding: PacSourceArtifactBinding, sourceWor const pipeline = renderHwlabPipelineFromOwningYaml(spec, sourceWorktree); return { pipeline, - provenance: { - configRef: expectedConfigRef, - effectiveConfigSha256: stableJsonSha256(spec), - renderer: "hwlab-runtime-lane", - mode: binding.consumer.sourceArtifact.mode, - }, + provenance, }; } @@ -547,7 +545,7 @@ function renderHwlabPipelineFromOwningYaml(spec: HwlabRuntimeLaneSpec, sourceWor `overlay_b64=${shellQuote(overlay)}`, ...nodeRuntimePipelinePostprocessScript(), ].join("\n"); - runChecked("sh", ["-c", postprocess], temporarySource, 120_000, "HWLAB UniDesk domain postprocess/verify renderer"); + runChecked("sh", [], temporarySource, 120_000, "HWLAB UniDesk domain postprocess/verify renderer", postprocess); const path = resolve(output, spec.tektonDir, "pipeline.yaml"); if (!existsSync(path)) throw new Error(`HWLAB domain renderer did not produce ${path}`); return parseYamlRecord(readFileSync(path, "utf8"), path); @@ -637,10 +635,7 @@ function pipelineRunAnnotations(binding: PacSourceArtifactBinding, provenance: P "pipelinesascode.tekton.dev/on-target-branch": `[${branch}]`, "pipelinesascode.tekton.dev/on-cel-expression": `event == 'push' && target_branch == '${branch}' && node == '${binding.consumer.node}'`, "pipelinesascode.tekton.dev/max-keep-runs": String(binding.consumer.sourceArtifact.maxKeepRuns), - [provenanceKeys.configRef]: provenance.configRef, - [provenanceKeys.effectiveConfigSha256]: provenance.effectiveConfigSha256, - [provenanceKeys.renderer]: provenance.renderer, - [provenanceKeys.mode]: binding.consumer.sourceArtifact.mode, + ...pipelineProvenanceAnnotations(provenance), }; } @@ -745,20 +740,6 @@ function pipelineRunWorkspaces(binding: PacSourceArtifactBinding, desiredSpec: R }); } -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 : {}; - metadata.annotations = { - ...annotations, - [provenanceKeys.configRef]: provenance.configRef, - [provenanceKeys.effectiveConfigSha256]: provenance.effectiveConfigSha256, - [provenanceKeys.renderer]: provenance.renderer, - [provenanceKeys.mode]: provenance.mode, - }; - return next; -} - function inspectSourceArtifact(sourceWorktree: string, binding: PacSourceArtifactBinding, desired: DesiredArtifact): SourceInspection { const sourceArtifact = binding.consumer.sourceArtifact; const pipelineRunPath = safeArtifactPath(sourceWorktree, sourceArtifact.pipelineRunPath); @@ -932,14 +913,11 @@ function assertPipelineIdentity(pipeline: Record, binding: PacS } 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]; - 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 }; + const provenance = pipelineProvenanceFromManifest(manifest); + if (provenance === null) return null; + if (provenance.renderer !== "agentrun-control-plane" && provenance.renderer !== "hwlab-runtime-lane") return null; + if (provenance.mode !== "embedded-pipeline-spec" && provenance.mode !== "remote-pipeline-annotation") return null; + return provenance as PacSourceArtifactProvenance; } function provenanceEquals(actual: PacSourceArtifactProvenance | null, expected: PacSourceArtifactProvenance): boolean { @@ -1098,9 +1076,13 @@ function git(worktree: string, args: readonly string[]): string { return result.stdout.trim(); } -function runChecked(command: string, args: readonly string[], cwd: string, timeout: number, label: string): void { - const result = spawnSync(command, [...args], { cwd, encoding: "utf8", timeout, maxBuffer: 32 * 1024 * 1024 }); - if (result.status !== 0) throw new Error(`${label} failed: ${(result.stderr || result.stdout).trim().slice(-4000)}`); +function runChecked(command: string, args: readonly string[], cwd: string, timeout: number, label: string, input?: string): void { + const result = spawnSync(command, [...args], { cwd, encoding: "utf8", timeout, maxBuffer: 32 * 1024 * 1024, input }); + if (result.status !== 0) { + const output = [result.stderr, result.stdout].find((value) => typeof value === "string" && value.trim().length > 0)?.trim(); + const reason = output ?? result.error?.message ?? `exit=${String(result.status)} signal=${String(result.signal)}`; + throw new Error(`${label} failed: ${reason.slice(-4000)}`); + } } function shellQuote(value: string): string {