feat(cicd): generate PaC source artifacts from YAML
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import type { UniDeskConfig } from "./config";
|
||||
@@ -17,11 +17,21 @@ import {
|
||||
sha256Fingerprint,
|
||||
} from "./platform-infra-ops-library";
|
||||
import { materializeYamlComposition } from "./yaml-composition";
|
||||
import {
|
||||
canonicalizePacPipelineSpec,
|
||||
parsePacSourceArtifactOptions,
|
||||
renderPacSourceArtifactResult,
|
||||
runPacSourceArtifact,
|
||||
type PacSourceArtifactBinding,
|
||||
type PacSourceArtifactRuntimeObservation,
|
||||
type PacSourceArtifactSpec,
|
||||
} from "./platform-infra-pipelines-as-code-source-artifact";
|
||||
|
||||
const configFile = rootPath("config", "platform-infra", "pipelines-as-code.yaml");
|
||||
const configLabel = "config/platform-infra/pipelines-as-code.yaml";
|
||||
const remoteScriptFile = rootPath("scripts", "src", "platform-infra-pipelines-as-code-remote.sh");
|
||||
const evaluatorFile = rootPath("scripts", "native", "cicd", "pac-status-evaluator.cjs");
|
||||
const sourceArtifactRuntimeObserverFile = rootPath("scripts", "native", "cicd", "pac-source-artifact-runtime.mjs");
|
||||
const fieldManager = "unidesk-platform-infra-pipelines-as-code";
|
||||
const y = createYamlFieldReader(configLabel);
|
||||
|
||||
@@ -132,6 +142,7 @@ interface PacConsumer {
|
||||
repositoryRef: string;
|
||||
closeoutGitOpsMirrorFlush: boolean;
|
||||
closeoutGitOpsMirrorLane: "v02" | "v03" | null;
|
||||
sourceArtifact: PacSourceArtifactSpec | null;
|
||||
}
|
||||
|
||||
interface CommonOptions {
|
||||
@@ -216,12 +227,78 @@ export async function runPlatformInfraPipelinesAsCodeCommand(config: UniDeskConf
|
||||
const result = await webhookTest(config, options);
|
||||
return options.json || options.full || options.raw ? result : renderWebhookTest(result);
|
||||
}
|
||||
if (action === "source-artifact") {
|
||||
if (args.length === 1 || args.slice(1).includes("--help") || args.slice(1).includes("-h")) return sourceArtifactHelp();
|
||||
const structuredError = args.includes("--json") || args.includes("--full");
|
||||
try {
|
||||
const options = parsePacSourceArtifactOptions(args.slice(1));
|
||||
const pac = readPacConfig();
|
||||
const target = resolveTarget(pac, options.targetId);
|
||||
const consumer = resolveConsumer(pac, options.consumerId);
|
||||
if (consumer.node.toLowerCase() !== target.id.toLowerCase()) throw new Error(`Pipelines-as-Code consumer ${consumer.id} belongs to ${consumer.node}, not target ${target.id}`);
|
||||
if (consumer.sourceArtifact === null) throw new Error(`Pipelines-as-Code consumer ${consumer.id} has no sourceArtifact owner in ${configLabel}`);
|
||||
const repository = resolveRepository(pac, consumer.repositoryRef);
|
||||
const binding: PacSourceArtifactBinding = {
|
||||
target: { id: target.id },
|
||||
consumer: {
|
||||
id: consumer.id,
|
||||
node: consumer.node,
|
||||
lane: consumer.lane,
|
||||
namespace: consumer.namespace,
|
||||
pipeline: consumer.pipeline,
|
||||
pipelineRunPrefix: consumer.pipelineRunPrefix,
|
||||
sourceArtifact: consumer.sourceArtifact,
|
||||
},
|
||||
repository: {
|
||||
id: repository.id,
|
||||
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));
|
||||
return options.json || options.full ? result : renderPacSourceArtifactResult(result);
|
||||
} catch (error) {
|
||||
if (structuredError) throw error;
|
||||
return sourceArtifactValidationError(error);
|
||||
}
|
||||
}
|
||||
return { ok: false, error: "unsupported-platform-infra-pipelines-as-code-command", args, help: help() };
|
||||
}
|
||||
|
||||
function sourceArtifactHelp(): RenderedCliResult {
|
||||
const lines = [
|
||||
"PAC SOURCE ARTIFACT",
|
||||
"Generate and verify consumer-declared PaC source artifacts from owning YAML plus the shared domain renderer.",
|
||||
"",
|
||||
"Usage:",
|
||||
" ... source-artifact plan --target <node> --consumer <id> --source-worktree <absolute-path>",
|
||||
" ... source-artifact check --target <node> --consumer <id> --source-worktree <absolute-path>",
|
||||
" ... source-artifact write --target <node> --consumer <id> --source-worktree <absolute-path> --confirm",
|
||||
" ... source-artifact status --target <node> --consumer <id> --source-worktree <absolute-path> [--source-commit <full-sha>]",
|
||||
" ... source-artifact verify-runtime --target <node> --consumer <id> --source-worktree <absolute-path> --source-commit <full-sha>",
|
||||
"",
|
||||
"plan/check/write compare desired YAML-rendered state with source files and never access runtime.",
|
||||
"status is non-gating runtime diagnostics; verify-runtime is fail-closed and binds the PipelineRun to the exact source commit.",
|
||||
"Use --json or --full for explicit structured output; source specs and Secret values are never printed.",
|
||||
];
|
||||
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);
|
||||
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`,
|
||||
contentType: "text/plain",
|
||||
};
|
||||
}
|
||||
|
||||
function help(): Record<string, unknown> {
|
||||
return {
|
||||
command: "platform-infra pipelines-as-code plan|apply|status|closeout|history|debug-step",
|
||||
command: "platform-infra pipelines-as-code plan|apply|status|closeout|history|debug-step|source-artifact",
|
||||
configTruth: configLabel,
|
||||
usage: [
|
||||
"bun scripts/cli.ts platform-infra pipelines-as-code plan --target JD01",
|
||||
@@ -232,6 +309,9 @@ function help(): Record<string, unknown> {
|
||||
"bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 [--consumer hwlab-jd01-v03] [--limit 10]",
|
||||
"bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --id <pipelinerun>",
|
||||
"bun scripts/cli.ts platform-infra pipelines-as-code debug-step --target JD01 [--consumer <consumer>] [--id <pipelinerun>] [--json]",
|
||||
"bun scripts/cli.ts platform-infra pipelines-as-code source-artifact check --target NC01 --consumer agentrun-nc01-v02 --source-worktree /abs/worktree",
|
||||
"bun scripts/cli.ts platform-infra pipelines-as-code source-artifact write --target NC01 --consumer agentrun-nc01-v02 --source-worktree /abs/worktree --confirm",
|
||||
"bun scripts/cli.ts platform-infra pipelines-as-code source-artifact verify-runtime --target NC01 --consumer agentrun-nc01-v02 --source-worktree /abs/worktree --source-commit <full-sha>",
|
||||
],
|
||||
diagnostics: "webhook-test exists only for bounded connectivity diagnosis and must not be used as delivery evidence.",
|
||||
boundary: "Sole CI trigger path for GH-1552/GH-1607: GitHub PR merge -> GitHub webhook bridge -> Gitea mirror/snapshot -> Pipelines-as-Code -> Tekton -> GitOps/Argo/k8s runtime.",
|
||||
@@ -353,9 +433,112 @@ function parseConsumer(consumer: Record<string, unknown>, path: string, defaultR
|
||||
repositoryRef: typeof consumer.repositoryRef === "string" && consumer.repositoryRef.length > 0 ? consumer.repositoryRef : defaultRepositoryRef,
|
||||
closeoutGitOpsMirrorFlush: y.booleanField(consumer, "closeoutGitOpsMirrorFlush", path),
|
||||
closeoutGitOpsMirrorLane: consumer.closeoutGitOpsMirrorLane === undefined ? null : y.enumField(consumer, "closeoutGitOpsMirrorLane", path, ["v02", "v03"] as const),
|
||||
sourceArtifact: consumer.sourceArtifact === undefined ? null : parseSourceArtifact(y.objectField(consumer, "sourceArtifact", path), `${path}.sourceArtifact`),
|
||||
};
|
||||
}
|
||||
|
||||
function parseSourceArtifact(value: Record<string, unknown>, path: string): PacSourceArtifactSpec {
|
||||
const mode = y.enumField(value, "mode", path, ["embedded-pipeline-spec", "remote-pipeline-annotation"] as const);
|
||||
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`);
|
||||
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`);
|
||||
if (renderer === "hwlab-runtime-lane" && mode !== "remote-pipeline-annotation") throw new Error(`${path}.renderer hwlab-runtime-lane requires remote-pipeline-annotation`);
|
||||
return {
|
||||
mode,
|
||||
renderer,
|
||||
configRef: y.stringField(value, "configRef", path),
|
||||
pipelineRunPath,
|
||||
pipelinePath,
|
||||
maxKeepRuns: positiveInteger(value, "maxKeepRuns", path),
|
||||
};
|
||||
}
|
||||
|
||||
function sourceArtifactPath(value: string, path: string): string {
|
||||
if (value.startsWith("/") || value.split(/[\\/]/u).includes("..") || !value.endsWith(".yaml")) throw new Error(`${path} must be a worktree-relative .yaml path without ..`);
|
||||
return value;
|
||||
}
|
||||
|
||||
async function observeSourceArtifactRuntime(
|
||||
config: UniDeskConfig,
|
||||
target: PacTarget,
|
||||
consumer: PacConsumer,
|
||||
desiredSpec: Record<string, unknown>,
|
||||
provenance: { readonly configRef: string; readonly effectiveConfigSha256: string; readonly renderer: string },
|
||||
sourceCommit: string | null,
|
||||
): Promise<PacSourceArtifactRuntimeObservation> {
|
||||
const script = renderPacSourceArtifactRuntimeObserverShell({
|
||||
namespace: consumer.namespace,
|
||||
pipeline: consumer.pipeline,
|
||||
pipelineRunPrefix: consumer.pipelineRunPrefix,
|
||||
desiredSpec: canonicalizePacPipelineSpec(desiredSpec),
|
||||
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);
|
||||
}
|
||||
const parsed = parseJsonOutput(captured.stdout);
|
||||
if (parsed === null) return unavailableSourceArtifactRuntime("runtime-observer-invalid-json", sourceCommit);
|
||||
return {
|
||||
live: runtimeSourceArtifactItem(parsed.live),
|
||||
embedded: runtimeSourceArtifactItem(parsed.embedded),
|
||||
};
|
||||
}
|
||||
|
||||
export function renderPacSourceArtifactRuntimeObserverShell(inputValue: Record<string, unknown>): string {
|
||||
const input = JSON.stringify(inputValue);
|
||||
const observer = readFileSync(sourceArtifactRuntimeObserverFile, "utf8").trimEnd();
|
||||
if (observer.includes("NODE_PAC_SOURCE_ARTIFACT_STATUS") || input.includes("JSON_PAC_SOURCE_ARTIFACT_INPUT")) throw new Error("PaC source artifact observer input contains a reserved heredoc delimiter");
|
||||
return `set -eu
|
||||
observer_input=$(mktemp)
|
||||
trap 'rm -f "$observer_input"' EXIT HUP INT TERM
|
||||
cat >"$observer_input" <<'JSON_PAC_SOURCE_ARTIFACT_INPUT'
|
||||
${input}
|
||||
JSON_PAC_SOURCE_ARTIFACT_INPUT
|
||||
PAC_SOURCE_ARTIFACT_INPUT_FILE="$observer_input" node --input-type=module <<'NODE_PAC_SOURCE_ARTIFACT_STATUS'
|
||||
${observer}
|
||||
NODE_PAC_SOURCE_ARTIFACT_STATUS
|
||||
`;
|
||||
}
|
||||
|
||||
function runtimeSourceArtifactItem(value: unknown): PacSourceArtifactRuntimeObservation["live"] {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return unavailableSourceArtifactRuntime("runtime-observer-invalid-item", null).live;
|
||||
const item = value as Record<string, unknown>;
|
||||
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;
|
||||
return {
|
||||
status,
|
||||
name: typeof item.name === "string" ? item.name : null,
|
||||
canonicalSha256: typeof item.canonicalSha256 === "string" ? item.canonicalSha256 : null,
|
||||
firstDrift: typeof drift === "object" && drift !== null && !Array.isArray(drift)
|
||||
? {
|
||||
path: String((drift as Record<string, unknown>).path ?? "$"),
|
||||
expected: String((drift as Record<string, unknown>).expected ?? ""),
|
||||
actual: String((drift as Record<string, unknown>).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,
|
||||
candidateCount: typeof item.candidateCount === "number" && Number.isInteger(item.candidateCount) && item.candidateCount >= 0 ? item.candidateCount : 0,
|
||||
};
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
function parseTarget(record: Record<string, unknown>, index: number): PacTarget {
|
||||
const path = `targets[${index}]`;
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user