3403 lines
164 KiB
TypeScript
3403 lines
164 KiB
TypeScript
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";
|
|
import { rootPath } from "./config";
|
|
import type { RenderedCliResult } from "./output";
|
|
import {
|
|
capture,
|
|
compactCapture,
|
|
createYamlFieldReader,
|
|
parseJsonOutput,
|
|
readYamlRecord,
|
|
shQuote,
|
|
sha256Fingerprint,
|
|
} from "./platform-infra-ops-library";
|
|
import { materializeYamlComposition } from "./yaml-composition";
|
|
import { kubernetesWatchOneEventShellFunction } from "./kubernetes-watch";
|
|
import { pacNodeReadOnlyNext, pacReadOnlyNext, resolveCicdDeliveryAuthority } from "./cicd-delivery-authority";
|
|
import { DEFAULT_READ_ONLY_LONG_POLL_TIMEOUT_MS, parseStrictDuration, readOnlyLongPollTransportTimeoutMs, runReadOnlyLongPoll } from "./read-only-long-poll";
|
|
import {
|
|
canonicalizePacPipelineSpec,
|
|
pacSourceArtifactSafeError,
|
|
pacValueEvidence,
|
|
parsePacSourceArtifactOptions,
|
|
renderPacSourceArtifactResult,
|
|
runPacSourceArtifact,
|
|
type PacSourceArtifactBinding,
|
|
type PacSourceArtifactRuntimeObservation,
|
|
type PacSourceArtifactSpec,
|
|
} from "./platform-infra-pipelines-as-code-source-artifact";
|
|
import { pacAdmissionDesiredIdentity } from "./platform-infra-pac-provenance";
|
|
import { pacConsumerRbacDesiredIdentity, renderPacConsumerRbacDesiredFragment } from "./platform-infra-pac-consumer-rbac";
|
|
import { runGiteaMirrorBootstrapPreflight, runPlatformInfraGiteaCommand } from "./platform-infra-gitea";
|
|
import { readGiteaConfig } from "./platform-infra-gitea-config";
|
|
import {
|
|
pacBootstrapYamlMatchFailure,
|
|
projectPacBootstrapResult,
|
|
renderPacBootstrap,
|
|
resolvePacBootstrapMirrorRepository,
|
|
} from "./platform-infra-pipelines-as-code-bootstrap";
|
|
import { featureConfigSchemaHistoryFields, renderFeatureConfigSchemaLine } from "./platform-infra-pac-feature-config-projection";
|
|
import {
|
|
observePacDeliveryTiming,
|
|
observePacHistoryDeliveryBudget,
|
|
observePacStatusDeliveryBudget,
|
|
type PacDeliveryTimingPolicy,
|
|
} from "./platform-infra-pac-delivery-timing";
|
|
|
|
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);
|
|
|
|
function kubernetesLabelValue(value: string): string {
|
|
const normalized = value
|
|
.trim()
|
|
.replace(/[^A-Za-z0-9_.-]+/gu, "-")
|
|
.replace(/^-+|-+$/gu, "")
|
|
.slice(0, 63);
|
|
return normalized.length > 0 ? normalized : "unspecified";
|
|
}
|
|
|
|
interface PacTarget {
|
|
id: string;
|
|
route: string;
|
|
namespace: string;
|
|
role: string;
|
|
enabled: boolean;
|
|
}
|
|
|
|
export interface PacConfig {
|
|
version: number;
|
|
kind: "platform-infra-pipelines-as-code";
|
|
metadata: {
|
|
id: string;
|
|
owner: string;
|
|
spec: string;
|
|
relatedIssues: number[];
|
|
};
|
|
defaults: {
|
|
targetId: string;
|
|
consumerId: string;
|
|
};
|
|
display: {
|
|
timeZone: string;
|
|
};
|
|
deliveryTiming: PacDeliveryTimingPolicy;
|
|
observability: {
|
|
configRef: string;
|
|
tracesEndpoint: string;
|
|
serviceName: string;
|
|
propagation: string[];
|
|
samplingMode: "parentbased_traceidratio";
|
|
samplingRatio: number;
|
|
exporterFailure: { warning: true; blocking: false; timeoutMs: number };
|
|
};
|
|
release: {
|
|
version: string;
|
|
manifestUrl: string;
|
|
namespace: string;
|
|
controllerServiceName: string;
|
|
controllerServicePort: number;
|
|
waitTimeoutSeconds: number;
|
|
};
|
|
deliveryProvenance: {
|
|
version: string;
|
|
markerAnnotation: string;
|
|
controllerIdentity: string;
|
|
policyName: string;
|
|
bindingName: string;
|
|
};
|
|
capabilities: {
|
|
sentinelInternalPublish: {
|
|
enabled: boolean;
|
|
admissionProvenance: "unavailable";
|
|
trackingIssue: string;
|
|
};
|
|
};
|
|
closeout: {
|
|
gitOpsMirrorFlush: {
|
|
enabled: boolean;
|
|
waitTimeoutSeconds: number;
|
|
maxAttempts: number;
|
|
};
|
|
};
|
|
targets: PacTarget[];
|
|
gitea: {
|
|
configRef: string;
|
|
internalBaseUrl: string;
|
|
admin: {
|
|
sourceRoot: string;
|
|
sourceRef: string;
|
|
format: "line-pair";
|
|
usernameLine: number;
|
|
passwordLine: number;
|
|
apiUsername: string;
|
|
};
|
|
webhook: {
|
|
secretRoot: string;
|
|
secretSourceRef: string;
|
|
events: string[];
|
|
branch: string;
|
|
};
|
|
};
|
|
repositories: PacRepository[];
|
|
consumers: PacConsumer[];
|
|
validationWarnings: PacConfigValidationWarning[];
|
|
}
|
|
|
|
export interface PacConfigValidationWarning {
|
|
readonly code:
|
|
| "pac-unselected-consumer-parse-invalid"
|
|
| "pac-unselected-consumer-invalid"
|
|
| "pac-unselected-repository-parse-invalid"
|
|
| "pac-unselected-repository-invalid";
|
|
readonly object: { readonly kind: "consumer" | "repository"; readonly id: string };
|
|
readonly consumer: string | null;
|
|
readonly configPath: string;
|
|
readonly blocking: false;
|
|
readonly evidence: string;
|
|
}
|
|
|
|
interface PacRepository {
|
|
id: string;
|
|
name: string;
|
|
namespace: string;
|
|
providerType: "gitea";
|
|
url: string;
|
|
cloneUrl: string;
|
|
owner: string;
|
|
repo: string;
|
|
secretName: string;
|
|
tokenKey: string;
|
|
webhookSecretKey: string;
|
|
concurrencyLimit: number;
|
|
params: Record<string, string>;
|
|
}
|
|
|
|
interface PacConsumer {
|
|
id: string;
|
|
node: string;
|
|
lane: string;
|
|
namespace: string;
|
|
pipeline: string;
|
|
pipelineRunPrefix: string;
|
|
argoNamespace: string;
|
|
argoApplication: string;
|
|
argoBootstrap: {
|
|
project: string;
|
|
repoUrl: string;
|
|
targetRevision: string;
|
|
path: string;
|
|
destinationNamespace: string;
|
|
automated: boolean;
|
|
repositoryCredential: {
|
|
secretName: string;
|
|
username: string;
|
|
} | null;
|
|
} | null;
|
|
deliveryProvenance: {
|
|
required: true;
|
|
markerValue: string;
|
|
executionServiceAccountName: string;
|
|
gitOps: { repoUrl: string; targetRevision: string };
|
|
} | null;
|
|
repositoryRef: string;
|
|
params: Record<string, string>;
|
|
closeoutGitOpsMirrorFlush: boolean;
|
|
closeoutGitOpsMirrorLane: "v02" | "v03" | null;
|
|
sourceArtifact: PacSourceArtifactSpec | null;
|
|
runnerServiceAccount: {
|
|
name: string;
|
|
automountServiceAccountToken: false;
|
|
roleBindingName: string;
|
|
} | null;
|
|
}
|
|
|
|
interface CommonOptions {
|
|
targetId: string | null;
|
|
consumerId: string | null;
|
|
full: boolean;
|
|
raw: boolean;
|
|
json: boolean;
|
|
}
|
|
|
|
export interface PipelinesAsCodeNodeStatusOptions {
|
|
targetId: string | null;
|
|
nodeId: string | null;
|
|
full: boolean;
|
|
raw: boolean;
|
|
}
|
|
|
|
interface HistoryOptions extends CommonOptions {
|
|
limit: number;
|
|
detailId: string | null;
|
|
}
|
|
|
|
export interface PacHistoryConsumerIdentity {
|
|
readonly id: string;
|
|
readonly node: string;
|
|
readonly pipelineRunPrefix: string;
|
|
}
|
|
|
|
export interface PacHistoryConsumerSelection {
|
|
readonly selectedConsumerIds: readonly string[];
|
|
readonly nextConsumerId: string | null;
|
|
}
|
|
|
|
interface CloseoutOptions extends CommonOptions {
|
|
sourceCommit: string | null;
|
|
wait: boolean;
|
|
timeoutMs: number;
|
|
}
|
|
|
|
interface ApplyOptions extends CommonOptions {
|
|
confirm: boolean;
|
|
dryRun: boolean;
|
|
wait: boolean;
|
|
}
|
|
|
|
interface SecretMaterial {
|
|
adminUsername: string;
|
|
adminPassword: string;
|
|
adminFingerprint: string;
|
|
webhookSecret: string;
|
|
webhookFingerprint: string;
|
|
webhookPath: string;
|
|
}
|
|
|
|
export async function runPlatformInfraPipelinesAsCodeCommand(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown> | RenderedCliResult> {
|
|
const [action = "plan"] = args;
|
|
if (action === "help" || action === "--help") return help(args[1] ?? null);
|
|
if (action === "plan") {
|
|
const options = parseCommonOptions(args.slice(1));
|
|
const result = plan(options);
|
|
return options.full || options.raw ? result : options.json ? compactPlanJson(result) : renderPlan(result);
|
|
}
|
|
if (action === "apply") {
|
|
const options = parseApplyOptions(args.slice(1));
|
|
const result = await apply(config, options);
|
|
return options.json || options.full || options.raw ? result : renderApply(result);
|
|
}
|
|
if (action === "bootstrap") {
|
|
const options = parseApplyOptions(args.slice(1));
|
|
const result = await bootstrap(config, options);
|
|
return options.full || options.raw ? result : options.json ? projectPacBootstrapResult(result) : renderPacBootstrap(result);
|
|
}
|
|
if (action === "status") {
|
|
const options = parseCommonOptions(args.slice(1));
|
|
const result = await status(config, options);
|
|
return options.full || options.raw ? result : options.json ? compactStatusJson(result) : renderStatus(result);
|
|
}
|
|
if (action === "closeout") {
|
|
let options: CloseoutOptions;
|
|
try {
|
|
options = parseCloseoutOptions(args.slice(1));
|
|
} catch (error) {
|
|
return {
|
|
ok: false,
|
|
action: "platform-infra-pipelines-as-code-closeout-validation",
|
|
mutation: false,
|
|
code: "validation-failed",
|
|
message: error instanceof Error ? error.message : String(error),
|
|
usage: "bun scripts/cli.ts platform-infra pipelines-as-code closeout --target <NODE> --consumer <consumer> --wait [--timeout 120s]",
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
const result = await closeout(config, options);
|
|
return options.full || options.raw ? result : options.json ? compactCloseoutJson(result) : renderCloseout(result);
|
|
}
|
|
if (action === "history" || action === "runs") {
|
|
const options = parseHistoryOptions(args.slice(1));
|
|
const result = await history(config, options);
|
|
return options.raw ? result : options.full ? compactHistoryJson(result, true) : options.json ? compactHistoryJson(result) : renderHistory(result);
|
|
}
|
|
if (action === "delivery-timing" || action === "timing") {
|
|
const options = parseCommonOptions(args.slice(1));
|
|
const result = await deliveryTiming(config, options);
|
|
return options.full || options.raw || options.json ? result : renderDeliveryTiming(result);
|
|
}
|
|
if (action === "debug-step") {
|
|
const options = parseHistoryOptions(args.slice(1));
|
|
const result = await debugStep(config, options);
|
|
return options.full || options.raw ? result : options.json ? result : renderDebugStep(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({ consumerId: options.consumerId });
|
|
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,
|
|
argoNamespace: consumer.argoNamespace,
|
|
argoApplication: consumer.argoApplication,
|
|
argoBootstrap: consumer.argoBootstrap,
|
|
deliveryProvenance: consumer.deliveryProvenance === null ? null : {
|
|
markerAnnotation: pac.deliveryProvenance.markerAnnotation,
|
|
markerValue: consumer.deliveryProvenance.markerValue,
|
|
executionServiceAccountName: consumer.deliveryProvenance.executionServiceAccountName,
|
|
},
|
|
sourceArtifact: consumer.sourceArtifact,
|
|
},
|
|
repository: {
|
|
id: repository.id,
|
|
url: repository.url,
|
|
cloneUrl: repository.cloneUrl,
|
|
owner: repository.owner,
|
|
repo: repository.repo,
|
|
params: consumerParams(repository, consumer),
|
|
},
|
|
};
|
|
const result = {
|
|
...await runPacSourceArtifact(binding, options, async ({ desiredSpec, desiredWrapper, provenance, sourceCommit }) => await observeSourceArtifactRuntime(config, target, consumer, desiredSpec, desiredWrapper, provenance, sourceCommit)),
|
|
warnings: pac.validationWarnings,
|
|
};
|
|
return options.json || options.full ? result : renderPacSourceArtifactResult(result);
|
|
} catch (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(null) };
|
|
}
|
|
|
|
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: ReturnType<typeof pacSourceArtifactSafeError>): 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\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",
|
|
};
|
|
}
|
|
|
|
function help(scope: string | null): Record<string, unknown> {
|
|
if (scope === "platform-bootstrap") {
|
|
return {
|
|
command: "platform-infra pipelines-as-code help platform-bootstrap",
|
|
scope: "explicit-initial-platform-bootstrap",
|
|
configTruth: configLabel,
|
|
usage: [
|
|
"bun scripts/cli.ts platform-infra pipelines-as-code bootstrap --target <NODE> --consumer <consumer> --dry-run",
|
|
"bun scripts/cli.ts platform-infra pipelines-as-code bootstrap --target <NODE> --consumer <consumer> --confirm",
|
|
"bun scripts/cli.ts platform-infra pipelines-as-code apply --target <NODE> --dry-run",
|
|
"bun scripts/cli.ts platform-infra pipelines-as-code apply --target <NODE> --confirm",
|
|
],
|
|
boundary: "bootstrap 是首次引导的最短入口;apply 保留为单层维护入口。两者都不得在 source PR 合并后用于交付、恢复或补跑。",
|
|
mutation: "explicit-confirm-required",
|
|
};
|
|
}
|
|
if (scope === "compatibility-diagnostics") {
|
|
return {
|
|
command: "platform-infra pipelines-as-code help compatibility-diagnostics",
|
|
scope: "explicit-readonly-or-connectivity-diagnostics",
|
|
configTruth: configLabel,
|
|
usage: [
|
|
"bun scripts/cli.ts platform-infra pipelines-as-code closeout --target <NODE> --consumer <consumer> --source-commit <sha> --wait [--timeout 120s]",
|
|
"bun scripts/cli.ts platform-infra pipelines-as-code status --target <NODE> --consumer <consumer> --full",
|
|
"bun scripts/cli.ts platform-infra pipelines-as-code history --target <NODE> --consumer <consumer> --limit 10 --full",
|
|
"bun scripts/cli.ts platform-infra pipelines-as-code debug-step --target <NODE> --consumer <consumer> --json",
|
|
],
|
|
boundary: "本 scope 只允许读取现有 PaC/Tekton/Argo 证据;不得 POST hook test、伪造 push、交付、恢复或补跑。",
|
|
};
|
|
}
|
|
return {
|
|
command: "platform-infra pipelines-as-code plan|status|history|delivery-timing|debug-step|source-artifact",
|
|
configTruth: configLabel,
|
|
usage: [
|
|
"bun scripts/cli.ts platform-infra pipelines-as-code plan --target JD01",
|
|
"bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01 [--json|--full|--raw]",
|
|
"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 delivery-timing --target NC01 --consumer selfmedia-nc01 [--json]",
|
|
"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 verify-runtime --target NC01 --consumer agentrun-nc01-v02 --source-worktree /abs/worktree --source-commit <full-sha>",
|
|
],
|
|
maintenanceHelp: "bun scripts/cli.ts platform-infra pipelines-as-code help platform-bootstrap",
|
|
compatibilityHelp: "bun scripts/cli.ts platform-infra pipelines-as-code help compatibility-diagnostics",
|
|
boundary: "唯一正式触发链:GitHub PR merge -> GitHub webhook bridge -> Gitea mirror/snapshot -> Pipelines-as-Code -> Tekton -> GitOps/Argo/k8s runtime。",
|
|
};
|
|
}
|
|
|
|
export function readPacConfig(selection: { readonly consumerId?: string | null; readonly selectDefault?: boolean } = {}): PacConfig {
|
|
const root = materializeYamlComposition(readYamlRecord<Record<string, unknown>>(configFile, "platform-infra-pipelines-as-code"), { label: configLabel }).value;
|
|
return parsePacConfigDocument(root, selection);
|
|
}
|
|
|
|
export function parsePacConfigDocument(
|
|
root: Record<string, unknown>,
|
|
selection: { readonly consumerId?: string | null; readonly selectDefault?: boolean } = {},
|
|
): PacConfig {
|
|
const release = y.objectField(root, "release", "");
|
|
const deliveryProvenance = y.objectField(root, "deliveryProvenance", "");
|
|
const provenanceController = y.objectField(deliveryProvenance, "controller", "deliveryProvenance");
|
|
const provenanceAdmission = y.objectField(deliveryProvenance, "admission", "deliveryProvenance");
|
|
const capabilities = y.objectField(root, "capabilities", "");
|
|
const sentinelInternalPublish = y.objectField(capabilities, "sentinelInternalPublish", "capabilities");
|
|
const closeout = y.objectField(root, "closeout", "");
|
|
const gitOpsMirrorFlush = y.objectField(closeout, "gitOpsMirrorFlush", "closeout");
|
|
const gitea = y.objectField(root, "gitea", "");
|
|
const display = y.objectField(root, "display", "");
|
|
const deliveryTiming = y.objectField(root, "deliveryTiming", "");
|
|
const observability = y.objectField(root, "observability", "");
|
|
const observabilitySampling = y.objectField(observability, "sampling", "observability");
|
|
const exporterFailure = y.objectField(observability, "exporterFailure", "observability");
|
|
const admin = y.objectField(gitea, "admin", "gitea");
|
|
const webhook = y.objectField(gitea, "webhook", "gitea");
|
|
const defaults = y.objectField(root, "defaults", "");
|
|
const repositoryEntries = pacConfigObjectEntries(root.repositories, root.repository, "repositories", "repository");
|
|
const consumerEntries = pacConfigObjectEntries(root.consumers, root.consumer, "consumers", "consumer");
|
|
const defaultRepositoryRef = rawPacObjectId(repositoryEntries[0]?.value, repositoryEntries[0]?.path ?? "repository", "repository");
|
|
const defaultConsumerId = typeof defaults.consumerId === "string" && defaults.consumerId.length > 0
|
|
? defaults.consumerId
|
|
: rawPacObjectId(consumerEntries[0]?.value, consumerEntries[0]?.path ?? "consumer", "consumer");
|
|
const selectedConsumerIds = selectedPacConsumerIds(defaultConsumerId, selection);
|
|
assertSelectedRawObjectsUnique(consumerEntries, selectedConsumerIds, "consumer");
|
|
const selectedRepositoryRefs = selectedPacRepositoryRefs(consumerEntries, selectedConsumerIds, defaultRepositoryRef);
|
|
assertSelectedRawObjectsUnique(repositoryEntries, selectedRepositoryRefs, "repository");
|
|
const parsedRepositories = parsePacRepositories(repositoryEntries, selectedRepositoryRefs);
|
|
const parsedConsumers = parsePacConsumers(consumerEntries, selectedConsumerIds, defaultRepositoryRef);
|
|
const parsed: PacConfig = {
|
|
version: y.integerField(root, "version", ""),
|
|
kind: "platform-infra-pipelines-as-code",
|
|
metadata: {
|
|
id: y.stringField(y.objectField(root, "metadata", ""), "id", "metadata"),
|
|
owner: y.stringField(y.objectField(root, "metadata", ""), "owner", "metadata"),
|
|
spec: y.stringField(y.objectField(root, "metadata", ""), "spec", "metadata"),
|
|
relatedIssues: y.numberArrayField(y.objectField(root, "metadata", ""), "relatedIssues", "metadata"),
|
|
},
|
|
defaults: {
|
|
targetId: y.stringField(defaults, "targetId", "defaults"),
|
|
consumerId: defaultConsumerId,
|
|
},
|
|
display: {
|
|
timeZone: y.stringField(display, "timeZone", "display"),
|
|
},
|
|
deliveryTiming: {
|
|
endToEndBudgetSeconds: positiveInteger(deliveryTiming, "endToEndBudgetSeconds", "deliveryTiming"),
|
|
configPath: `${configLabel}#deliveryTiming.endToEndBudgetSeconds`,
|
|
},
|
|
observability: {
|
|
configRef: y.stringField(observability, "configRef", "observability"),
|
|
tracesEndpoint: urlField(observability, "tracesEndpoint", "observability"),
|
|
serviceName: y.stringField(observability, "serviceName", "observability"),
|
|
propagation: y.stringArrayField(observability, "propagation", "observability"),
|
|
samplingMode: y.enumField(observabilitySampling, "mode", "observability.sampling", ["parentbased_traceidratio"] as const),
|
|
samplingRatio: numberInRange(observabilitySampling.ratio, 0, 1, "observability.sampling.ratio"),
|
|
exporterFailure: {
|
|
warning: literalBoolean(exporterFailure, "warning", true, "observability.exporterFailure"),
|
|
blocking: literalBoolean(exporterFailure, "blocking", false, "observability.exporterFailure"),
|
|
timeoutMs: integerInRange(exporterFailure.timeoutMs, 50, 2_000, "observability.exporterFailure.timeoutMs"),
|
|
},
|
|
},
|
|
release: {
|
|
version: y.stringField(release, "version", "release"),
|
|
manifestUrl: urlField(release, "manifestUrl", "release"),
|
|
namespace: y.kubernetesNameField(release, "namespace", "release"),
|
|
controllerServiceName: y.kubernetesNameField(release, "controllerServiceName", "release"),
|
|
controllerServicePort: y.portField(release, "controllerServicePort", "release"),
|
|
waitTimeoutSeconds: positiveInteger(release, "waitTimeoutSeconds", "release"),
|
|
},
|
|
deliveryProvenance: {
|
|
version: y.stringField(deliveryProvenance, "version", "deliveryProvenance"),
|
|
markerAnnotation: y.stringField(deliveryProvenance, "markerAnnotation", "deliveryProvenance"),
|
|
controllerIdentity: `system:serviceaccount:${y.kubernetesNameField(provenanceController, "namespace", "deliveryProvenance.controller")}:${y.kubernetesNameField(provenanceController, "serviceAccountName", "deliveryProvenance.controller")}`,
|
|
policyName: y.kubernetesNameField(provenanceAdmission, "policyName", "deliveryProvenance.admission"),
|
|
bindingName: y.kubernetesNameField(provenanceAdmission, "bindingName", "deliveryProvenance.admission"),
|
|
},
|
|
capabilities: {
|
|
sentinelInternalPublish: {
|
|
enabled: y.booleanField(sentinelInternalPublish, "enabled", "capabilities.sentinelInternalPublish"),
|
|
admissionProvenance: y.enumField(sentinelInternalPublish, "admissionProvenance", "capabilities.sentinelInternalPublish", ["unavailable"] as const),
|
|
trackingIssue: urlField(sentinelInternalPublish, "trackingIssue", "capabilities.sentinelInternalPublish"),
|
|
},
|
|
},
|
|
closeout: {
|
|
gitOpsMirrorFlush: {
|
|
enabled: y.booleanField(gitOpsMirrorFlush, "enabled", "closeout.gitOpsMirrorFlush"),
|
|
waitTimeoutSeconds: positiveInteger(gitOpsMirrorFlush, "waitTimeoutSeconds", "closeout.gitOpsMirrorFlush"),
|
|
maxAttempts: positiveInteger(gitOpsMirrorFlush, "maxAttempts", "closeout.gitOpsMirrorFlush"),
|
|
},
|
|
},
|
|
targets: y.arrayOfRecords(root.targets, "targets").map(parseTarget),
|
|
gitea: {
|
|
configRef: y.stringField(gitea, "configRef", "gitea"),
|
|
internalBaseUrl: urlField(gitea, "internalBaseUrl", "gitea"),
|
|
admin: {
|
|
sourceRoot: y.absolutePathField(admin, "sourceRoot", "gitea.admin"),
|
|
sourceRef: y.sourceRefField(admin, "sourceRef", "gitea.admin"),
|
|
format: y.enumField(admin, "format", "gitea.admin", ["line-pair"] as const),
|
|
usernameLine: positiveInteger(admin, "usernameLine", "gitea.admin"),
|
|
passwordLine: positiveInteger(admin, "passwordLine", "gitea.admin"),
|
|
apiUsername: y.stringField(admin, "apiUsername", "gitea.admin"),
|
|
},
|
|
webhook: {
|
|
secretRoot: y.absolutePathField(webhook, "secretRoot", "gitea.webhook"),
|
|
secretSourceRef: y.sourceRefField(webhook, "secretSourceRef", "gitea.webhook"),
|
|
events: y.stringArrayField(webhook, "events", "gitea.webhook"),
|
|
branch: y.stringField(webhook, "branch", "gitea.webhook"),
|
|
},
|
|
},
|
|
repositories: parsedRepositories.values,
|
|
consumers: parsedConsumers.values,
|
|
validationWarnings: [...parsedRepositories.warnings, ...parsedConsumers.warnings],
|
|
};
|
|
parsed.validationWarnings = validateConfig(parsed, selectedConsumerIds, parsed.validationWarnings);
|
|
return parsed;
|
|
}
|
|
|
|
function selectedPacConsumerIds(
|
|
defaultConsumerId: string,
|
|
selection: { readonly consumerId?: string | null; readonly selectDefault?: boolean },
|
|
): ReadonlySet<string> {
|
|
if (typeof selection.consumerId === "string" && selection.consumerId.length > 0) {
|
|
return new Set([selection.consumerId.toLowerCase()]);
|
|
}
|
|
return selection.selectDefault === true ? new Set([defaultConsumerId.toLowerCase()]) : new Set();
|
|
}
|
|
|
|
interface PacConfigObjectEntry {
|
|
readonly value: unknown;
|
|
readonly path: string;
|
|
}
|
|
|
|
function pacConfigObjectEntries(plural: unknown, singular: unknown, pluralLabel: string, singularLabel: string): PacConfigObjectEntry[] {
|
|
if (plural === undefined) {
|
|
if (singular === undefined) throw new Error(`${configLabel}.${singularLabel} must be an object`);
|
|
return [{ value: singular, path: singularLabel }];
|
|
}
|
|
if (!Array.isArray(plural)) throw new Error(`${configLabel}.${pluralLabel} must be an array`);
|
|
return plural.map((value, index) => ({ value, path: `${pluralLabel}[${index}]` }));
|
|
}
|
|
|
|
function rawPacObjectId(value: unknown, path: string, kind: "consumer" | "repository"): string {
|
|
if (!isRecord(value)) return path;
|
|
if (typeof value.id === "string" && value.id.length > 0) return value.id;
|
|
if (kind === "repository" && typeof value.name === "string" && value.name.length > 0) return value.name;
|
|
if (kind === "consumer" && typeof value.node === "string" && value.node.length > 0 && typeof value.lane === "string" && value.lane.length > 0) {
|
|
return `${value.node}-${value.lane}`;
|
|
}
|
|
return path;
|
|
}
|
|
|
|
function rawPacObjectRefs(entry: PacConfigObjectEntry, kind: "consumer" | "repository"): string[] {
|
|
if (!isRecord(entry.value)) return [entry.path.toLowerCase()];
|
|
const refs = [rawPacObjectId(entry.value, entry.path, kind)];
|
|
if (kind === "repository" && typeof entry.value.name === "string" && entry.value.name.length > 0) refs.push(entry.value.name);
|
|
return [...new Set(refs.map((value) => value.toLowerCase()))];
|
|
}
|
|
|
|
function assertSelectedRawObjectsUnique(
|
|
entries: readonly PacConfigObjectEntry[],
|
|
selectedRefs: ReadonlySet<string>,
|
|
kind: "consumer" | "repository",
|
|
): void {
|
|
for (const selectedRef of selectedRefs) {
|
|
const matches = entries.filter((entry) => rawPacObjectRefs(entry, kind).includes(selectedRef));
|
|
if (matches.length !== 1) {
|
|
throw new Error(`selected Pipelines-as-Code ${kind} ${selectedRef} must match exactly one YAML object; matched ${matches.length}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function selectedPacRepositoryRefs(
|
|
consumerEntries: readonly PacConfigObjectEntry[],
|
|
selectedConsumerIds: ReadonlySet<string>,
|
|
defaultRepositoryRef: string,
|
|
): ReadonlySet<string> {
|
|
const refs = new Set<string>();
|
|
for (const entry of consumerEntries) {
|
|
if (!rawPacObjectRefs(entry, "consumer").some((value) => selectedConsumerIds.has(value))) continue;
|
|
const repositoryRef = isRecord(entry.value) && typeof entry.value.repositoryRef === "string" && entry.value.repositoryRef.length > 0
|
|
? entry.value.repositoryRef
|
|
: defaultRepositoryRef;
|
|
refs.add(repositoryRef.toLowerCase());
|
|
}
|
|
return refs;
|
|
}
|
|
|
|
function parsePacRepositories(
|
|
entries: readonly PacConfigObjectEntry[],
|
|
selectedRepositoryRefs: ReadonlySet<string>,
|
|
): { readonly values: PacRepository[]; readonly warnings: PacConfigValidationWarning[] } {
|
|
const values: PacRepository[] = [];
|
|
const warnings: PacConfigValidationWarning[] = [];
|
|
for (const entry of entries) {
|
|
const id = rawPacObjectId(entry.value, entry.path, "repository");
|
|
try {
|
|
if (!isRecord(entry.value)) throw new Error(`${configLabel}.${entry.path} must be an object`);
|
|
values.push(parseRepository(entry.value, entry.path));
|
|
} catch (error) {
|
|
if (rawPacObjectRefs(entry, "repository").some((value) => selectedRepositoryRefs.has(value))) throw error;
|
|
warnings.push(pacConfigWarning("pac-unselected-repository-parse-invalid", "repository", id, entry.path, error));
|
|
}
|
|
}
|
|
return { values, warnings };
|
|
}
|
|
|
|
function parsePacConsumers(
|
|
entries: readonly PacConfigObjectEntry[],
|
|
selectedConsumerIds: ReadonlySet<string>,
|
|
defaultRepositoryRef: string,
|
|
): { readonly values: PacConsumer[]; readonly warnings: PacConfigValidationWarning[] } {
|
|
const values: PacConsumer[] = [];
|
|
const warnings: PacConfigValidationWarning[] = [];
|
|
for (const entry of entries) {
|
|
const id = rawPacObjectId(entry.value, entry.path, "consumer");
|
|
try {
|
|
if (!isRecord(entry.value)) throw new Error(`${configLabel}.${entry.path} must be an object`);
|
|
values.push(parseConsumer(entry.value, entry.path, defaultRepositoryRef));
|
|
} catch (error) {
|
|
if (selectedConsumerIds.has(id.toLowerCase())) throw error;
|
|
warnings.push(pacConfigWarning("pac-unselected-consumer-parse-invalid", "consumer", id, entry.path, error));
|
|
}
|
|
}
|
|
return { values, warnings };
|
|
}
|
|
|
|
function pacConfigWarning(
|
|
code: PacConfigValidationWarning["code"],
|
|
kind: PacConfigValidationWarning["object"]["kind"],
|
|
id: string,
|
|
path: string,
|
|
error: unknown,
|
|
): PacConfigValidationWarning {
|
|
return {
|
|
code,
|
|
object: { kind, id },
|
|
consumer: kind === "consumer" ? id : null,
|
|
configPath: `${configLabel}#${path}`,
|
|
blocking: false,
|
|
evidence: pacValueEvidence(error instanceof Error ? error.message : String(error)),
|
|
};
|
|
}
|
|
|
|
function parseRepository(repository: Record<string, unknown>, path: string): PacRepository {
|
|
const name = y.kubernetesNameField(repository, "name", path);
|
|
return {
|
|
id: typeof repository.id === "string" && repository.id.length > 0 ? repository.id : name,
|
|
name,
|
|
namespace: y.kubernetesNameField(repository, "namespace", path),
|
|
providerType: y.enumField(repository, "providerType", path, ["gitea"] as const),
|
|
url: urlField(repository, "url", path),
|
|
cloneUrl: urlField(repository, "cloneUrl", path),
|
|
owner: y.stringField(repository, "owner", path),
|
|
repo: y.stringField(repository, "repo", path),
|
|
secretName: y.kubernetesNameField(repository, "secretName", path),
|
|
tokenKey: y.stringField(repository, "tokenKey", path),
|
|
webhookSecretKey: y.stringField(repository, "webhookSecretKey", path),
|
|
concurrencyLimit: positiveInteger(repository, "concurrencyLimit", path),
|
|
params: stringRecord(y.objectField(repository, "params", path), `${path}.params`),
|
|
};
|
|
}
|
|
|
|
function parseConsumer(consumer: Record<string, unknown>, path: string, defaultRepositoryRef: string): PacConsumer {
|
|
const id = typeof consumer.id === "string" && consumer.id.length > 0 ? consumer.id : `${y.stringField(consumer, "node", path)}-${y.stringField(consumer, "lane", path)}`;
|
|
const argoBootstrap = consumer.argoBootstrap === undefined ? null : y.objectField(consumer, "argoBootstrap", path);
|
|
const deliveryProvenance = consumer.deliveryProvenance === undefined ? null : parseConsumerDeliveryProvenance(y.objectField(consumer, "deliveryProvenance", path), `${path}.deliveryProvenance`);
|
|
const runnerServiceAccount = consumer.runnerServiceAccount === undefined ? null : y.objectField(consumer, "runnerServiceAccount", path);
|
|
return {
|
|
id,
|
|
node: y.stringField(consumer, "node", path),
|
|
lane: y.stringField(consumer, "lane", path),
|
|
namespace: y.kubernetesNameField(consumer, "namespace", path),
|
|
pipeline: y.kubernetesNameField(consumer, "pipeline", path),
|
|
pipelineRunPrefix: y.stringField(consumer, "pipelineRunPrefix", path),
|
|
argoNamespace: y.kubernetesNameField(consumer, "argoNamespace", path),
|
|
argoApplication: y.kubernetesNameField(consumer, "argoApplication", path),
|
|
argoBootstrap: argoBootstrap === null ? null : {
|
|
project: y.kubernetesNameField(argoBootstrap, "project", `${path}.argoBootstrap`),
|
|
repoUrl: urlField(argoBootstrap, "repoUrl", `${path}.argoBootstrap`),
|
|
targetRevision: y.stringField(argoBootstrap, "targetRevision", `${path}.argoBootstrap`),
|
|
path: y.stringField(argoBootstrap, "path", `${path}.argoBootstrap`),
|
|
destinationNamespace: y.kubernetesNameField(argoBootstrap, "destinationNamespace", `${path}.argoBootstrap`),
|
|
automated: y.booleanField(argoBootstrap, "automated", `${path}.argoBootstrap`),
|
|
repositoryCredential: argoBootstrap.repositoryCredential === undefined ? null : (() => {
|
|
const credential = y.objectField(argoBootstrap, "repositoryCredential", `${path}.argoBootstrap`);
|
|
return {
|
|
secretName: y.kubernetesNameField(credential, "secretName", `${path}.argoBootstrap.repositoryCredential`),
|
|
username: y.stringField(credential, "username", `${path}.argoBootstrap.repositoryCredential`),
|
|
};
|
|
})(),
|
|
},
|
|
deliveryProvenance,
|
|
repositoryRef: typeof consumer.repositoryRef === "string" && consumer.repositoryRef.length > 0 ? consumer.repositoryRef : defaultRepositoryRef,
|
|
params: consumer.params === undefined ? {} : stringRecord(y.objectField(consumer, "params", path), `${path}.params`),
|
|
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`),
|
|
runnerServiceAccount: runnerServiceAccount === null ? null : {
|
|
name: y.kubernetesNameField(runnerServiceAccount, "name", `${path}.runnerServiceAccount`),
|
|
automountServiceAccountToken: parseFalse(runnerServiceAccount, "automountServiceAccountToken", `${path}.runnerServiceAccount`),
|
|
roleBindingName: y.kubernetesNameField(runnerServiceAccount, "roleBindingName", `${path}.runnerServiceAccount`),
|
|
},
|
|
};
|
|
}
|
|
|
|
function parseFalse(record: Record<string, unknown>, key: string, path: string): false {
|
|
if (y.booleanField(record, key, path) !== false) throw new Error(`${path}.${key} must be false`);
|
|
return false;
|
|
}
|
|
|
|
function parseConsumerDeliveryProvenance(value: Record<string, unknown>, path: string): PacConsumer["deliveryProvenance"] {
|
|
const required = y.booleanField(value, "required", path);
|
|
if (!required) return null;
|
|
const gitOps = y.objectField(value, "gitOps", path);
|
|
return {
|
|
required: true,
|
|
markerValue: y.stringField(value, "markerValue", path),
|
|
executionServiceAccountName: y.kubernetesNameField(value, "executionServiceAccountName", path),
|
|
gitOps: {
|
|
repoUrl: urlField(gitOps, "repoUrl", `${path}.gitOps`),
|
|
targetRevision: y.stringField(gitOps, "targetRevision", `${path}.gitOps`),
|
|
},
|
|
};
|
|
}
|
|
|
|
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", "sub2rank-platform-service", "selfmedia-runtime", "pikaoa-test-runtime"] 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`);
|
|
if (renderer === "hwlab-runtime-lane" && mode !== "remote-pipeline-annotation") throw new Error(`${path}.renderer hwlab-runtime-lane requires remote-pipeline-annotation`);
|
|
if (renderer === "sub2rank-platform-service" && mode !== "embedded-pipeline-spec") throw new Error(`${path}.renderer sub2rank-platform-service requires embedded-pipeline-spec`);
|
|
if (renderer === "selfmedia-runtime" && mode !== "embedded-pipeline-spec") throw new Error(`${path}.renderer selfmedia-runtime requires embedded-pipeline-spec`);
|
|
if (renderer === "pikaoa-test-runtime" && mode !== "embedded-pipeline-spec") throw new Error(`${path}.renderer pikaoa-test-runtime requires embedded-pipeline-spec`);
|
|
return {
|
|
mode,
|
|
renderer,
|
|
configRef: y.stringField(value, "configRef", path),
|
|
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`),
|
|
},
|
|
};
|
|
}
|
|
|
|
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>,
|
|
desiredWrapper: Record<string, unknown> | null,
|
|
provenance: { readonly configRef: string; readonly effectiveConfigSha256: string; readonly renderer: string; readonly mode: string },
|
|
sourceCommit: string | null,
|
|
): Promise<PacSourceArtifactRuntimeObservation> {
|
|
const script = renderPacSourceArtifactRuntimeObserverShell({
|
|
namespace: consumer.namespace,
|
|
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) {
|
|
return unavailableSourceArtifactRuntime(pacRuntimeTransportFailureReason(captured), sourceCommit);
|
|
}
|
|
const parsed = parseJsonOutput(captured.stdout);
|
|
if (parsed === null) return unavailableSourceArtifactRuntime("runtime-observer-invalid-json", sourceCommit);
|
|
return {
|
|
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, 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
|
|
`;
|
|
}
|
|
|
|
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<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;
|
|
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,
|
|
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: pacRuntimeSafePath((drift as Record<string, unknown>).path),
|
|
expected: pacRuntimeSafeEvidence((drift as Record<string, unknown>).expected),
|
|
actual: pacRuntimeSafeEvidence((drift as Record<string, unknown>).actual),
|
|
}
|
|
: 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 };
|
|
}
|
|
|
|
function parseTarget(record: Record<string, unknown>, index: number): PacTarget {
|
|
const path = `targets[${index}]`;
|
|
return {
|
|
id: y.stringField(record, "id", path),
|
|
route: y.stringField(record, "route", path),
|
|
namespace: y.kubernetesNameField(record, "namespace", path),
|
|
role: y.stringField(record, "role", path),
|
|
enabled: y.booleanField(record, "enabled", path),
|
|
};
|
|
}
|
|
|
|
export function validatePacConfig(config: PacConfig, selectedConsumerIds: ReadonlySet<string>): PacConfigValidationWarning[] {
|
|
return validateConfig(config, selectedConsumerIds, []);
|
|
}
|
|
|
|
function validateConfig(
|
|
config: PacConfig,
|
|
selectedConsumerIds: ReadonlySet<string>,
|
|
initialWarnings: readonly PacConfigValidationWarning[],
|
|
): PacConfigValidationWarning[] {
|
|
if (config.version !== 1) throw new Error(`${configLabel}.version must be 1`);
|
|
resolveTarget(config, config.defaults.targetId);
|
|
if (config.release.waitTimeoutSeconds > 55) throw new Error(`${configLabel}.release.waitTimeoutSeconds must fit the 60s trans budget`);
|
|
if (config.closeout.gitOpsMirrorFlush.waitTimeoutSeconds > 55) throw new Error(`${configLabel}.closeout.gitOpsMirrorFlush.waitTimeoutSeconds must fit the 60s trans budget`);
|
|
if (selectedConsumerIds.has(config.defaults.consumerId.toLowerCase())) resolveConsumer(config, config.defaults.consumerId);
|
|
const warnings: PacConfigValidationWarning[] = [...initialWarnings];
|
|
const selectedRepositoryRefs = new Set(config.consumers
|
|
.filter((consumer) => selectedConsumerIds.has(consumer.id.toLowerCase()))
|
|
.map((consumer) => consumer.repositoryRef.toLowerCase()));
|
|
const ids = new Set<string>();
|
|
for (const repository of config.repositories) {
|
|
try {
|
|
if (ids.has(repository.id)) throw new Error(`${configLabel}.repositories id must be unique: ${repository.id}`);
|
|
if (repository.providerType !== "gitea") throw new Error(`${configLabel}.repositories.${repository.id}.providerType must be gitea`);
|
|
if (isInternalRepositoryMatchUrl(repository.url)) throw new Error(`${configLabel}.repositories.${repository.id}.url must be the public Gitea URL used in webhook payloads; keep internal service URLs in cloneUrl/params.git_read_url`);
|
|
if (new URL(repository.cloneUrl).origin !== new URL(config.gitea.internalBaseUrl).origin) throw new Error(`${configLabel}.repositories.${repository.id}.cloneUrl must use the configured internal Gitea base URL`);
|
|
ids.add(repository.id);
|
|
} catch (error) {
|
|
if (selectedRepositoryRefs.has(repository.id.toLowerCase()) || selectedRepositoryRefs.has(repository.name.toLowerCase())) throw error;
|
|
warnings.push(pacConfigWarning("pac-unselected-repository-invalid", "repository", repository.id, `repositories.${repository.id}`, error));
|
|
}
|
|
}
|
|
const consumerIds = new Set<string>();
|
|
const markerValues = new Set<string>();
|
|
const reservedServiceAccounts = new Set<string>();
|
|
for (const consumer of config.consumers) {
|
|
try {
|
|
if (consumerIds.has(consumer.id)) throw new Error(`${configLabel}.consumers id must be unique: ${consumer.id}`);
|
|
validateConsumerConfig(config, consumer, markerValues, reservedServiceAccounts);
|
|
consumerIds.add(consumer.id);
|
|
if (consumer.deliveryProvenance !== null) {
|
|
markerValues.add(consumer.deliveryProvenance.markerValue);
|
|
reservedServiceAccounts.add(`${consumer.node.toLowerCase()}\u0000${consumer.deliveryProvenance.executionServiceAccountName}`);
|
|
}
|
|
} catch (error) {
|
|
if (selectedConsumerIds.has(consumer.id.toLowerCase())) throw error;
|
|
warnings.push(pacConfigWarning("pac-unselected-consumer-invalid", "consumer", consumer.id, `consumers.${consumer.id}`, error));
|
|
}
|
|
}
|
|
if (!config.gitea.webhook.events.includes("push")) throw new Error(`${configLabel}.gitea.webhook.events must include push`);
|
|
validateTimeZone(config.display.timeZone, `${configLabel}.display.timeZone`);
|
|
return warnings;
|
|
}
|
|
|
|
function validateConsumerConfig(
|
|
config: PacConfig,
|
|
consumer: PacConsumer,
|
|
markerValues: Set<string>,
|
|
reservedServiceAccounts: Set<string>,
|
|
): void {
|
|
const repository = resolveRepository(config, consumer.repositoryRef);
|
|
const params = consumerParams(repository, consumer);
|
|
if (consumer.id.startsWith("sentinel-")) {
|
|
if (repository.params.otel_traces_endpoint !== config.observability.tracesEndpoint) {
|
|
throw new Error(`${configLabel}.repositories.${repository.id}.params.otel_traces_endpoint must match observability.tracesEndpoint`);
|
|
}
|
|
if (repository.params.otel_sampling_ratio !== String(config.observability.samplingRatio)) {
|
|
throw new Error(`${configLabel}.repositories.${repository.id}.params.otel_sampling_ratio must match observability.sampling.ratio`);
|
|
}
|
|
if (repository.params.otel_exporter_timeout_ms !== String(config.observability.exporterFailure.timeoutMs)) {
|
|
throw new Error(`${configLabel}.repositories.${repository.id}.params.otel_exporter_timeout_ms must match observability.exporterFailure.timeoutMs`);
|
|
}
|
|
}
|
|
if (repository.namespace !== consumer.namespace) throw new Error(`${configLabel}.consumers.${consumer.id}.namespace must match repository namespace`);
|
|
if (consumer.deliveryProvenance !== null) {
|
|
if (consumer.sourceArtifact === null) throw new Error(`${configLabel}.consumers.${consumer.id}.deliveryProvenance requires sourceArtifact ownership`);
|
|
if (markerValues.has(consumer.deliveryProvenance.markerValue)) throw new Error(`${configLabel}.consumers deliveryProvenance.markerValue must be unique: ${consumer.deliveryProvenance.markerValue}`);
|
|
if (consumer.deliveryProvenance.executionServiceAccountName === "default") {
|
|
throw new Error(`${configLabel}.consumers.${consumer.id}.deliveryProvenance.executionServiceAccountName must not reserve the shared default ServiceAccount`);
|
|
}
|
|
const serviceAccountKey = `${consumer.node.toLowerCase()}\u0000${consumer.deliveryProvenance.executionServiceAccountName}`;
|
|
if (reservedServiceAccounts.has(serviceAccountKey)) {
|
|
throw new Error(`${configLabel}.consumers deliveryProvenance.executionServiceAccountName must be unique per target: ${consumer.deliveryProvenance.executionServiceAccountName}`);
|
|
}
|
|
if (params.service_account !== consumer.deliveryProvenance.executionServiceAccountName) {
|
|
throw new Error(`${configLabel}.consumers.${consumer.id}.deliveryProvenance.executionServiceAccountName must match effective params.service_account`);
|
|
}
|
|
if (consumer.argoBootstrap !== null
|
|
&& (consumer.argoBootstrap.repoUrl !== consumer.deliveryProvenance.gitOps.repoUrl
|
|
|| consumer.argoBootstrap.targetRevision !== consumer.deliveryProvenance.gitOps.targetRevision)) {
|
|
throw new Error(`${configLabel}.consumers.${consumer.id}.deliveryProvenance.gitOps must match argoBootstrap source identity`);
|
|
}
|
|
if (consumer.runnerServiceAccount !== null && consumer.runnerServiceAccount.name !== consumer.deliveryProvenance.executionServiceAccountName) {
|
|
throw new Error(`${configLabel}.consumers.${consumer.id}.runnerServiceAccount.name must match deliveryProvenance.executionServiceAccountName`);
|
|
}
|
|
}
|
|
if (consumer.sourceArtifact?.renderer === "sub2rank-platform-service" && consumer.runnerServiceAccount === null) {
|
|
throw new Error(`${configLabel}.consumers.${consumer.id}.runnerServiceAccount is required for sub2rank-platform-service`);
|
|
}
|
|
if (consumer.sourceArtifact?.renderer === "selfmedia-runtime" || consumer.sourceArtifact?.renderer === "pikaoa-test-runtime") {
|
|
if (consumer.runnerServiceAccount === null) throw new Error(`${configLabel}.consumers.${consumer.id}.runnerServiceAccount is required for private GitOps runtime renderers`);
|
|
if (consumer.argoBootstrap?.repositoryCredential === null || consumer.argoBootstrap?.repositoryCredential === undefined) {
|
|
throw new Error(`${configLabel}.consumers.${consumer.id}.argoBootstrap.repositoryCredential is required for the private repository`);
|
|
}
|
|
if (consumer.closeoutGitOpsMirrorFlush) throw new Error(`${configLabel}.consumers.${consumer.id}.closeoutGitOpsMirrorFlush must be false for Gitea writeback GitOps`);
|
|
if (params.gitops_secret_name !== repository.secretName) throw new Error(`${configLabel}.consumers.${consumer.id}.effective params.gitops_secret_name must match repository secretName`);
|
|
if (params.gitops_username !== consumer.argoBootstrap.repositoryCredential.username) throw new Error(`${configLabel}.consumers.${consumer.id}.Argo and Tekton Gitea usernames must match`);
|
|
if (params.gitops_read_url !== consumer.argoBootstrap.repoUrl) throw new Error(`${configLabel}.consumers.${consumer.id}.Argo repoUrl must match effective params.gitops_read_url`);
|
|
}
|
|
}
|
|
|
|
function numberInRange(value: unknown, minimum: number, maximum: number, path: string): number {
|
|
if (typeof value !== "number" || !Number.isFinite(value) || value < minimum || value > maximum) {
|
|
throw new Error(`${configLabel}.${path} must be a number from ${minimum} to ${maximum}`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function integerInRange(value: unknown, minimum: number, maximum: number, path: string): number {
|
|
const parsed = numberInRange(value, minimum, maximum, path);
|
|
if (!Number.isInteger(parsed)) throw new Error(`${configLabel}.${path} must be an integer`);
|
|
return parsed;
|
|
}
|
|
|
|
function literalBoolean<T extends boolean>(value: Record<string, unknown>, key: string, expected: T, path: string): T {
|
|
if (value[key] !== expected) throw new Error(`${configLabel}.${path}.${key} must be ${String(expected)}`);
|
|
return expected;
|
|
}
|
|
|
|
function resolveTarget(config: PacConfig, targetId: string | null): PacTarget {
|
|
const id = targetId ?? config.defaults.targetId;
|
|
const target = config.targets.find((item) => item.id.toLowerCase() === id.toLowerCase());
|
|
if (target === undefined) throw new Error(`unknown Pipelines-as-Code target ${id}; known targets: ${config.targets.map((item) => item.id).join(", ")}`);
|
|
if (!target.enabled) throw new Error(`Pipelines-as-Code target ${target.id} is disabled in ${configLabel}`);
|
|
return target;
|
|
}
|
|
|
|
function resolveConsumer(config: PacConfig, consumerId: string | null): PacConsumer {
|
|
const id = consumerId ?? config.defaults.consumerId;
|
|
const matches = config.consumers.filter((item) => item.id.toLowerCase() === id.toLowerCase());
|
|
if (matches.length !== 1) throw new Error(`Pipelines-as-Code consumer ${id} must resolve exactly once; matched ${matches.length}; known consumers: ${config.consumers.map((item) => item.id).join(", ")}`);
|
|
return matches[0] as PacConsumer;
|
|
}
|
|
|
|
function resolveRepository(config: PacConfig, repositoryRef: string): PacRepository {
|
|
const matches = config.repositories.filter((item) => item.id === repositoryRef || item.name === repositoryRef);
|
|
if (matches.length !== 1) throw new Error(`Pipelines-as-Code repository ${repositoryRef} must resolve exactly once; matched ${matches.length}; known repositories: ${config.repositories.map((item) => item.id).join(", ")}`);
|
|
return matches[0] as PacRepository;
|
|
}
|
|
|
|
function consumerParams(repository: PacRepository, consumer: PacConsumer): Record<string, string> {
|
|
return { ...repository.params, ...consumer.params };
|
|
}
|
|
|
|
export function validPacConsumers(config: PacConfig): PacConsumer[] {
|
|
const invalidConsumers = new Set(config.validationWarnings
|
|
.filter((warning) => warning.object.kind === "consumer")
|
|
.map((warning) => warning.object.id.toLowerCase()));
|
|
const invalidRepositories = new Set(config.validationWarnings
|
|
.filter((warning) => warning.object.kind === "repository")
|
|
.map((warning) => warning.object.id.toLowerCase()));
|
|
for (const repository of config.repositories) {
|
|
if (invalidRepositories.has(repository.id.toLowerCase()) || invalidRepositories.has(repository.name.toLowerCase())) {
|
|
invalidRepositories.add(repository.id.toLowerCase());
|
|
invalidRepositories.add(repository.name.toLowerCase());
|
|
}
|
|
}
|
|
return config.consumers.filter((consumer) => (
|
|
!invalidConsumers.has(consumer.id.toLowerCase())
|
|
&& !invalidRepositories.has(consumer.repositoryRef.toLowerCase())
|
|
));
|
|
}
|
|
|
|
function plan(options: CommonOptions): Record<string, unknown> {
|
|
const pac = readPacConfig({ consumerId: options.consumerId, selectDefault: true });
|
|
const target = resolveTarget(pac, options.targetId);
|
|
const consumer = resolveConsumer(pac, options.consumerId);
|
|
const repository = resolveRepository(pac, consumer.repositoryRef);
|
|
const secrets = secretSummaries(pac);
|
|
return {
|
|
ok: true,
|
|
action: "platform-infra-pipelines-as-code-plan",
|
|
mutation: false,
|
|
target: targetSummary(target),
|
|
config: configSummary(pac, consumer, repository),
|
|
release: pac.release,
|
|
repository: repositorySummary(repository),
|
|
consumer: consumerObservationSummary(consumer),
|
|
secrets,
|
|
policy: policyChecks(repository),
|
|
warnings: pac.validationWarnings,
|
|
next: nextCommands(target.id, consumer.id, pac.defaults.consumerId),
|
|
};
|
|
}
|
|
|
|
async function apply(config: UniDeskConfig, options: ApplyOptions): Promise<Record<string, unknown>> {
|
|
const pac = readPacConfig({ consumerId: options.consumerId, selectDefault: true });
|
|
const target = resolveTarget(pac, options.targetId);
|
|
const consumer = resolveConsumer(pac, options.consumerId);
|
|
const repository = resolveRepository(pac, consumer.repositoryRef);
|
|
const secrets = ensureSecrets(pac, !options.dryRun, options.dryRun);
|
|
const releaseManifest = options.dryRun ? "" : await fetchReleaseManifest(pac);
|
|
const result = await capture(config, target.route, ["sh"], remoteScript("apply", pac, target, repository, consumer, options, secrets, releaseManifest));
|
|
const parsed = parseJsonOutput(result.stdout);
|
|
return {
|
|
ok: result.exitCode === 0 && parsed?.ok === true,
|
|
action: "platform-infra-pipelines-as-code-apply",
|
|
mutation: options.dryRun ? false : parsed?.mutation !== false,
|
|
mode: options.dryRun ? "dry-run" : "confirmed",
|
|
target: targetSummary(target),
|
|
config: compactConfigSummary(pac, consumer, repository),
|
|
release: { version: pac.release.version, manifestUrl: pac.release.manifestUrl },
|
|
repository: repositorySummary(repository),
|
|
consumer: consumerObservationSummary(consumer),
|
|
secrets: secretSummaries(pac),
|
|
remote: parsed ?? compactCapture(result, { full: true }),
|
|
warnings: pac.validationWarnings,
|
|
next: nextCommands(target.id, consumer.id, pac.defaults.consumerId),
|
|
};
|
|
}
|
|
|
|
async function bootstrap(config: UniDeskConfig, options: ApplyOptions): Promise<Record<string, unknown>> {
|
|
const pac = readPacConfig({ consumerId: options.consumerId, selectDefault: true });
|
|
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}`);
|
|
}
|
|
const repository = resolveRepository(pac, consumer.repositoryRef);
|
|
let mirror: ReturnType<typeof resolvePacBootstrapMirrorRepository>;
|
|
try {
|
|
mirror = resolvePacBootstrapMirrorRepository(readGiteaConfig(), target.id, {
|
|
...repository,
|
|
sourceBranch: consumerParams(repository, consumer).source_branch,
|
|
});
|
|
} catch (error) {
|
|
return { ...pacBootstrapYamlMatchFailure(target.id, consumer.id, error), warnings: pac.validationWarnings };
|
|
}
|
|
const githubPreflight = record(await runGiteaMirrorBootstrapPreflight(config, target.id, mirror.key));
|
|
if (githubPreflight.ok !== true) {
|
|
return bootstrapResult(target, consumer, repository, mirror, options, githubPreflight, { ok: false, mutation: false, mode: "skipped", githubPreflight, valuesPrinted: false }, { ok: false, mutation: false, mode: "skipped", valuesPrinted: false }, pac.validationWarnings);
|
|
}
|
|
const giteaArgs = ["mirror", "bootstrap", "--target", target.id, "--repo", mirror.key, ...(options.confirm ? ["--confirm"] : []), "--full"];
|
|
const giteaBootstrap = record(await runPlatformInfraGiteaCommand(config, giteaArgs));
|
|
if (options.confirm && giteaBootstrap.ok !== true) {
|
|
return bootstrapResult(target, consumer, repository, mirror, options, githubPreflight, giteaBootstrap, {
|
|
ok: false,
|
|
mutation: false,
|
|
mode: "skipped",
|
|
error: "gitea-bootstrap-failed",
|
|
valuesPrinted: false,
|
|
}, pac.validationWarnings);
|
|
}
|
|
const pacApply = await apply(config, options);
|
|
return bootstrapResult(target, consumer, repository, mirror, options, githubPreflight, giteaBootstrap, pacApply, pac.validationWarnings);
|
|
}
|
|
|
|
function bootstrapResult(
|
|
target: PacTarget,
|
|
consumer: PacConsumer,
|
|
repository: PacRepository,
|
|
mirror: ReturnType<typeof resolvePacBootstrapMirrorRepository>,
|
|
options: ApplyOptions,
|
|
githubPreflight: Record<string, unknown>,
|
|
giteaBootstrap: Record<string, unknown>,
|
|
pacApply: Record<string, unknown>,
|
|
warnings: readonly PacConfigValidationWarning[],
|
|
): Record<string, unknown> {
|
|
const remote = record(pacApply.remote);
|
|
const repositoryMissing = remote.error === "gitea-repository-missing";
|
|
const pacReady = pacApply.ok === true || (!options.confirm && repositoryMissing);
|
|
const ok = giteaBootstrap.ok === true && pacReady;
|
|
const confirm = `bun scripts/cli.ts platform-infra pipelines-as-code bootstrap --target ${target.id} --consumer ${consumer.id} --confirm`;
|
|
return {
|
|
ok,
|
|
action: "platform-infra-pipelines-as-code-bootstrap",
|
|
mutation: giteaBootstrap.mutation === true || pacApply.mutation === true,
|
|
mode: options.confirm ? "confirmed" : "dry-run",
|
|
target: targetSummary(target),
|
|
consumer: consumerObservationSummary(consumer),
|
|
repository: repositorySummary(repository),
|
|
mirrorRepository: {
|
|
key: mirror.key,
|
|
upstreamRepository: mirror.upstream.repository,
|
|
upstreamBranch: mirror.upstream.branch,
|
|
owner: mirror.gitea.owner,
|
|
repo: mirror.gitea.name,
|
|
valuesPrinted: false,
|
|
},
|
|
githubPreflight,
|
|
steps: [
|
|
{ id: "gitea-repository", ok: giteaBootstrap.ok === true, mutation: giteaBootstrap.mutation === true, mode: giteaBootstrap.mode, valuesPrinted: false },
|
|
{ id: "pac-tekton-gitops", ok: pacReady, mutation: pacApply.mutation === true, mode: pacApply.mode, repositoryMissing, valuesPrinted: false },
|
|
],
|
|
giteaBootstrap,
|
|
pipelinesAsCodeApply: pacApply,
|
|
warnings,
|
|
next: options.confirm
|
|
? {
|
|
deliveryTrigger: `Merge the prepared GitHub PR for ${mirror.upstream.repository}@${mirror.upstream.branch}; the automatic chain owns mirror, PaC, Tekton, GitOps, Argo, and runtime convergence.`,
|
|
investigate: `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${target.id} --consumer ${consumer.id}`,
|
|
}
|
|
: {
|
|
confirm,
|
|
boundary: "Run confirm only before the first source PR merge; do not use bootstrap as post-merge recovery.",
|
|
},
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
async function status(config: UniDeskConfig, options: CommonOptions): Promise<Record<string, unknown>> {
|
|
const pac = readPacConfig({ consumerId: options.consumerId, selectDefault: true });
|
|
const target = resolveTarget(pac, options.targetId);
|
|
const consumer = resolveConsumer(pac, options.consumerId);
|
|
const repository = resolveRepository(pac, consumer.repositoryRef);
|
|
const secrets = ensureSecrets(pac, false);
|
|
const result = await capture(config, target.route, ["sh"], remoteScript("status", pac, target, repository, consumer, { ...options, confirm: false, dryRun: true, wait: false }, secrets, ""));
|
|
const parsed = parseJsonOutput(result.stdout);
|
|
const summary = parsed === null ? null : statusSummary(parsed);
|
|
const deliveryAuthority = resolveCicdDeliveryAuthority({ consumerId: consumer.id, node: consumer.node, lane: consumer.lane });
|
|
const deliveryBudget = observePacStatusDeliveryBudget({ policy: pac.deliveryTiming, targetId: target.id, consumerId: consumer.id, summary });
|
|
const deliveryWarning = record(deliveryBudget.warning);
|
|
return {
|
|
ok: result.exitCode === 0 && summary?.ready === true && deliveryAuthority.kind === "pac-pr-merge",
|
|
action: "platform-infra-pipelines-as-code-status",
|
|
mutation: false,
|
|
target: targetSummary(target),
|
|
config: compactConfigSummary(pac, consumer, repository),
|
|
consumer: consumerObservationSummary(consumer),
|
|
deliveryAuthority,
|
|
coverage: consumerCoverage(pac, target.id),
|
|
summary,
|
|
deliveryBudget,
|
|
remote: parsed === null ? compactCapture(result, { full: true }) : options.raw ? parsed : undefined,
|
|
warnings: [...pac.validationWarnings, ...(Object.keys(deliveryWarning).length === 0 ? [] : [deliveryWarning])],
|
|
next: nextCommands(target.id, consumer.id, pac.defaults.consumerId),
|
|
};
|
|
}
|
|
|
|
export async function getPlatformInfraPipelinesAsCodeNodeStatus(config: UniDeskConfig, options: PipelinesAsCodeNodeStatusOptions): Promise<Record<string, unknown>> {
|
|
const pac = readPacConfig();
|
|
const target = resolveTarget(pac, options.targetId ?? options.nodeId);
|
|
const nodeId = options.nodeId ?? target.id;
|
|
const consumers = validPacConsumers(pac).filter((consumer) => consumer.node.toLowerCase() === nodeId.toLowerCase());
|
|
const startedAt = Date.now();
|
|
const statuses = await Promise.all(consumers.map(async (consumer) => {
|
|
const repository = resolveRepository(pac, consumer.repositoryRef);
|
|
try {
|
|
const current = await status(config, { targetId: target.id, consumerId: consumer.id, full: false, raw: false, json: false });
|
|
return nodeStatusRow(target.id, consumer, repository, current);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return {
|
|
consumer: consumer.id,
|
|
node: consumer.node,
|
|
lane: consumer.lane,
|
|
repository: `${repository.owner}/${repository.repo}`,
|
|
namespace: consumer.namespace,
|
|
pipeline: consumer.pipeline,
|
|
argoApplication: consumer.argoApplication,
|
|
ready: false,
|
|
ciReady: false,
|
|
argoReady: false,
|
|
runtimeReady: false,
|
|
diagnosticsReady: false,
|
|
reason: "status-read-failed",
|
|
hint: message,
|
|
error: message,
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
}));
|
|
const ready = statuses.every((row) => row.ready === true);
|
|
const configured = statuses.length > 0;
|
|
return {
|
|
ok: configured && ready,
|
|
action: "cicd-node-status",
|
|
mutation: false,
|
|
node: nodeId,
|
|
target: targetSummary(target),
|
|
config: {
|
|
path: configLabel,
|
|
source: "Gitea Repository CR + Tekton PipelineRun/TaskRun + Argo + runtime live objects.",
|
|
displayTimeZone: pac.display.timeZone,
|
|
valuesPrinted: false,
|
|
},
|
|
consumers: statuses,
|
|
warnings: pac.validationWarnings,
|
|
summary: {
|
|
ready: configured && ready,
|
|
status: configured ? (ready ? "ready" : "blocked") : "warning",
|
|
code: configured ? (ready ? "cicd-node-ready" : "cicd-node-blocked") : "cicd-node-no-consumers",
|
|
hint: configured ? null : `no Pipelines-as-Code consumers are configured for node ${nodeId}`,
|
|
total: statuses.length,
|
|
readyCount: statuses.filter((row) => row.ready === true).length,
|
|
blockedCount: statuses.filter((row) => row.ready !== true).length,
|
|
elapsedMs: Date.now() - startedAt,
|
|
valuesPrinted: false,
|
|
},
|
|
next: pacNodeReadOnlyNext(target.id),
|
|
details: options.full || options.raw ? statuses : undefined,
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
async function closeout(config: UniDeskConfig, options: CloseoutOptions): Promise<Record<string, unknown>> {
|
|
const pac = readPacConfig({ consumerId: options.consumerId, selectDefault: true });
|
|
const target = resolveTarget(pac, options.targetId);
|
|
const consumer = resolveConsumer(pac, options.consumerId);
|
|
const startedAt = Date.now();
|
|
let current: Record<string, unknown> = {};
|
|
let phase: "ci" | "runtime" = "ci";
|
|
const waitResult = await runReadOnlyLongPoll({
|
|
timeoutMs: options.wait ? options.timeoutMs : 1,
|
|
observe: async () => {
|
|
current = await observeCloseoutStatus(config, options, target, consumer, phase, 1, startedAt);
|
|
phase = closeoutCiReady(current, options.sourceCommit) ? "runtime" : "ci";
|
|
return current;
|
|
},
|
|
completed: (value) => !options.wait || closeoutWaitComplete(value, options.sourceCommit),
|
|
waitForChange: async (remainingMs) => await waitForCloseoutChange(config, target, consumer, phase, remainingMs),
|
|
cursor: closeoutCursor,
|
|
});
|
|
current = waitResult.value;
|
|
let summary = record(current.summary);
|
|
let latest = record(summary.latestPipelineRun);
|
|
let diagnostics = record(summary.diagnostics);
|
|
let observedSourceCommit = observedCloseoutSourceCommit(latest, diagnostics);
|
|
let sourceMatched = options.sourceCommit === null || observedSourceCommit === options.sourceCommit;
|
|
let gitOpsMirrorFlush: Record<string, unknown> = {
|
|
ok: true,
|
|
enabled: false,
|
|
configured: consumer.closeoutGitOpsMirrorFlush,
|
|
required: false,
|
|
applicable: false,
|
|
mode: "automatic-chain-owned-observation",
|
|
executed: false,
|
|
reason: "PaC migrated delivery owns GitOps publication and mirror persistence; compatibility closeout is read-only.",
|
|
configSource: configLabel,
|
|
valuesPrinted: false,
|
|
};
|
|
summary = record(current.summary);
|
|
latest = record(summary.latestPipelineRun);
|
|
diagnostics = record(summary.diagnostics);
|
|
observedSourceCommit = observedCloseoutSourceCommit(latest, diagnostics);
|
|
sourceMatched = options.sourceCommit === null || observedSourceCommit === options.sourceCommit;
|
|
const baseReady = current.ok === true && sourceMatched;
|
|
gitOpsMirrorFlush = {
|
|
...record(gitOpsMirrorFlush),
|
|
afterSummary: summary,
|
|
valuesPrinted: false,
|
|
};
|
|
const ready = baseReady;
|
|
printCloseoutProgress(target, consumer, "closeout", ready ? "completed" : "blocked", startedAt, {
|
|
ready,
|
|
sourceMatched,
|
|
diagnosticCode: diagnostics.code,
|
|
timedOut: waitResult.timedOut,
|
|
observations: waitResult.observations,
|
|
});
|
|
return {
|
|
ok: ready,
|
|
action: "platform-infra-pipelines-as-code-closeout",
|
|
mutation: false,
|
|
target: targetSummary(target),
|
|
consumer,
|
|
sourceCommit: options.sourceCommit,
|
|
observedSourceCommit,
|
|
sourceMatched,
|
|
ready,
|
|
timedOut: waitResult.timedOut,
|
|
elapsedMs: waitResult.elapsedMs,
|
|
cursor: waitResult.cursor,
|
|
wait: {
|
|
requested: options.wait,
|
|
completed: waitResult.completed,
|
|
timedOut: waitResult.timedOut,
|
|
cancelled: waitResult.cancelled,
|
|
observations: waitResult.observations,
|
|
elapsedMs: waitResult.elapsedMs,
|
|
budgetSeconds: options.timeoutMs / 1000,
|
|
budgetScope: "whole-observation",
|
|
source: options.wait ? "cli --timeout or default 120s" : "single observation",
|
|
identity: { target: target.id, consumer: consumer.id, pipelineRun: latest.name ?? null, sourceCommit: options.sourceCommit, valuesPrinted: false },
|
|
cursor: waitResult.cursor,
|
|
valuesPrinted: false,
|
|
},
|
|
summary,
|
|
status: current,
|
|
gitOpsMirrorFlush,
|
|
blocker: ready ? null : closeoutBlocker(current, options.sourceCommit, observedSourceCommit, sourceMatched, gitOpsMirrorFlush),
|
|
next: {
|
|
...nextCommands(target.id, consumer.id, pac.defaults.consumerId),
|
|
wait: `bun scripts/cli.ts platform-infra pipelines-as-code closeout --target ${target.id} --consumer ${consumer.id}${options.sourceCommit === null ? "" : ` --source-commit ${options.sourceCommit}`} --wait --timeout 120s`,
|
|
},
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
async function waitForCloseoutChange(config: UniDeskConfig, target: PacTarget, consumer: PacConsumer, phase: "ci" | "runtime", remainingMs: number): Promise<void> {
|
|
const timeoutSeconds = Math.max(1, Math.ceil(remainingMs / 1000));
|
|
const args = phase === "ci"
|
|
? ["kubectl", "-n", consumer.namespace, "get", "pipelinerun", "-l", `tekton.dev/pipeline=${consumer.pipeline}`, "--watch-only", `--request-timeout=${timeoutSeconds}s`, "-o", "name"]
|
|
: ["kubectl", "-n", consumer.argoNamespace, "get", "application", consumer.argoApplication, "--watch-only", `--request-timeout=${timeoutSeconds}s`, "-o", "name"];
|
|
const script = [
|
|
"set -euo pipefail",
|
|
kubernetesWatchOneEventShellFunction(),
|
|
`unidesk_watch_one_event ${args.map(shQuote).join(" ")}`,
|
|
].join("\n");
|
|
const result = await capture(config, target.route, ["bash"], script, { runtimeTimeoutMs: readOnlyLongPollTransportTimeoutMs(remainingMs) });
|
|
if (result.exitCode !== 0) throw new Error(`CI/CD ${phase} watch disconnected: exit=${result.exitCode} stderr=${stringValue(compactCapture(result, { full: true }).stderrTail)}`);
|
|
}
|
|
|
|
function closeoutWaitComplete(value: Record<string, unknown>, sourceCommit: string | null): boolean {
|
|
if (closeoutReady(value, sourceCommit)) return true;
|
|
const latest = record(record(value.summary).latestPipelineRun);
|
|
const terminal = stringValue(latest.status, stringValue(latest.reason, "")).toLowerCase();
|
|
return ["failed", "failure", "false", "cancelled", "canceled"].some((status) => terminal.includes(status));
|
|
}
|
|
|
|
function closeoutCursor(value: Record<string, unknown>): string {
|
|
const summary = record(value.summary);
|
|
const latest = record(summary.latestPipelineRun);
|
|
const argo = record(summary.argo);
|
|
const runtime = record(summary.runtime);
|
|
return [latest.name, latest.status, latest.reason, argo.revision, argo.sync, argo.health, runtime.digest, runtime.readyReplicas]
|
|
.map((item) => stringValue(item, "-"))
|
|
.join(":");
|
|
}
|
|
|
|
async function observeCloseoutStatus(config: UniDeskConfig, options: CloseoutOptions, target: PacTarget, consumer: PacConsumer, phase: "ci" | "runtime", attempt: number, startedAt: number): Promise<Record<string, unknown>> {
|
|
printCloseoutProgress(target, consumer, `${phase}-status`, "started", startedAt, { attempt });
|
|
const current = await status(config, options);
|
|
const summary = record(current.summary);
|
|
const latest = record(summary.latestPipelineRun);
|
|
const diagnostics = record(summary.diagnostics);
|
|
printCloseoutProgress(target, consumer, `${phase}-status`, "observed", startedAt, {
|
|
attempt,
|
|
ready: current.ok === true,
|
|
pipelineRun: latest.name,
|
|
sourceCommit: observedCloseoutSourceCommit(latest, diagnostics),
|
|
diagnosticCode: diagnostics.code,
|
|
});
|
|
return current;
|
|
}
|
|
|
|
function printCloseoutProgress(target: PacTarget, consumer: PacConsumer, stage: string, status: string, startedAt: number, detail: Record<string, unknown>): void {
|
|
process.stderr.write(`${JSON.stringify({
|
|
event: "platform-infra.pipelines-as-code.closeout.progress",
|
|
stage,
|
|
status,
|
|
target: target.id,
|
|
consumer: consumer.id,
|
|
elapsedMs: Date.now() - startedAt,
|
|
...detail,
|
|
valuesPrinted: false,
|
|
})}\n`);
|
|
}
|
|
|
|
async function history(config: UniDeskConfig, options: HistoryOptions): Promise<Record<string, unknown>> {
|
|
const pac = readPacConfig({ consumerId: options.consumerId });
|
|
const target = resolveTarget(pac, options.targetId);
|
|
const selection = resolvePacHistoryConsumerSelection(validPacConsumers(pac), target.id, options.consumerId, options.detailId);
|
|
const selectedConsumers = selection.selectedConsumerIds.map((id) => resolveConsumer(pac, id));
|
|
const firstConsumer = selectedConsumers[0];
|
|
if (firstConsumer === undefined) throw new Error("no Pipelines-as-Code consumers are configured");
|
|
const firstRepository = resolveRepository(pac, firstConsumer.repositoryRef);
|
|
const secrets = ensureSecrets(pac, false);
|
|
const result = await capture(config, target.route, ["sh"], remoteScript("history", pac, target, firstRepository, firstConsumer, { ...options, confirm: false, dryRun: true, wait: false }, secrets, "", selectedConsumers));
|
|
const parsed = parseJsonOutput(result.stdout);
|
|
const remote = parsed ?? compactCapture(result, { full: true });
|
|
const historyErrors = arrayRecords(record(remote).historyErrors);
|
|
const rows = arrayRecords(record(remote).rows);
|
|
const deliveryBudget = observePacHistoryDeliveryBudget({
|
|
policy: pac.deliveryTiming,
|
|
targetId: target.id,
|
|
consumerIds: selectedConsumers.map((consumer) => consumer.id),
|
|
defaultConsumerId: pac.defaults.consumerId,
|
|
rows,
|
|
});
|
|
const deliveryWarnings = arrayRecords(deliveryBudget.observations)
|
|
.map((observation) => record(observation.warning))
|
|
.filter((warning) => Object.keys(warning).length > 0);
|
|
return {
|
|
ok: result.exitCode === 0 && parsed?.ok !== false && historyErrors.length === 0,
|
|
action: "platform-infra-pipelines-as-code-history",
|
|
mutation: false,
|
|
target: targetSummary(target),
|
|
config: {
|
|
path: configLabel,
|
|
source: "Gitea Repository CR + Tekton PipelineRun/TaskRun live objects; no UniDesk-owned history store is written.",
|
|
limitPerConsumer: options.limit,
|
|
displayTimeZone: pac.display.timeZone,
|
|
valuesPrinted: false,
|
|
},
|
|
consumers: selectedConsumers.map((consumer) => consumer.id),
|
|
detailId: options.detailId,
|
|
rows,
|
|
deliveryBudget,
|
|
historyErrors,
|
|
warnings: [...pac.validationWarnings, ...deliveryWarnings],
|
|
controlPlane: parsed === null ? undefined : {
|
|
crdPresent: parsed.crdPresent === true,
|
|
controllerReady: parsed.controllerReady,
|
|
repositoryCondition: parsed.repositoryCondition,
|
|
repository: record(parsed.repository),
|
|
consumerRows: arrayRecords(parsed.consumerRows),
|
|
admissionProvenance: record(parsed.admissionProvenance),
|
|
webhookCount: arrayRecords(parsed.webhooks).length,
|
|
valuesPrinted: false,
|
|
},
|
|
remote: parsed === null || options.raw ? remote : undefined,
|
|
next: selection.nextConsumerId === null
|
|
? pacNodeReadOnlyNext(target.id)
|
|
: pacReadOnlyNext(target.id, selection.nextConsumerId),
|
|
};
|
|
}
|
|
|
|
export function resolvePacHistoryConsumerSelection(
|
|
consumers: readonly PacHistoryConsumerIdentity[],
|
|
targetId: string,
|
|
requestedConsumerId: string | null,
|
|
detailId: string | null,
|
|
): PacHistoryConsumerSelection {
|
|
const targetConsumers = consumers.filter((consumer) => consumer.node.toLowerCase() === targetId.toLowerCase());
|
|
if (targetConsumers.length === 0) throw new Error(`no Pipelines-as-Code consumers are configured for target ${targetId}`);
|
|
const requestedConsumer = requestedConsumerId === null
|
|
? null
|
|
: consumers.find((consumer) => consumer.id.toLowerCase() === requestedConsumerId.toLowerCase()) ?? null;
|
|
if (requestedConsumerId !== null && requestedConsumer === null) {
|
|
throw new Error(`unknown Pipelines-as-Code consumer ${requestedConsumerId}`);
|
|
}
|
|
if (requestedConsumer !== null && requestedConsumer.node.toLowerCase() !== targetId.toLowerCase()) {
|
|
throw new Error(`Pipelines-as-Code consumer ${requestedConsumer.id} belongs to ${requestedConsumer.node}, not target ${targetId}`);
|
|
}
|
|
if (detailId !== null) {
|
|
const matches = targetConsumers.filter((consumer) => detailId.startsWith(consumer.pipelineRunPrefix));
|
|
if (matches.length !== 1) {
|
|
throw new Error(`PipelineRun ${detailId} must match exactly one consumer on target ${targetId}; matched ${matches.length}`);
|
|
}
|
|
const matched = matches[0] as PacHistoryConsumerIdentity;
|
|
if (requestedConsumer !== null && requestedConsumer.id.toLowerCase() !== matched.id.toLowerCase()) {
|
|
throw new Error(`PipelineRun ${detailId} belongs to ${matched.id}, not requested consumer ${requestedConsumer.id}`);
|
|
}
|
|
return { selectedConsumerIds: [matched.id], nextConsumerId: matched.id };
|
|
}
|
|
if (requestedConsumer !== null) {
|
|
return { selectedConsumerIds: [requestedConsumer.id], nextConsumerId: requestedConsumer.id };
|
|
}
|
|
return {
|
|
selectedConsumerIds: targetConsumers.map((consumer) => consumer.id),
|
|
nextConsumerId: targetConsumers.length === 1 ? (targetConsumers[0] as PacHistoryConsumerIdentity).id : null,
|
|
};
|
|
}
|
|
|
|
async function debugStep(config: UniDeskConfig, options: HistoryOptions): Promise<Record<string, unknown>> {
|
|
const pac = readPacConfig({ consumerId: options.consumerId });
|
|
const target = resolveTarget(pac, options.targetId);
|
|
const selection = resolvePacHistoryConsumerSelection(validPacConsumers(pac), target.id, options.consumerId, options.detailId);
|
|
if (selection.selectedConsumerIds.length !== 1) {
|
|
throw new Error("debug-step requires --consumer or --id resolving exactly one Pipelines-as-Code consumer");
|
|
}
|
|
const consumer = resolveConsumer(pac, selection.selectedConsumerIds[0] ?? null);
|
|
const repository = resolveRepository(pac, consumer.repositoryRef);
|
|
const result = await capture(config, target.route, ["sh"], remoteScript("debug-step", pac, target, repository, consumer, { ...options, confirm: false, dryRun: true, wait: false }, emptySecretMaterial(), ""));
|
|
const parsed = parseJsonOutput(result.stdout);
|
|
const fixtureChecks = parsed === null ? [] : arrayRecords(parsed.checks);
|
|
const fixtureDisclosure = pacDebugFixtureDisclosure(fixtureChecks, options.detailId);
|
|
return {
|
|
ok: result.exitCode === 0 && parsed?.ok === true,
|
|
action: "platform-infra-pipelines-as-code-debug-step",
|
|
mutation: false,
|
|
target: targetSummary(target),
|
|
consumer: consumer.id,
|
|
detailId: options.detailId,
|
|
evaluator: "scripts/native/cicd/pac-status-evaluator.cjs",
|
|
fixtureGate: { ...fixtureDisclosure.fixtureGate, ok: parsed !== null && fixtureDisclosure.fixtureGate.ok === true },
|
|
checks: fixtureDisclosure.checks,
|
|
realRun: parsed === null ? null : parsed.realRun ?? null,
|
|
remote: parsed === null || options.raw ? parsed ?? compactCapture(result, { full: true }) : undefined,
|
|
next: nextCommands(target.id, consumer.id, pac.defaults.consumerId),
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
export function pacDebugFixtureDisclosure(fixtureChecks: readonly Record<string, unknown>[], detailId: string | null): {
|
|
readonly fixtureGate: Record<string, unknown>;
|
|
readonly checks: readonly Record<string, unknown>[];
|
|
} {
|
|
const failedChecks = fixtureChecks.filter((item) => item.ok !== true);
|
|
return {
|
|
fixtureGate: {
|
|
ok: failedChecks.length === 0,
|
|
total: fixtureChecks.length,
|
|
passed: fixtureChecks.length - failedChecks.length,
|
|
failed: failedChecks.length,
|
|
disclosure: detailId === null ? "all-checks" : "failed-checks-only-for-id-specific-debug",
|
|
valuesPrinted: false,
|
|
},
|
|
checks: detailId === null ? fixtureChecks : failedChecks,
|
|
};
|
|
}
|
|
|
|
async function fetchReleaseManifest(pac: PacConfig): Promise<string> {
|
|
const response = await fetch(pac.release.manifestUrl);
|
|
if (!response.ok) throw new Error(`failed to fetch ${pac.release.manifestUrl}: HTTP ${response.status}`);
|
|
const text = await response.text();
|
|
if (!text.includes("repositories.pipelinesascode.tekton.dev")) throw new Error(`${pac.release.manifestUrl} did not contain the Repository CRD`);
|
|
return text;
|
|
}
|
|
|
|
function remoteScript(action: "apply" | "status" | "history" | "debug-step", pac: PacConfig, target: PacTarget, repository: PacRepository, consumer: PacConsumer, options: ApplyOptions | HistoryOptions, secrets: SecretMaterial, releaseManifest: string, historyConsumers: PacConsumer[] = [consumer]): string {
|
|
const webhookUrl = `${pac.gitea.internalBaseUrl.replace(/\/+$/u, "").replace(/gitea-http\.[^.]+\.svc\.cluster\.local:3000/u, `${pac.release.controllerServiceName}.${pac.release.namespace}.svc.cluster.local:${pac.release.controllerServicePort}`)}`;
|
|
const admissionIdentity = pacAdmissionDesiredIdentity(target.id);
|
|
const rbacIdentity = pacConsumerRbacDesiredIdentity(target.id);
|
|
const consumerConfig = historyConsumerConfig(pac, consumer);
|
|
const env: Record<string, string> = {
|
|
UNIDESK_PAC_ACTION: action,
|
|
UNIDESK_PAC_EVALUATOR_B64: Buffer.from(readFileSync(evaluatorFile, "utf8"), "utf8").toString("base64"),
|
|
UNIDESK_PAC_FIELD_MANAGER: fieldManager,
|
|
UNIDESK_PAC_TARGET_ID: target.id,
|
|
UNIDESK_PAC_TARGET_NAMESPACE: consumer.namespace,
|
|
UNIDESK_PAC_CONSUMER_ID: consumer.id,
|
|
UNIDESK_PAC_RELEASE_NAMESPACE: pac.release.namespace,
|
|
UNIDESK_PAC_RELEASE_MANIFEST_B64: Buffer.from(releaseManifest, "utf8").toString("base64"),
|
|
UNIDESK_PAC_CONTROLLER_SERVICE_NAME: pac.release.controllerServiceName,
|
|
UNIDESK_PAC_WAIT_TIMEOUT_SECONDS: String(pac.release.waitTimeoutSeconds),
|
|
UNIDESK_PAC_DRY_RUN: "dryRun" in options && options.dryRun ? "1" : "0",
|
|
UNIDESK_PAC_GITEA_BASE_URL: pac.gitea.internalBaseUrl.replace(/\/+$/u, ""),
|
|
UNIDESK_PAC_GITEA_ADMIN_USERNAME: secrets.adminUsername,
|
|
UNIDESK_PAC_GITEA_ADMIN_PASSWORD: secrets.adminPassword,
|
|
UNIDESK_PAC_GITEA_API_USERNAME: pac.gitea.admin.apiUsername,
|
|
UNIDESK_PAC_GITEA_OWNER: repository.owner,
|
|
UNIDESK_PAC_GITEA_REPO: repository.repo,
|
|
UNIDESK_PAC_WEBHOOK_URL: webhookUrl,
|
|
UNIDESK_PAC_WEBHOOK_SECRET: secrets.webhookSecret,
|
|
UNIDESK_PAC_REPOSITORY_NAME: repository.name,
|
|
UNIDESK_PAC_REPOSITORY_URL: repository.url,
|
|
UNIDESK_PAC_SECRET_NAME: repository.secretName,
|
|
UNIDESK_PAC_TOKEN_KEY: repository.tokenKey,
|
|
UNIDESK_PAC_WEBHOOK_SECRET_KEY: repository.webhookSecretKey,
|
|
UNIDESK_PAC_CONCURRENCY_LIMIT: String(repository.concurrencyLimit),
|
|
UNIDESK_PAC_REPOSITORY_PARAMS_JSON: JSON.stringify(repository.params),
|
|
UNIDESK_PAC_PARAMS_JSON: JSON.stringify(consumerParams(repository, consumer)),
|
|
UNIDESK_PAC_REGISTRY_APPLICABILITY: registryApplicability(consumer, repository),
|
|
UNIDESK_PAC_PIPELINE_NAME: consumer.pipeline,
|
|
UNIDESK_PAC_PIPELINE_RUN_PREFIX: consumer.pipelineRunPrefix,
|
|
UNIDESK_PAC_CONSUMER_CONFIG_B64: Buffer.from(JSON.stringify(consumerConfig), "utf8").toString("base64"),
|
|
UNIDESK_PAC_DELIVERY_PROVENANCE_REQUIRED: consumer.deliveryProvenance?.required === true ? "1" : "0",
|
|
UNIDESK_PAC_CONSUMER_RBAC_MANIFEST_B64: Buffer.from(renderPacConsumerRbacDesiredFragment(target.id), "utf8").toString("base64"),
|
|
UNIDESK_PAC_RUNNER_SERVICE_ACCOUNT_MANIFEST_B64: Buffer.from(runnerServiceAccountManifest(consumer), "utf8").toString("base64"),
|
|
UNIDESK_PAC_RUNNER_SERVICE_ACCOUNT_NAME: consumer.runnerServiceAccount?.name ?? "",
|
|
UNIDESK_PAC_RUNNER_ROLE_BINDING_NAME: consumer.runnerServiceAccount?.roleBindingName ?? "",
|
|
UNIDESK_PAC_CONSUMER_RBAC_CONFIG_SHA256: rbacIdentity.configSha256,
|
|
UNIDESK_PAC_ADMISSION_POLICY_NAME: admissionIdentity.policyName,
|
|
UNIDESK_PAC_ADMISSION_BINDING_NAME: admissionIdentity.bindingName,
|
|
UNIDESK_PAC_ADMISSION_VERSION: admissionIdentity.version,
|
|
UNIDESK_PAC_ADMISSION_CONTROLLER_IDENTITY: admissionIdentity.controllerIdentity,
|
|
UNIDESK_PAC_ADMISSION_CONFIG_SHA256: admissionIdentity.configSha256,
|
|
UNIDESK_PAC_GITOPS_EXPECTED_REPO_URL: consumer.deliveryProvenance?.gitOps.repoUrl ?? "",
|
|
UNIDESK_PAC_GITOPS_EXPECTED_TARGET_REVISION: consumer.deliveryProvenance?.gitOps.targetRevision ?? "",
|
|
UNIDESK_PAC_HISTORY_LIMIT: "limit" in options ? String(options.limit) : "5",
|
|
UNIDESK_PAC_HISTORY_ID: "detailId" in options && options.detailId !== null ? options.detailId : "",
|
|
UNIDESK_PAC_HISTORY_CONSUMERS_B64: Buffer.from(JSON.stringify(historyConsumers.map((item) => historyConsumerConfig(pac, item))), "utf8").toString("base64"),
|
|
UNIDESK_PAC_DISPLAY_TIME_ZONE: pac.display.timeZone,
|
|
UNIDESK_PAC_ARGO_NAMESPACE: consumer.argoNamespace,
|
|
UNIDESK_PAC_ARGO_APPLICATION: consumer.argoApplication,
|
|
UNIDESK_PAC_ARGO_BOOTSTRAP_MANIFEST_B64: Buffer.from(argoBootstrapManifest(consumer), "utf8").toString("base64"),
|
|
UNIDESK_PAC_ARGO_REPOSITORY_SECRET_NAME: consumer.argoBootstrap?.repositoryCredential?.secretName ?? "",
|
|
UNIDESK_PAC_ARGO_REPOSITORY_USERNAME: consumer.argoBootstrap?.repositoryCredential?.username ?? "",
|
|
UNIDESK_PAC_ARGO_REPOSITORY_URL: consumer.argoBootstrap?.repoUrl ?? "",
|
|
UNIDESK_PAC_PART_OF: pacPartOf(consumer.id),
|
|
UNIDESK_PAC_SPEC: kubernetesLabelValue(pac.metadata.relatedIssues.includes(1555) ? "GH-1552-GH-1555" : pac.metadata.spec),
|
|
};
|
|
const exports = Object.entries(env).map(([key, value]) => `export ${key}=${shQuote(value)}`).join("\n");
|
|
return `${exports}\n${readFileSync(remoteScriptFile, "utf8")}`;
|
|
}
|
|
|
|
function registryApplicability(consumer: PacConsumer, repository: PacRepository): "configured" | "not-configured" {
|
|
const params = consumerParams(repository, consumer);
|
|
const prefix = consumer.id === "unidesk-host" ? "unidesk_host_" : "";
|
|
const imageRepository = params[`${prefix}image_repository`] ?? params.image_repository;
|
|
if (typeof imageRepository !== "string" || imageRepository.length === 0) return "not-configured";
|
|
if (prefix.length > 0) return "configured";
|
|
return params.pipeline_name === consumer.pipeline ? "configured" : "not-configured";
|
|
}
|
|
|
|
function historyConsumerConfig(pac: PacConfig, consumer: PacConsumer): Record<string, unknown> {
|
|
const repository = resolveRepository(pac, consumer.repositoryRef);
|
|
const admissionIdentity = pacAdmissionDesiredIdentity(consumer.node);
|
|
const rbacIdentity = pacConsumerRbacDesiredIdentity(consumer.node);
|
|
return {
|
|
id: consumer.id,
|
|
node: consumer.node,
|
|
namespace: consumer.namespace,
|
|
pipeline: consumer.pipeline,
|
|
pipelineRunPrefix: consumer.pipelineRunPrefix,
|
|
repository: repository.name,
|
|
repo: `${repository.owner}/${repository.repo}`,
|
|
repoUrl: repository.url,
|
|
deliveryProvenance: consumer.deliveryProvenance === null ? null : {
|
|
required: true,
|
|
targetId: consumer.node,
|
|
version: admissionIdentity.version,
|
|
controllerIdentity: admissionIdentity.controllerIdentity,
|
|
policyName: admissionIdentity.policyName,
|
|
bindingName: admissionIdentity.bindingName,
|
|
configSha256: admissionIdentity.configSha256,
|
|
rbacConfigSha256: rbacIdentity.configSha256,
|
|
markerAnnotation: pac.deliveryProvenance.markerAnnotation,
|
|
markerValue: consumer.deliveryProvenance.markerValue,
|
|
executionServiceAccountName: consumer.deliveryProvenance.executionServiceAccountName,
|
|
gitOps: consumer.deliveryProvenance.gitOps,
|
|
},
|
|
};
|
|
}
|
|
|
|
function credentialPath(root: string, sourceRef: string): string {
|
|
return sourceRef.startsWith("/") ? sourceRef : join(root, sourceRef);
|
|
}
|
|
|
|
function pacPartOf(consumerId: string): string {
|
|
if (consumerId.startsWith("platform-infra-")) return "platform-infra";
|
|
if (consumerId === "unidesk-host") return "unidesk-host";
|
|
if (consumerId.startsWith("sentinel")) return "hwlab-web-probe-sentinel";
|
|
if (consumerId.startsWith("hwlab-")) return "hwlab";
|
|
if (consumerId.startsWith("selfmedia-")) return "selfmedia-factory";
|
|
return "agentrun";
|
|
}
|
|
|
|
function runnerServiceAccountManifest(consumer: PacConsumer): string {
|
|
const runner = consumer.runnerServiceAccount;
|
|
if (runner === null) return "";
|
|
const labels = {
|
|
"app.kubernetes.io/managed-by": "unidesk",
|
|
"app.kubernetes.io/part-of": pacPartOf(consumer.id),
|
|
"unidesk.ai/pac-runner": consumer.id,
|
|
};
|
|
const objects = [
|
|
{
|
|
apiVersion: "v1",
|
|
kind: "ServiceAccount",
|
|
metadata: { name: runner.name, namespace: consumer.namespace, labels },
|
|
automountServiceAccountToken: runner.automountServiceAccountToken,
|
|
},
|
|
{
|
|
apiVersion: "rbac.authorization.k8s.io/v1",
|
|
kind: "RoleBinding",
|
|
metadata: { name: runner.roleBindingName, namespace: consumer.namespace, labels },
|
|
subjects: [{ kind: "ServiceAccount", name: runner.name, namespace: consumer.namespace }],
|
|
roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: "unidesk-pac-consumer-runner" },
|
|
},
|
|
];
|
|
return `${objects.map((item) => Bun.YAML.stringify(item).trim()).join("\n---\n")}\n`;
|
|
}
|
|
|
|
function argoBootstrapManifest(consumer: PacConsumer): string {
|
|
const bootstrap = consumer.argoBootstrap;
|
|
if (bootstrap === null) return "";
|
|
const application = {
|
|
apiVersion: "argoproj.io/v1alpha1",
|
|
kind: "Application",
|
|
metadata: {
|
|
name: consumer.argoApplication,
|
|
namespace: consumer.argoNamespace,
|
|
labels: {
|
|
"app.kubernetes.io/managed-by": "unidesk",
|
|
"app.kubernetes.io/part-of": pacPartOf(consumer.id),
|
|
},
|
|
},
|
|
spec: {
|
|
project: bootstrap.project,
|
|
source: {
|
|
repoURL: bootstrap.repoUrl,
|
|
targetRevision: bootstrap.targetRevision,
|
|
path: bootstrap.path,
|
|
},
|
|
destination: {
|
|
server: "https://kubernetes.default.svc",
|
|
namespace: bootstrap.destinationNamespace,
|
|
},
|
|
syncPolicy: bootstrap.automated ? {
|
|
automated: { prune: true, selfHeal: true },
|
|
syncOptions: ["CreateNamespace=true"],
|
|
} : undefined,
|
|
},
|
|
};
|
|
return `${Bun.YAML.stringify(application).trim()}\n`;
|
|
}
|
|
|
|
function parseLinePair(path: string, usernameLine: number, passwordLine: number): { username: string; password: string } {
|
|
const lines = readFileSync(path, "utf8").split(/\r?\n/u);
|
|
const username = lines[usernameLine - 1]?.trim() ?? "";
|
|
const password = lines[passwordLine - 1]?.trim() ?? "";
|
|
if (username.length === 0 || password.length === 0) throw new Error(`${path} must contain username on line ${usernameLine} and password on line ${passwordLine}`);
|
|
return { username, password };
|
|
}
|
|
|
|
function emptySecretMaterial(): SecretMaterial {
|
|
return {
|
|
adminUsername: "",
|
|
adminPassword: "",
|
|
adminFingerprint: "",
|
|
webhookSecret: "",
|
|
webhookFingerprint: "",
|
|
webhookPath: "",
|
|
};
|
|
}
|
|
|
|
function ensureSecrets(pac: PacConfig, createWebhookSecret: boolean, allowMissingWebhookSecret = false): SecretMaterial {
|
|
const adminPath = credentialPath(pac.gitea.admin.sourceRoot, pac.gitea.admin.sourceRef);
|
|
if (!existsSync(adminPath)) throw new Error(`${pac.gitea.admin.sourceRef} is missing`);
|
|
const admin = parseLinePair(adminPath, pac.gitea.admin.usernameLine, pac.gitea.admin.passwordLine);
|
|
const webhookPath = credentialPath(pac.gitea.webhook.secretRoot, pac.gitea.webhook.secretSourceRef);
|
|
if (!existsSync(webhookPath)) {
|
|
if (allowMissingWebhookSecret) {
|
|
const placeholder = "dry-run-webhook-secret-placeholder";
|
|
return {
|
|
adminUsername: admin.username,
|
|
adminPassword: admin.password,
|
|
adminFingerprint: secretFingerprint(`${admin.username}\0${admin.password}`),
|
|
webhookSecret: placeholder,
|
|
webhookFingerprint: secretFingerprint(placeholder),
|
|
webhookPath,
|
|
};
|
|
}
|
|
if (!createWebhookSecret) throw new Error(`${pac.gitea.webhook.secretSourceRef} is missing; run apply --confirm to create it`);
|
|
mkdirSync(dirname(webhookPath), { recursive: true });
|
|
writeFileSync(webhookPath, `${randomBytes(32).toString("hex")}\n`, { encoding: "utf8", mode: 0o600 });
|
|
chmodSync(webhookPath, 0o600);
|
|
}
|
|
const webhookSecret = readFileSync(webhookPath, "utf8").trim();
|
|
if (webhookSecret.length < 32) throw new Error(`${pac.gitea.webhook.secretSourceRef} must contain at least 32 chars`);
|
|
return {
|
|
adminUsername: admin.username,
|
|
adminPassword: admin.password,
|
|
adminFingerprint: secretFingerprint(`${admin.username}\0${admin.password}`),
|
|
webhookSecret,
|
|
webhookFingerprint: secretFingerprint(webhookSecret),
|
|
webhookPath,
|
|
};
|
|
}
|
|
|
|
function secretSummaries(pac: PacConfig): Array<Record<string, unknown>> {
|
|
const adminPath = credentialPath(pac.gitea.admin.sourceRoot, pac.gitea.admin.sourceRef);
|
|
const webhookPath = credentialPath(pac.gitea.webhook.secretRoot, pac.gitea.webhook.secretSourceRef);
|
|
const admin = existsSync(adminPath) ? parseLinePair(adminPath, pac.gitea.admin.usernameLine, pac.gitea.admin.passwordLine) : null;
|
|
const webhookSecret = existsSync(webhookPath) ? readFileSync(webhookPath, "utf8").trim() : "";
|
|
return [
|
|
{
|
|
id: "gitea-admin",
|
|
sourceRef: pac.gitea.admin.sourceRef,
|
|
sourcePath: adminPath,
|
|
present: admin !== null,
|
|
fingerprint: admin === null ? null : secretFingerprint(`${admin.username}\0${admin.password}`),
|
|
valuesPrinted: false,
|
|
},
|
|
{
|
|
id: "pac-webhook",
|
|
sourceRef: pac.gitea.webhook.secretSourceRef,
|
|
sourcePath: webhookPath,
|
|
present: webhookSecret.length > 0,
|
|
fingerprint: webhookSecret.length > 0 ? secretFingerprint(webhookSecret) : null,
|
|
valuesPrinted: false,
|
|
},
|
|
];
|
|
}
|
|
|
|
function statusSummary(payload: Record<string, unknown>): Record<string, unknown> {
|
|
const pipelineRuns = arrayRecords(payload.pipelineRuns);
|
|
const latest = pipelineRuns[0] ?? {};
|
|
const taskRuns = arrayRecords(payload.taskRuns);
|
|
const argo = record(payload.argo);
|
|
const runtime = record(payload.runtime);
|
|
const diagnostics = record(payload.diagnostics);
|
|
const admissionProvenance = record(payload.admissionProvenance);
|
|
const consumerBootstrap = record(payload.consumerBootstrap);
|
|
const webhooks = arrayRecords(payload.webhooks);
|
|
const rawArtifact = record(payload.artifact);
|
|
const sourceObservation = record(rawArtifact.sourceObservation);
|
|
const provenance = record(diagnostics.provenance);
|
|
const noRuntimeChange = stringValue(sourceObservation.mode) === "no-runtime-change"
|
|
&& sourceObservation.valid === true
|
|
&& stringValue(diagnostics.code) === "pac-ready-no-runtime-change";
|
|
const artifact = {
|
|
...rawArtifact,
|
|
imageStatus: noRuntimeChange ? "retained" : rawArtifact.imageStatus ?? (runtime.image === undefined ? undefined : "runtime"),
|
|
digest: noRuntimeChange ? provenance.runtimeDigest ?? runtime.digest : rawArtifact.digest ?? runtime.digest,
|
|
gitopsCommit: noRuntimeChange ? provenance.gitopsRevision ?? argo.revision : rawArtifact.gitopsCommit ?? argo.revision,
|
|
provenanceRelation: noRuntimeChange ? "retained" : record(argo.revisionRelation).relation,
|
|
};
|
|
const pipelineGate = pipelineRunGate(latest);
|
|
return {
|
|
ready: payload.crdPresent === true && String(payload.controllerReady ?? "0/0") !== "0/0" && pipelineGate.ok && diagnostics.ok !== false && consumerBootstrap.ready !== false,
|
|
crdPresent: payload.crdPresent === true,
|
|
controllerReady: payload.controllerReady,
|
|
repositoryCondition: payload.repositoryCondition,
|
|
repository: record(payload.repository),
|
|
webhooks,
|
|
webhookCount: webhooks.length,
|
|
latestPipelineRun: latest,
|
|
taskRuns,
|
|
pipelineRunGate: pipelineGate,
|
|
artifact,
|
|
sourceObservation: Object.keys(sourceObservation).length === 0 ? null : sourceObservation,
|
|
provenance: Object.keys(provenance).length === 0 ? null : provenance,
|
|
argo,
|
|
runtime,
|
|
diagnostics,
|
|
admissionProvenance,
|
|
consumerBootstrap,
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
function nodeStatusRow(targetId: string, consumer: PacConsumer, repository: PacRepository, current: Record<string, unknown>): Record<string, unknown> {
|
|
const summary = record(current.summary);
|
|
const latest = record(summary.latestPipelineRun);
|
|
const artifact = record(summary.artifact);
|
|
const argo = record(summary.argo);
|
|
const runtime = record(summary.runtime);
|
|
const diagnostics = record(summary.diagnostics);
|
|
const pipelineGate = record(summary.pipelineRunGate);
|
|
const deliveryDisabled = stringValue(artifact.imageStatus) === "disabled";
|
|
const desired = numericValue(runtime.replicas);
|
|
const readyReplicas = numericValue(runtime.readyReplicas);
|
|
const runtimeReady = deliveryDisabled || (desired !== null && desired > 0 && readyReplicas === desired);
|
|
const argoReady = deliveryDisabled || (stringValue(argo.sync) === "Synced" && stringValue(argo.health) === "Healthy");
|
|
const ciReady = pipelineGate.ok === true;
|
|
const diagnosticsReady = diagnostics.ok !== false;
|
|
const ready = current.ok === true && ciReady && argoReady && runtimeReady && diagnosticsReady;
|
|
return {
|
|
consumer: consumer.id,
|
|
node: consumer.node,
|
|
lane: consumer.lane,
|
|
repository: `${repository.owner}/${repository.repo}`,
|
|
namespace: consumer.namespace,
|
|
pipeline: consumer.pipeline,
|
|
argoApplication: consumer.argoApplication,
|
|
ready,
|
|
ciReady,
|
|
argoReady,
|
|
runtimeReady,
|
|
diagnosticsReady,
|
|
latestPipelineRun: stringValue(latest.name),
|
|
pipelineStatus: statusText(latest),
|
|
durationSeconds: latest.durationSeconds ?? null,
|
|
sourceCommit: stringValue(latest.sourceCommit),
|
|
imageStatus: stringValue(artifact.imageStatus),
|
|
envReuse: envReuseText(record(artifact.envReuse)),
|
|
digest: stringValue(artifact.digest),
|
|
gitopsCommit: stringValue(artifact.gitopsCommit),
|
|
argo: {
|
|
sync: stringValue(argo.sync),
|
|
health: stringValue(argo.health),
|
|
revision: stringValue(argo.revision),
|
|
repoURL: stringValue(argo.repoURL),
|
|
targetRevision: stringValue(argo.targetRevision),
|
|
revisionRelation: record(argo.revisionRelation),
|
|
},
|
|
runtime: {
|
|
deployment: stringValue(runtime.deployment),
|
|
readyReplicas: runtime.readyReplicas ?? null,
|
|
replicas: runtime.replicas ?? null,
|
|
digest: stringValue(runtime.digest),
|
|
image: stringValue(runtime.image),
|
|
},
|
|
diagnostics: {
|
|
ok: diagnostics.ok !== false,
|
|
code: stringValue(diagnostics.code),
|
|
phase: stringValue(diagnostics.phase),
|
|
hint: compactLine(stringValue(diagnostics.hint)),
|
|
registry: record(diagnostics.registry),
|
|
},
|
|
traceId: stringValue(artifact.traceId),
|
|
firstBreak: record(artifact.firstBreak),
|
|
error: record(artifact.error),
|
|
reason: ready ? "ready" : nodeStatusReason({ ciReady, argoReady, runtimeReady, diagnosticsReady, pipelineStatus: statusText(latest), diagnostics }),
|
|
statusCommand: `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${targetId} --consumer ${consumer.id}`,
|
|
historyCommand: `bun scripts/cli.ts platform-infra pipelines-as-code history --target ${targetId} --consumer ${consumer.id} --limit 10`,
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
function nodeStatusReason(input: { ciReady: boolean; argoReady: boolean; runtimeReady: boolean; diagnosticsReady: boolean; pipelineStatus: string; diagnostics: Record<string, unknown> }): string {
|
|
if (!input.ciReady) return `ci:${input.pipelineStatus}`;
|
|
if (!input.argoReady) return "argo-not-ready";
|
|
if (!input.runtimeReady) return "runtime-not-ready";
|
|
if (!input.diagnosticsReady) return stringValue(input.diagnostics.code, "diagnostics-not-ready");
|
|
return "not-ready";
|
|
}
|
|
|
|
function policyChecks(repository: PacRepository): Array<Record<string, unknown>> {
|
|
return [
|
|
{ name: "single-path", ok: true, detail: "Gitea webhook -> Pipelines-as-Code -> Tekton -> Argo/k8s runtime; no Gitea Actions/act_runner/branch-follower fallback." },
|
|
{ name: "yaml-source-of-truth", ok: true, detail: `${configLabel} owns PaC release, Repository CR, Gitea webhook and consumer params.` },
|
|
{ name: "public-repository-url", ok: !isInternalRepositoryMatchUrl(repository.url), detail: "Repository CR url matches the public Gitea webhook payload URL; internal service URLs remain clone/read URLs only." },
|
|
{ name: "gitea-internal-source", ok: repository.cloneUrl.includes(".svc.cluster.local"), detail: "Tekton source clone uses the internal k3s Gitea service URL." },
|
|
{ name: "runtime-zero-docker", ok: true, detail: "Runtime starts at k8s pulling already-built images; CI build may use Tekton/BuildKit." },
|
|
];
|
|
}
|
|
|
|
function isInternalRepositoryMatchUrl(value: string): boolean {
|
|
const parsed = new URL(value);
|
|
return parsed.hostname.endsWith(".svc.cluster.local")
|
|
|| parsed.hostname === "localhost"
|
|
|| parsed.hostname === "127.0.0.1"
|
|
|| /^10\./u.test(parsed.hostname)
|
|
|| /^192\.168\./u.test(parsed.hostname)
|
|
|| /^172\.(1[6-9]|2\d|3[0-1])\./u.test(parsed.hostname);
|
|
}
|
|
|
|
function targetSummary(target: PacTarget): Record<string, unknown> {
|
|
return { id: target.id, route: target.route, namespace: target.namespace, role: target.role };
|
|
}
|
|
|
|
function configSummary(pac: PacConfig, consumer: PacConsumer, repository: PacRepository): Record<string, unknown> {
|
|
return {
|
|
path: configLabel,
|
|
metadata: pac.metadata,
|
|
release: pac.release,
|
|
capabilities: capabilitySummary(pac),
|
|
deliveryTiming: pac.deliveryTiming,
|
|
gitea: { configRef: pac.gitea.configRef, internalBaseUrl: pac.gitea.internalBaseUrl, webhookBranch: pac.gitea.webhook.branch },
|
|
display: pac.display,
|
|
repositories: pac.repositories.map(repositorySummary),
|
|
consumers: pac.consumers.map(consumerObservationSummary),
|
|
repository: repositorySummary(repository),
|
|
consumer,
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
function compactConfigSummary(pac: PacConfig, consumer: PacConsumer, repository: PacRepository): Record<string, unknown> {
|
|
return {
|
|
path: configLabel,
|
|
releaseVersion: pac.release.version,
|
|
capabilities: capabilitySummary(pac),
|
|
repository: repository.name,
|
|
providerType: repository.providerType,
|
|
sourceUrl: repository.cloneUrl,
|
|
displayTimeZone: pac.display.timeZone,
|
|
deliveryTiming: pac.deliveryTiming,
|
|
consumer: `${consumer.node}/${consumer.lane}`,
|
|
consumerId: consumer.id,
|
|
pipeline: consumer.pipeline,
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
function capabilitySummary(pac: PacConfig): Record<string, unknown> {
|
|
return {
|
|
sentinelInternalPublish: {
|
|
...pac.capabilities.sentinelInternalPublish,
|
|
configRef: `${configLabel}#capabilities.sentinelInternalPublish`,
|
|
valuesPrinted: false,
|
|
},
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
function repositorySummary(repository: PacRepository): Record<string, unknown> {
|
|
return {
|
|
id: repository.id,
|
|
name: repository.name,
|
|
namespace: repository.namespace,
|
|
providerType: repository.providerType,
|
|
url: repository.url,
|
|
cloneUrl: repository.cloneUrl,
|
|
owner: repository.owner,
|
|
repo: repository.repo,
|
|
secretName: repository.secretName,
|
|
concurrencyLimit: repository.concurrencyLimit,
|
|
params: Object.keys(repository.params).sort(),
|
|
};
|
|
}
|
|
|
|
function consumerObservationSummary(consumer: PacConsumer): Record<string, unknown> {
|
|
return {
|
|
id: consumer.id,
|
|
node: consumer.node,
|
|
lane: consumer.lane,
|
|
namespace: consumer.namespace,
|
|
pipeline: consumer.pipeline,
|
|
pipelineRunPrefix: consumer.pipelineRunPrefix,
|
|
argoNamespace: consumer.argoNamespace,
|
|
argoApplication: consumer.argoApplication,
|
|
repositoryRef: consumer.repositoryRef,
|
|
params: Object.keys(consumer.params).sort(),
|
|
runnerServiceAccount: consumer.runnerServiceAccount,
|
|
argoRepositoryCredential: consumer.argoBootstrap?.repositoryCredential === null || consumer.argoBootstrap?.repositoryCredential === undefined ? null : {
|
|
secretName: consumer.argoBootstrap.repositoryCredential.secretName,
|
|
usernameConfigured: consumer.argoBootstrap.repositoryCredential.username.length > 0,
|
|
},
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
function consumerCoverage(pac: PacConfig, targetId: string): Record<string, unknown>[] {
|
|
return validPacConsumers(pac).filter((consumer) => consumer.node.toLowerCase() === targetId.toLowerCase()).map((consumer) => {
|
|
const repository = resolveRepository(pac, consumer.repositoryRef);
|
|
return {
|
|
consumer: consumer.id,
|
|
source: `${repository.owner}/${repository.repo}@${consumerParams(repository, consumer).source_branch ?? "-"}`,
|
|
namespace: consumer.namespace,
|
|
pipeline: consumer.pipeline,
|
|
argoApplication: consumer.argoApplication,
|
|
statusCommand: `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${targetId} --consumer ${consumer.id}`,
|
|
historyCommand: `bun scripts/cli.ts platform-infra pipelines-as-code history --target ${targetId} --consumer ${consumer.id}`,
|
|
};
|
|
});
|
|
}
|
|
|
|
function nextCommands(targetId: string, consumerId: string, _defaultConsumerId: string): Record<string, unknown> {
|
|
return pacReadOnlyNext(targetId, consumerId);
|
|
}
|
|
|
|
function compactPlanJson(result: Record<string, unknown>): Record<string, unknown> {
|
|
const config = record(result.config);
|
|
const consumer = record(result.consumer);
|
|
return {
|
|
ok: result.ok === true,
|
|
action: result.action,
|
|
mutation: false,
|
|
target: result.target,
|
|
config: {
|
|
path: config.path,
|
|
metadata: config.metadata,
|
|
release: config.release,
|
|
capabilities: config.capabilities,
|
|
gitea: config.gitea,
|
|
display: config.display,
|
|
deliveryTiming: config.deliveryTiming,
|
|
valuesPrinted: false,
|
|
},
|
|
repository: result.repository,
|
|
consumer: {
|
|
id: consumer.id,
|
|
node: consumer.node,
|
|
lane: consumer.lane,
|
|
namespace: consumer.namespace,
|
|
pipeline: consumer.pipeline,
|
|
pipelineRunPrefix: consumer.pipelineRunPrefix,
|
|
argoNamespace: consumer.argoNamespace,
|
|
argoApplication: consumer.argoApplication,
|
|
repositoryRef: consumer.repositoryRef,
|
|
params: consumer.params,
|
|
runnerServiceAccount: consumer.runnerServiceAccount,
|
|
argoRepositoryCredential: consumer.argoRepositoryCredential,
|
|
},
|
|
secrets: result.secrets,
|
|
policy: result.policy,
|
|
warnings: result.warnings,
|
|
next: result.next,
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
function compactStatusJson(result: Record<string, unknown>): Record<string, unknown> {
|
|
const consumer = record(result.consumer);
|
|
return {
|
|
ok: result.ok === true,
|
|
action: result.action,
|
|
mutation: false,
|
|
target: result.target,
|
|
consumer: {
|
|
id: consumer.id,
|
|
node: consumer.node,
|
|
lane: consumer.lane,
|
|
namespace: consumer.namespace,
|
|
pipeline: consumer.pipeline,
|
|
argoApplication: consumer.argoApplication,
|
|
},
|
|
deliveryAuthority: result.deliveryAuthority,
|
|
capabilities: record(result.config).capabilities,
|
|
warnings: result.warnings,
|
|
deliveryBudget: result.deliveryBudget,
|
|
summary: compactStatusSummary(record(result.summary)),
|
|
next: result.next,
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
export function compactStatusSummary(summary: Record<string, unknown>): Record<string, unknown> {
|
|
const latest = record(summary.latestPipelineRun);
|
|
const artifact = record(summary.artifact);
|
|
const argo = record(summary.argo);
|
|
const runtime = record(summary.runtime);
|
|
const diagnostics = record(summary.diagnostics);
|
|
const taskRuns = arrayRecords(summary.taskRuns);
|
|
const longest = longestTaskRun(taskRuns);
|
|
return {
|
|
ready: summary.ready === true,
|
|
crdPresent: summary.crdPresent === true,
|
|
controllerReady: summary.controllerReady,
|
|
repositoryCondition: summary.repositoryCondition,
|
|
webhookCount: summary.webhookCount,
|
|
latestPipelineRun: {
|
|
name: latest.name,
|
|
status: latest.status,
|
|
reason: latest.reason,
|
|
durationSeconds: latest.durationSeconds,
|
|
sourceCommit: latest.sourceCommit,
|
|
},
|
|
taskRuns: {
|
|
count: taskRuns.length,
|
|
longest: longest === null ? null : {
|
|
name: longest.name,
|
|
status: longest.status,
|
|
reason: longest.reason,
|
|
durationSeconds: longest.durationSeconds,
|
|
},
|
|
},
|
|
pipelineRunGate: summary.pipelineRunGate,
|
|
admissionProvenance: summary.admissionProvenance,
|
|
consumerBootstrap: summary.consumerBootstrap,
|
|
sourceObservation: summary.sourceObservation,
|
|
artifact: {
|
|
imageStatus: artifact.imageStatus,
|
|
envReuse: artifact.envReuse,
|
|
envIdentity: artifact.envIdentity,
|
|
digest: artifact.digest,
|
|
gitopsCommit: artifact.gitopsCommit,
|
|
provenanceRelation: artifact.provenanceRelation,
|
|
traceId: artifact.traceId ?? latest.traceId,
|
|
firstBreak: artifact.firstBreak ?? latest.firstBreak,
|
|
error: artifact.error ?? latest.error,
|
|
},
|
|
argo: {
|
|
sync: argo.sync,
|
|
health: argo.health,
|
|
revision: argo.revision,
|
|
repoURL: argo.repoURL,
|
|
targetRevision: argo.targetRevision,
|
|
revisionRelation: argo.revisionRelation,
|
|
},
|
|
runtime: {
|
|
deployment: runtime.deployment,
|
|
readyReplicas: runtime.readyReplicas,
|
|
replicas: runtime.replicas,
|
|
image: runtime.image,
|
|
digest: runtime.digest,
|
|
},
|
|
diagnostics: {
|
|
ok: diagnostics.ok !== false,
|
|
code: diagnostics.code,
|
|
phase: diagnostics.phase,
|
|
hint: diagnostics.hint,
|
|
deliveryMode: diagnostics.deliveryMode,
|
|
registry: diagnostics.registry,
|
|
},
|
|
provenance: summary.provenance,
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
function compactCloseoutJson(result: Record<string, unknown>): Record<string, unknown> {
|
|
const consumer = record(result.consumer);
|
|
const flush = record(result.gitOpsMirrorFlush);
|
|
return {
|
|
ok: result.ok === true,
|
|
action: result.action,
|
|
mutation: result.mutation === true,
|
|
target: result.target,
|
|
consumer: { id: consumer.id, node: consumer.node, lane: consumer.lane },
|
|
sourceCommit: result.sourceCommit,
|
|
observedSourceCommit: result.observedSourceCommit,
|
|
sourceMatched: result.sourceMatched === true,
|
|
ready: result.ready === true,
|
|
timedOut: result.timedOut === true,
|
|
elapsedMs: result.elapsedMs,
|
|
cursor: result.cursor,
|
|
wait: result.wait,
|
|
summary: compactStatusSummary(record(result.summary)),
|
|
gitOpsMirrorFlush: {
|
|
ok: flush.ok !== false,
|
|
enabled: flush.enabled,
|
|
required: flush.required,
|
|
applicable: flush.applicable,
|
|
mode: flush.mode,
|
|
executed: flush.executed === true,
|
|
reason: flush.reason,
|
|
},
|
|
blocker: result.blocker,
|
|
next: result.next,
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
export function compactHistoryJson(result: Record<string, unknown>, includeTaskDetails = false): Record<string, unknown> {
|
|
const detailRequested = stringValue(result.detailId) !== "-";
|
|
return {
|
|
ok: result.ok === true,
|
|
action: result.action,
|
|
mutation: false,
|
|
target: result.target,
|
|
config: result.config,
|
|
consumers: result.consumers,
|
|
detailId: result.detailId,
|
|
deliveryBudget: result.deliveryBudget,
|
|
rows: arrayRecords(result.rows).map((row) => {
|
|
const taskRuns = record(row.taskRuns);
|
|
const artifact = record(row.artifact);
|
|
const catalog = record(artifact.catalog);
|
|
const collector = record(row.collector);
|
|
return {
|
|
id: row.id ?? row.pipelineRun,
|
|
consumer: row.consumer,
|
|
repo: row.repo,
|
|
pipeline: row.pipeline,
|
|
triggeredAt: row.triggeredAt,
|
|
startTime: row.startTime,
|
|
completionTime: row.completionTime,
|
|
displayTime: row.displayTime,
|
|
displayTimeZone: row.displayTimeZone,
|
|
durationSeconds: row.durationSeconds,
|
|
status: row.status,
|
|
reason: row.reason,
|
|
commit: row.commit,
|
|
branch: row.branch,
|
|
deliveryClass: row.deliveryClass,
|
|
deliveryAuthorityEligible: row.deliveryAuthorityEligible === true,
|
|
deliveryOwner: row.deliveryOwner,
|
|
parentRelation: row.parentRelation,
|
|
classification: row.classification,
|
|
envReuse: row.envReuse,
|
|
sourceObservation: row.sourceObservation,
|
|
traceId: row.traceId,
|
|
firstBreak: row.firstBreak,
|
|
error: row.error,
|
|
artifact: detailRequested ? {
|
|
imageStatus: artifact.imageStatus,
|
|
envReuse: artifact.envReuse,
|
|
digest: artifact.digest,
|
|
digests: artifact.digests,
|
|
gitopsCommit: artifact.gitopsCommit,
|
|
sourceCommit: artifact.sourceCommit,
|
|
action: artifact.action,
|
|
reason: artifact.reason,
|
|
catalog: {
|
|
present: catalog.present === true,
|
|
status: catalog.status,
|
|
sourceCommit: catalog.sourceCommit,
|
|
registryVerified: catalog.registryVerified === true,
|
|
publishedCount: catalog.publishedCount,
|
|
reusedCount: catalog.reusedCount,
|
|
requiredServiceCount: catalog.requiredServiceCount,
|
|
digestCount: catalog.digestCount,
|
|
source: catalog.source,
|
|
valuesPrinted: false,
|
|
},
|
|
valuesPrinted: false,
|
|
} : undefined,
|
|
collector: detailRequested ? {
|
|
mode: collector.mode,
|
|
matchingTaskCount: collector.matchingTaskCount,
|
|
contractTaskCount: collector.contractTaskCount,
|
|
terminalRecordCount: collector.terminalRecordCount,
|
|
logErrorCount: collector.logErrorCount,
|
|
logErrors: collector.logErrors,
|
|
valuesPrinted: false,
|
|
} : undefined,
|
|
taskRuns: {
|
|
count: taskRuns.count,
|
|
longest: taskRuns.longest,
|
|
failed: taskRuns.failed,
|
|
details: includeTaskDetails ? taskRuns.details : undefined,
|
|
logsCommand: taskRuns.logsCommand,
|
|
},
|
|
};
|
|
}),
|
|
historyErrors: result.historyErrors,
|
|
warnings: result.warnings,
|
|
next: result.next,
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
function renderPlan(result: Record<string, unknown>): RenderedCliResult {
|
|
const target = record(result.target);
|
|
const config = record(result.config);
|
|
const repository = record(result.repository);
|
|
const consumer = record(result.consumer);
|
|
const internalPublish = record(record(config.capabilities).sentinelInternalPublish);
|
|
const secrets = arrayRecords(result.secrets);
|
|
const policy = arrayRecords(result.policy);
|
|
const warnings = arrayRecords(result.warnings);
|
|
const lines = [
|
|
"PLATFORM-INFRA PIPELINES-AS-CODE PLAN",
|
|
...table(["TARGET", "NAMESPACE", "RELEASE", "REPOSITORY", "CONSUMER"], [[stringValue(target.id), stringValue(repository.namespace), stringValue(record(config.release).version), stringValue(repository.name), stringValue(consumer.id)]]),
|
|
"",
|
|
"PATH",
|
|
...table(["STAGE", "AUTHORITY", "DETAIL"], [
|
|
["source", "Gitea", stringValue(repository.cloneUrl)],
|
|
["trigger", "Pipelines-as-Code", "Gitea push webhook"],
|
|
["ci", "Tekton", stringValue(consumer.pipeline)],
|
|
["cd", "Argo", stringValue(consumer.argoApplication)],
|
|
]),
|
|
"",
|
|
"SECRETS",
|
|
...table(["ID", "PRESENT", "FINGERPRINT", "VALUES"], secrets.map((item) => [stringValue(item.id), boolText(item.present), stringValue(item.fingerprint), "false"])),
|
|
"",
|
|
"POLICY",
|
|
...table(["NAME", "OK", "DETAIL"], policy.map((item) => [stringValue(item.name), boolText(item.ok), stringValue(item.detail)])),
|
|
...renderPacConfigWarnings(warnings),
|
|
"",
|
|
"CAPABILITIES",
|
|
...table(["NAME", "ENABLED", "PROVENANCE", "TRACKING"], [[
|
|
"sentinelInternalPublish",
|
|
boolText(internalPublish.enabled),
|
|
stringValue(internalPublish.admissionProvenance),
|
|
stringValue(internalPublish.trackingIssue),
|
|
]]),
|
|
"",
|
|
"READ-ONLY NEXT",
|
|
` status: ${stringValue(record(result.next).status)}`,
|
|
` history: ${stringValue(record(result.next).history)}`,
|
|
];
|
|
return rendered(result, "platform-infra pipelines-as-code plan", lines);
|
|
}
|
|
|
|
function renderApply(result: Record<string, unknown>): RenderedCliResult {
|
|
const target = record(result.target);
|
|
const remote = record(result.remote);
|
|
const preflight = record(remote.preflight);
|
|
const errorText = compactLine(stringValue(remote.stderrTail, stringValue(remote.error, stringValue(result.error, ""))));
|
|
const warnings = arrayRecords(result.warnings);
|
|
const lines = [
|
|
"PLATFORM-INFRA PIPELINES-AS-CODE APPLY",
|
|
...table(["TARGET", "MODE", "MUTATION", "OK"], [[stringValue(target.id), stringValue(result.mode), boolText(result.mutation), boolText(result.ok)]]),
|
|
"",
|
|
"REMOTE",
|
|
...table(["CONTROLLER", "WEBHOOK", "REPOSITORY", "EXIT", "VALUES"], [[stringValue(remote.controllerReady), stringValue(record(remote.webhook).present), stringValue(remote.repository), stringValue(remote.exitCode, "0"), "false"]]),
|
|
...(Object.keys(preflight).length === 0 ? [] : [
|
|
"",
|
|
"PREFLIGHT",
|
|
...table(["GITEA_REPOSITORY", "HTTP", "EXISTS"], [[stringValue(preflight.repository), stringValue(preflight.httpStatus), boolText(preflight.repositoryExists)]]),
|
|
]),
|
|
...(result.ok === false && errorText.length > 0 ? ["", `ERROR ${errorText}`] : []),
|
|
...(remote.error === "gitea-repository-missing" ? ["HELP bun scripts/cli.ts platform-infra pipelines-as-code help platform-bootstrap"] : []),
|
|
...renderPacConfigWarnings(warnings),
|
|
"",
|
|
"NEXT",
|
|
` status: ${stringValue(record(result.next).status)}`,
|
|
];
|
|
return rendered(result, "platform-infra pipelines-as-code apply", lines);
|
|
}
|
|
|
|
export function renderStatus(result: Record<string, unknown>): RenderedCliResult {
|
|
const summary = record(result.summary);
|
|
const consumer = record(result.consumer);
|
|
const deliveryAuthority = record(result.deliveryAuthority);
|
|
const internalPublish = record(record(record(result.config).capabilities).sentinelInternalPublish);
|
|
const coverage = arrayRecords(result.coverage);
|
|
const warnings = arrayRecords(result.warnings);
|
|
const latest = record(summary.latestPipelineRun);
|
|
const deliveryBudget = record(result.deliveryBudget);
|
|
const taskRuns = arrayRecords(summary.taskRuns);
|
|
const artifact = record(summary.artifact);
|
|
const argo = record(summary.argo);
|
|
const diagnostics = record(summary.diagnostics);
|
|
const latestFirstBreak = record(artifact.firstBreak ?? latest.firstBreak);
|
|
const latestError = record(artifact.error ?? latest.error);
|
|
const latestTraceId = stringValue(artifact.traceId ?? latest.traceId);
|
|
const admissionProvenance = record(summary.admissionProvenance);
|
|
const consumerBootstrap = record(summary.consumerBootstrap);
|
|
const bootstrapRunner = record(consumerBootstrap.runner);
|
|
const bootstrapArgo = record(consumerBootstrap.argoRepository);
|
|
const sourceObservation = record(summary.sourceObservation);
|
|
const deliveryPlan = record(sourceObservation.plan);
|
|
const repository = record(summary.repository);
|
|
const webhooks = arrayRecords(summary.webhooks);
|
|
const lines = [
|
|
"PLATFORM-INFRA PIPELINES-AS-CODE STATUS",
|
|
...renderPacConfigWarnings(warnings),
|
|
...(coverage.length === 0 ? [] : [
|
|
"COVERAGE",
|
|
...table(["CONSUMER", "SOURCE", "NAMESPACE", "PIPELINE", "ARGO_APP"], coverage.map((item) => [stringValue(item.consumer), stringValue(item.source), stringValue(item.namespace), stringValue(item.pipeline), stringValue(item.argoApplication)])),
|
|
"",
|
|
]),
|
|
...table(["CONSUMER", "READY", "AUTHORITY", "CRD", "CONTROLLER", "WEBHOOKS", "REPO_URL"], [[stringValue(consumer.id), boolText(summary.ready), stringValue(deliveryAuthority.kind), boolText(summary.crdPresent), stringValue(summary.controllerReady), stringValue(summary.webhookCount), short(stringValue(repository.url), 56)]]),
|
|
` repository-condition: ${compactLine(stringValue(summary.repositoryCondition))}`,
|
|
` sentinel-internal-publish: enabled=${boolText(internalPublish.enabled)} provenance=${stringValue(internalPublish.admissionProvenance)} tracking=${stringValue(internalPublish.trackingIssue)}`,
|
|
...(admissionProvenance.configured === false
|
|
? [" admission-provenance: not-required"]
|
|
: [
|
|
` admission-provenance: ready=${boolText(admissionProvenance.ready)} policy=${stringValue(admissionProvenance.policyName)} binding=${stringValue(admissionProvenance.bindingName)} active-after=${stringValue(admissionProvenance.activeAfter)} default-can-mutate=${boolText(admissionProvenance.defaultCanMutate)}`,
|
|
` admission-reasons: ${Array.isArray(admissionProvenance.reasons) ? admissionProvenance.reasons.join(",") || "-" : "-"}`,
|
|
]),
|
|
...(consumerBootstrap.configured !== true
|
|
? [" consumer-bootstrap: not-declared"]
|
|
: [
|
|
` consumer-bootstrap: ready=${boolText(consumerBootstrap.ready)} runner=${stringValue(bootstrapRunner.serviceAccount)} exists=${boolText(bootstrapRunner.exists)} automount-disabled=${boolText(bootstrapRunner.automountDisabled)} rolebinding-exact=${boolText(bootstrapRunner.roleBindingExact)}`,
|
|
` argocd-repository-secret: name=${stringValue(bootstrapArgo.secretName)} exists=${boolText(bootstrapArgo.exists)} label=${boolText(bootstrapArgo.labelReady)} keys=${boolText(bootstrapArgo.keysReady)} url=${boolText(bootstrapArgo.urlReady)} password-present=${boolText(bootstrapArgo.passwordPresent)} password-decoded=${boolText(bootstrapArgo.passwordDecoded)}`,
|
|
` consumer-bootstrap-reasons: ${Array.isArray(consumerBootstrap.reasons) ? consumerBootstrap.reasons.join(",") || "-" : "-"}`,
|
|
]),
|
|
"",
|
|
"GITEA HOOKS",
|
|
...(webhooks.length === 0 ? ["-"] : table(["HOOK", "ACTIVE", "EVENTS", "URL"], webhooks.map((item) => [stringValue(item.id), boolText(item.active), Array.isArray(item.events) ? item.events.join(",") : stringValue(item.events), short(stringValue(item.url), 56)]))),
|
|
"",
|
|
"LATEST PIPELINERUN",
|
|
...table(["NAME", "STATUS", "REASON", "DURATION_S", "SOURCE"], [[stringValue(latest.name), stringValue(latest.status), stringValue(latest.reason), stringValue(latest.durationSeconds), short(stringValue(latest.sourceCommit))]]),
|
|
` delivery-budget: state=${stringValue(deliveryBudget.state)} end-to-end=${stringValue(deliveryBudget.observedSeconds)}s/${stringValue(deliveryBudget.budgetSeconds)}s exact=${stringValue(deliveryBudget.exactCommand)}`,
|
|
"",
|
|
"DELIVERY OBSERVATION",
|
|
...table(["MODE", "VALID", "SOURCE_MATCHED", "AFFECTED", "ROLLOUT", "BUILD", "REUSED", "REASON"], [[
|
|
stringValue(sourceObservation.mode),
|
|
boolText(sourceObservation.valid),
|
|
boolText(sourceObservation.sourceMatched ?? (stringValue(sourceObservation.sourceCommit) === stringValue(latest.sourceCommit))),
|
|
stringValue(deliveryPlan.affectedServiceCount),
|
|
stringValue(deliveryPlan.rolloutServiceCount),
|
|
stringValue(deliveryPlan.buildServiceCount),
|
|
stringValue(deliveryPlan.reusedServiceCount),
|
|
stringValue(sourceObservation.reason),
|
|
]]),
|
|
renderFeatureConfigSchemaLine(sourceObservation.featureConfigSchema),
|
|
"",
|
|
"TASKRUN DURATIONS",
|
|
...(taskRuns.length === 0 ? ["-"] : table(["TASKRUN", "STATUS", "REASON", "FAILED_STEP", "EXIT", "DURATION_S"], taskRuns.map((item) => {
|
|
const failedStep = record(item.failedStep);
|
|
return [short(stringValue(item.name), 48), stringValue(item.status), stringValue(item.reason), stringValue(failedStep.name), stringValue(failedStep.exitCode), stringValue(item.durationSeconds)];
|
|
}))),
|
|
"",
|
|
"IMAGE / GITOPS",
|
|
...table(["IMAGE_STATUS", "ENV_REUSE", "ENV_ID", "DIGEST", "GITOPS"], [[stringValue(artifact.imageStatus), stringValue(artifact.envReuse), stringValue(artifact.envIdentity), short(stringValue(artifact.digest), 18), short(stringValue(artifact.gitopsCommit))]]),
|
|
` stage-timings: ${stageTimingsText(record(artifact.stageTimings))}`,
|
|
"",
|
|
"ARGO",
|
|
...table(["SYNC", "HEALTH", "REVISION", "RELATION", "TARGET_REVISION"], [[
|
|
stringValue(argo.sync),
|
|
stringValue(argo.health),
|
|
short(stringValue(argo.revision)),
|
|
stringValue(record(argo.revisionRelation).relation),
|
|
stringValue(argo.targetRevision),
|
|
]]),
|
|
` repo-url: ${compactLine(stringValue(argo.repoURL))}`,
|
|
"",
|
|
"CICD DIAGNOSIS",
|
|
...table(["OK", "CODE", "PHASE", "TRACE", "FIRST_BREAK", "ERROR", "EXIT", "SOURCE", "REGISTRY", "GITOPS", "RUNTIME"], [[
|
|
boolText(diagnostics.ok !== false),
|
|
stringValue(diagnostics.code),
|
|
stringValue(latestFirstBreak.phase ?? latestError.phase ?? diagnostics.phase),
|
|
short(latestTraceId, 16),
|
|
stringValue(latestFirstBreak.code),
|
|
`${stringValue(latestError.type)}/${stringValue(latestError.code)}`,
|
|
stringValue(latestError.exitCode),
|
|
short(stringValue(diagnostics.sourceCommit)),
|
|
registryText(record(diagnostics.registry)),
|
|
short(stringValue(record(diagnostics.gitops).commit)),
|
|
runtimeText(record(diagnostics.runtime)),
|
|
]]),
|
|
` hint: ${compactLine(stringValue(diagnostics.hint))}`,
|
|
` pipeline-run: ${latest.name === undefined ? "-" : `bun scripts/cli.ts platform-infra pipelines-as-code history --target ${stringValue(record(result.target).id)} --id ${stringValue(latest.name)}`}`,
|
|
"",
|
|
"NEXT",
|
|
` full: ${stringValue(record(result.next).status)} --full`,
|
|
` all-history: ${stringValue(record(result.next).history).replace(/ --consumer [^ ]+/u, "")} --limit 10`,
|
|
` fix-automatic-delivery: ${stringValue(record(record(result.next).fixAutomaticDelivery).reference)}`,
|
|
];
|
|
return rendered(result, "platform-infra pipelines-as-code status", lines);
|
|
}
|
|
|
|
function renderPacConfigWarnings(warnings: readonly Record<string, unknown>[]): string[] {
|
|
if (warnings.length === 0) return [];
|
|
return [
|
|
"",
|
|
"NON-BLOCKING WARNINGS",
|
|
...table(["OBJECT", "BLOCKING", "CODE", "CONFIG", "OBSERVED/BUDGET"], warnings.map((item) => [
|
|
`${stringValue(record(item.object).kind)}:${stringValue(record(item.object).id)}`,
|
|
boolText(item.blocking),
|
|
stringValue(item.code),
|
|
stringValue(item.configPath),
|
|
item.observedSeconds === undefined ? "-" : `${stringValue(item.observedSeconds)}s/${stringValue(item.budgetSeconds)}s`,
|
|
])),
|
|
...warnings.flatMap((item) => {
|
|
const hint = record(item.hint);
|
|
return Object.keys(hint).length === 0 ? [] : [
|
|
` hint: ${compactLine(stringValue(hint.summary))}`,
|
|
` status: ${stringValue(hint.status)}`,
|
|
` history: ${stringValue(hint.history)}`,
|
|
];
|
|
}),
|
|
];
|
|
}
|
|
|
|
function renderCloseout(result: Record<string, unknown>): RenderedCliResult {
|
|
const target = record(result.target);
|
|
const consumer = record(result.consumer);
|
|
const wait = record(result.wait);
|
|
const summary = record(result.summary);
|
|
const latest = record(summary.latestPipelineRun);
|
|
const artifact = record(summary.artifact);
|
|
const argo = record(summary.argo);
|
|
const runtime = record(summary.runtime);
|
|
const diagnostics = record(summary.diagnostics);
|
|
const sourceObservation = record(summary.sourceObservation);
|
|
const blocker = record(result.blocker);
|
|
const gitOpsMirrorFlush = record(result.gitOpsMirrorFlush);
|
|
const lines = [
|
|
"PLATFORM-INFRA PIPELINES-AS-CODE CLOSEOUT",
|
|
...table(["TARGET", "CONSUMER", "READY", "SOURCE_MATCHED", "MUTATION"], [[stringValue(target.id), stringValue(consumer.id), boolText(result.ready), boolText(result.sourceMatched), boolText(result.mutation)]]),
|
|
"",
|
|
"WAIT",
|
|
...table(["REQUESTED", "COMPLETED", "TIMED_OUT", "CANCELLED", "OBSERVATIONS", "ELAPSED_MS", "BUDGET_S"], [[
|
|
stringValue(wait.requested),
|
|
boolText(wait.completed),
|
|
boolText(wait.timedOut),
|
|
boolText(wait.cancelled),
|
|
stringValue(wait.observations),
|
|
stringValue(wait.elapsedMs),
|
|
stringValue(wait.budgetSeconds),
|
|
]]),
|
|
` identity: ${compactLine(JSON.stringify(wait.identity ?? {}))}`,
|
|
` cursor: ${stringValue(wait.cursor)}`,
|
|
"",
|
|
"SOURCE / PIPELINERUN",
|
|
...table(["EXPECTED", "OBSERVED", "PIPELINERUN", "STATUS", "DURATION_S"], [[short(stringValue(result.sourceCommit), 16), short(stringValue(result.observedSourceCommit), 16), stringValue(latest.name), stringValue(latest.reason ?? latest.status), stringValue(latest.durationSeconds)]]),
|
|
` delivery: ${stringValue(sourceObservation.mode)} (${stringValue(sourceObservation.reason)})`,
|
|
"",
|
|
"RUNTIME",
|
|
...table(["IMAGE_STATUS", "ENV_REUSE", "DIGEST", "GITOPS", "ARGO", "RELATION", "RUNTIME"], [[
|
|
stringValue(artifact.imageStatus),
|
|
stringValue(artifact.envReuse),
|
|
short(stringValue(artifact.digest), 18),
|
|
short(stringValue(artifact.gitopsCommit), 12),
|
|
`${stringValue(argo.sync)}/${stringValue(argo.health)}`,
|
|
stringValue(record(argo.revisionRelation).relation),
|
|
runtimeText(runtime),
|
|
]]),
|
|
"",
|
|
"CICD DIAGNOSIS",
|
|
...table(["OK", "CODE", "PHASE", "HINT"], [[boolText(diagnostics.ok !== false), stringValue(diagnostics.code), stringValue(diagnostics.phase), compactLine(stringValue(diagnostics.hint))]]),
|
|
"",
|
|
"AUTOMATIC DELIVERY OBSERVATION",
|
|
...table(["ENABLED", "REQUIRED", "OK", "MODE", "EXECUTED", "PENDING", "IN_SYNC"], [[
|
|
boolText(gitOpsMirrorFlush.enabled),
|
|
boolText(gitOpsMirrorFlush.required),
|
|
boolText(gitOpsMirrorFlush.ok !== false),
|
|
stringValue(gitOpsMirrorFlush.mode),
|
|
boolText(gitOpsMirrorFlush.executed),
|
|
stringValue(record(gitOpsMirrorFlush.afterSummary ?? gitOpsMirrorFlush.beforeSummary).pendingFlush),
|
|
stringValue(record(gitOpsMirrorFlush.afterSummary ?? gitOpsMirrorFlush.beforeSummary).githubInSync),
|
|
]]),
|
|
"",
|
|
...(Object.keys(blocker).length === 0
|
|
? ["BLOCKER", "-"]
|
|
: ["BLOCKER", ...table(["CODE", "REASON"], [[stringValue(blocker.code), compactLine(stringValue(blocker.reason))]])]),
|
|
"",
|
|
"NEXT",
|
|
` status: ${stringValue(record(result.next).status)}`,
|
|
` history: ${stringValue(record(result.next).history)} --limit 10`,
|
|
` wait: ${stringValue(record(result.next).wait)}`,
|
|
latest.name === undefined ? " pipeline-run: -" : ` pipeline-run: ${stringValue(record(result.next).history)} --id ${stringValue(latest.name)}`,
|
|
];
|
|
return rendered(result, "platform-infra pipelines-as-code closeout", lines);
|
|
}
|
|
|
|
async function deliveryTiming(config: UniDeskConfig, options: CommonOptions): Promise<Record<string, unknown>> {
|
|
const pac = readPacConfig({ consumerId: options.consumerId, selectDefault: true });
|
|
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}`);
|
|
const authority = resolveCicdDeliveryAuthority({ consumerId: consumer.id, node: consumer.node, lane: consumer.lane });
|
|
if (authority.kind !== "pac-pr-merge") throw new Error(`delivery authority is ${authority.kind}: ${authority.reason}`);
|
|
const historyOptions: HistoryOptions = { ...options, limit: 1, detailId: null };
|
|
return await observePacDeliveryTiming({
|
|
target: targetSummary(target),
|
|
consumer: {
|
|
id: consumer.id,
|
|
node: consumer.node,
|
|
lane: consumer.lane,
|
|
repository: authority.consumer.sourceRepository,
|
|
branch: authority.consumer.sourceBranch,
|
|
pipeline: consumer.pipeline,
|
|
},
|
|
policy: pac.deliveryTiming,
|
|
}, {
|
|
history: async () => await history(config, historyOptions),
|
|
status: async () => await status(config, options),
|
|
});
|
|
}
|
|
|
|
function renderDeliveryTiming(result: Record<string, unknown>): RenderedCliResult {
|
|
const timing = record(result.timing);
|
|
const source = record(result.source);
|
|
const pr = record(source.pullRequest);
|
|
const runtime = record(result.runtime);
|
|
const argo = record(runtime.argo);
|
|
const health = record(runtime.health);
|
|
const deliveryBudget = record(result.deliveryBudget);
|
|
const warnings = arrayRecords(result.warnings);
|
|
const lines = [
|
|
"PLATFORM-INFRA DELIVERY TIMING",
|
|
`STATE: ${stringValue(result.state)} MUTATION: ${boolText(result.mutation)}`,
|
|
`SOURCE: ${stringValue(record(result.consumer).repository)}@${stringValue(source.commit)} PR#${stringValue(pr.number)}`,
|
|
`MERGED_AT: ${stringValue(timing.mergedAt)} PIPELINE_START: ${stringValue(timing.pipelineStart)} PIPELINE_DONE: ${stringValue(timing.pipelineCompletion)}`,
|
|
`TRIGGER_WAIT_S: ${stringValue(timing.triggerWaitSeconds)} PIPELINE_S: ${stringValue(timing.pipelineDurationSeconds)} END_TO_END_S: ${stringValue(timing.endToEndSeconds)}`,
|
|
`BUDGET_S: ${stringValue(deliveryBudget.budgetSeconds)} OVER_BUDGET: ${stringValue(deliveryBudget.overBudget)} BUDGET_STATE: ${stringValue(deliveryBudget.state)}`,
|
|
`ARGO: ${stringValue(argo.sync)}/${stringValue(argo.health)} RUNTIME_READY: ${stringValue(health.readyReplicas)}/${stringValue(health.replicas)}`,
|
|
`EVIDENCE_GAPS: ${(Array.isArray(result.evidenceGaps) ? result.evidenceGaps.map(String) : []).join(", ") || "-"}`,
|
|
...renderPacConfigWarnings(warnings),
|
|
];
|
|
return rendered(result, "platform-infra pipelines-as-code delivery-timing", lines);
|
|
}
|
|
|
|
export function renderHistory(result: Record<string, unknown>): RenderedCliResult {
|
|
const rows = arrayRecords(result.rows);
|
|
const config = record(result.config);
|
|
const historyErrors = arrayRecords(result.historyErrors);
|
|
const deliveryBudget = record(result.deliveryBudget);
|
|
const warnings = arrayRecords(result.warnings);
|
|
const detailId = stringValue(result.detailId);
|
|
const timeHeader = `TIME(${stringValue(config.displayTimeZone)})`;
|
|
const lines = [
|
|
"PLATFORM-INFRA PIPELINES-AS-CODE HISTORY",
|
|
`DATA: ${stringValue(config.source)}`,
|
|
`ROWS: ${stringValue(rows.length)} LIMIT_PER_CONSUMER: ${stringValue(config.limitPerConsumer)} READ_ERRORS: ${stringValue(historyErrors.length)}`,
|
|
`DELIVERY_BUDGET: state=${stringValue(deliveryBudget.state)} budget=${stringValue(record(deliveryBudget.policy).endToEndBudgetSeconds)}s exact=${stringValue(deliveryBudget.exactCommand)}`,
|
|
...renderPacConfigWarnings(warnings),
|
|
...(historyErrors.length === 0 ? [] : [
|
|
"",
|
|
"READ ERRORS",
|
|
...table(["CONTEXT", "STATUS", "ERROR"], historyErrors.map((item) => [stringValue(item.context), stringValue(item.status), compactLine(stringValue(item.error ?? item.stderr))])),
|
|
]),
|
|
"",
|
|
"TRIGGERS",
|
|
...(rows.length === 0 ? ["-"] : table([timeHeader, "CONSUMER", "CLASS", "OWNER", "STATUS", "TRACE", "FIRST_BREAK", "PHASE", "ERROR", "EXIT", "DUR_S", "COMMIT", "ENV_REUSE", "ID"], rows.map((row) => {
|
|
const firstBreak = record(row.firstBreak);
|
|
const error = record(row.error);
|
|
return [
|
|
stringValue(row.displayTime) === "-" ? isoShort(stringValue(row.triggeredAt)) : stringValue(row.displayTime),
|
|
stringValue(row.consumer),
|
|
stringValue(row.deliveryClass),
|
|
stringValue(row.deliveryOwner ?? row.executionOwner),
|
|
statusText(row),
|
|
short(stringValue(row.traceId), 16),
|
|
stringValue(firstBreak.code),
|
|
stringValue(firstBreak.phase ?? error.phase),
|
|
`${stringValue(error.type)}/${stringValue(error.code)}`,
|
|
stringValue(error.exitCode),
|
|
stringValue(row.durationSeconds),
|
|
short(stringValue(row.commit)),
|
|
envReuseText(record(row.envReuse)),
|
|
stringValue(row.id ?? row.pipelineRun),
|
|
];
|
|
}))),
|
|
...(detailId === "-" || rows.length !== 1 ? [] : renderHistoryDetail(rows[0] ?? {})),
|
|
"",
|
|
"NEXT",
|
|
rows.length > 0 && detailId === "-" ? ` detail: ${stringValue(record(result.next).history)} --id ${stringValue(rows[0]?.id ?? rows[0]?.pipelineRun)}` : null,
|
|
` full: ${stringValue(record(result.next).history)}${detailId === "-" ? "" : ` --id ${detailId}`} --full`,
|
|
];
|
|
return rendered(result, "platform-infra pipelines-as-code history", lines.filter((line): line is string => line !== null));
|
|
}
|
|
|
|
export function renderDebugStep(result: Record<string, unknown>): RenderedCliResult {
|
|
const checks = arrayRecords(result.checks);
|
|
const realRun = record(result.realRun);
|
|
const pipelineRun = record(realRun.pipelineRun);
|
|
const artifact = record(realRun.artifact);
|
|
const catalog = record(artifact.catalog);
|
|
const terminal = arrayRecords(realRun.terminal);
|
|
const firstBreak = record(realRun.firstBreak);
|
|
const typedError = record(realRun.error);
|
|
const fixturePassed = checks.filter((item) => item.ok === true).length;
|
|
const lines = [
|
|
"PLATFORM-INFRA PIPELINES-AS-CODE DEBUG STEP",
|
|
...table(["TARGET", "CONSUMER", "PIPELINERUN", "OK", "FIXTURES", "MUTATION"], [[
|
|
stringValue(record(result.target).id),
|
|
stringValue(result.consumer),
|
|
stringValue(result.detailId),
|
|
boolText(result.ok),
|
|
`${fixturePassed}/${checks.length}`,
|
|
boolText(result.mutation),
|
|
]]),
|
|
...(Object.keys(realRun).length === 0 ? [] : [
|
|
"",
|
|
"REAL PIPELINERUN EVIDENCE",
|
|
...table(["FOUND", "CLASS", "AUTHORITY", "STATUS", "TRACE", "PHASE", "ERROR", "EXIT", "SOURCE", "MODE", "VALID", "FIRST_BREAK"], [[
|
|
boolText(realRun.found),
|
|
stringValue(pipelineRun.deliveryClass),
|
|
boolText(pipelineRun.deliveryAuthorityEligible),
|
|
stringValue(pipelineRun.status ?? pipelineRun.reason),
|
|
short(stringValue(realRun.traceId), 16),
|
|
stringValue(firstBreak.phase ?? firstBreak.stage),
|
|
`${stringValue(typedError.type)}/${stringValue(typedError.code)}`,
|
|
stringValue(typedError.exitCode),
|
|
short(stringValue(pipelineRun.sourceCommit), 16),
|
|
stringValue(realRun.mode),
|
|
boolText(realRun.valid),
|
|
stringValue(firstBreak.code),
|
|
]]),
|
|
"",
|
|
"TERMINAL EVIDENCE",
|
|
...(terminal.length === 0 ? ["-"] : table(["STEP", "PRESENT", "STATUS", "SOURCE", "MARKER", "TASKRUN", "RESULTS"], terminal.map((item) => [
|
|
stringValue(item.step),
|
|
boolText(item.present),
|
|
stringValue(item.status),
|
|
stringValue(item.source),
|
|
item.markerPresent === true ? stringValue(item.markerSource) : "-",
|
|
short(stringValue(item.taskRun), 52),
|
|
compactResultNames(item.results),
|
|
]))),
|
|
"",
|
|
"ARTIFACT / GITOPS",
|
|
...table(["CATALOG", "VERIFIED", "REUSED", "DIGESTS", "GITOPS"], [[
|
|
stringValue(catalog.status),
|
|
boolText(catalog.registryVerified),
|
|
stringValue(catalog.reusedCount),
|
|
stringValue(catalog.digestCount),
|
|
short(stringValue(artifact.gitopsCommit), 16),
|
|
]]),
|
|
renderFeatureConfigSchemaLine(record(artifact.sourceObservation).featureConfigSchema),
|
|
]),
|
|
"",
|
|
"STATUS EVALUATOR FIXTURES",
|
|
...(checks.length === 0 ? ["-"] : table(["CASE", "OK", "EXPECTED", "ACTUAL"], checks.map((item) => [
|
|
stringValue(item.id),
|
|
boolText(item.ok),
|
|
`${stringValue(item.expectedOk)}/${stringValue(item.expectedCode)}`,
|
|
`${stringValue(item.actualOk)}/${stringValue(item.actualCode)}`,
|
|
]))),
|
|
"",
|
|
"NEXT",
|
|
` status: ${stringValue(record(result.next).status)}`,
|
|
];
|
|
return rendered(result, "platform-infra pipelines-as-code debug-step", lines);
|
|
}
|
|
|
|
function parseApplyOptions(args: string[]): ApplyOptions {
|
|
const commonArgs: string[] = [];
|
|
let confirm = false;
|
|
let dryRun = false;
|
|
let wait = false;
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index];
|
|
if (arg === "--confirm") confirm = true;
|
|
else if (arg === "--dry-run") dryRun = true;
|
|
else if (arg === "--wait") wait = true;
|
|
else {
|
|
commonArgs.push(arg);
|
|
if (arg === "--target" || arg === "--node" || arg === "--consumer") {
|
|
commonArgs.push(args[index + 1] ?? "");
|
|
index += 1;
|
|
}
|
|
}
|
|
}
|
|
if (confirm && dryRun) throw new Error("pipelines-as-code apply accepts only one of --confirm or --dry-run");
|
|
return { ...parseCommonOptions(commonArgs), confirm, dryRun: dryRun || !confirm, wait };
|
|
}
|
|
|
|
function parseCloseoutOptions(args: string[]): CloseoutOptions {
|
|
const commonArgs: string[] = [];
|
|
let sourceCommit: string | null = null;
|
|
let wait = false;
|
|
let timeoutMs = DEFAULT_READ_ONLY_LONG_POLL_TIMEOUT_MS;
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index];
|
|
if (arg === "--source-commit" || arg === "--commit") {
|
|
const value = args[index + 1];
|
|
if (value === undefined || value.startsWith("--")) throw new Error(`${arg} requires a value`);
|
|
if (!/^[0-9a-f]{40}$/iu.test(value)) throw new Error(`${arg} must be a full git sha`);
|
|
sourceCommit = value;
|
|
index += 1;
|
|
} else if (arg === "--wait") {
|
|
wait = true;
|
|
} else if (arg === "--timeout") {
|
|
const value = args[index + 1];
|
|
if (value === undefined || value.startsWith("--")) throw new Error("--timeout requires a value");
|
|
timeoutMs = parseStrictDuration(value, "--timeout");
|
|
index += 1;
|
|
} else {
|
|
commonArgs.push(arg);
|
|
if (arg === "--target" || arg === "--node" || arg === "--consumer") {
|
|
commonArgs.push(args[index + 1] ?? "");
|
|
index += 1;
|
|
}
|
|
}
|
|
}
|
|
return { ...parseCommonOptions(commonArgs), sourceCommit, wait, timeoutMs };
|
|
}
|
|
|
|
function parseHistoryOptions(args: string[]): HistoryOptions {
|
|
const commonArgs: string[] = [];
|
|
let limit = 5;
|
|
let detailId: string | null = null;
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index];
|
|
if (arg === "--limit") {
|
|
const value = args[index + 1];
|
|
if (value === undefined || value.startsWith("--")) throw new Error("--limit requires a value");
|
|
const parsed = Number.parseInt(value, 10);
|
|
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 50) throw new Error("--limit must be an integer from 1 to 50");
|
|
limit = parsed;
|
|
index += 1;
|
|
} else if (arg === "--id" || arg === "--run" || arg === "--pipeline-run") {
|
|
const value = args[index + 1];
|
|
if (value === undefined || value.startsWith("--")) throw new Error(`${arg} requires a value`);
|
|
if (!/^[A-Za-z0-9._:-]+$/u.test(value)) throw new Error(`${arg} must be a PipelineRun id`);
|
|
detailId = value;
|
|
index += 1;
|
|
} else {
|
|
commonArgs.push(arg);
|
|
if (arg === "--target" || arg === "--node" || arg === "--consumer") {
|
|
commonArgs.push(args[index + 1] ?? "");
|
|
index += 1;
|
|
}
|
|
}
|
|
}
|
|
return { ...parseCommonOptions(commonArgs), limit, detailId };
|
|
}
|
|
|
|
function parseCommonOptions(args: string[]): CommonOptions {
|
|
let targetId: string | null = null;
|
|
let consumerId: string | null = null;
|
|
let full = false;
|
|
let raw = false;
|
|
let json = false;
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index];
|
|
if (arg === "--target" || arg === "--node" || arg === "--consumer") {
|
|
const value = args[index + 1];
|
|
if (value === undefined || value.startsWith("--")) throw new Error(`${arg} requires a value`);
|
|
if (!/^[A-Za-z0-9._-]+$/u.test(value)) throw new Error(`${arg} must be a simple id`);
|
|
if (arg === "--consumer") consumerId = value;
|
|
else targetId = value;
|
|
index += 1;
|
|
} else if (arg === "--full") {
|
|
full = true;
|
|
} else if (arg === "--raw") {
|
|
raw = true;
|
|
full = true;
|
|
} else if (arg === "--json") {
|
|
json = true;
|
|
} else {
|
|
throw new Error(`unsupported pipelines-as-code option: ${arg}`);
|
|
}
|
|
}
|
|
return { targetId, consumerId, full, raw, json };
|
|
}
|
|
|
|
function closeoutReady(value: Record<string, unknown>, sourceCommit: string | null): boolean {
|
|
if (value.ok !== true) return false;
|
|
const summary = record(value.summary);
|
|
const latest = record(summary.latestPipelineRun);
|
|
const diagnostics = record(summary.diagnostics);
|
|
const observedSourceCommit = observedCloseoutSourceCommit(latest, diagnostics);
|
|
if (sourceCommit === null) return true;
|
|
return observedSourceCommit === sourceCommit;
|
|
}
|
|
|
|
function closeoutCiReady(value: Record<string, unknown>, sourceCommit: string | null): boolean {
|
|
const summary = record(value.summary);
|
|
const latest = record(summary.latestPipelineRun);
|
|
const diagnostics = record(summary.diagnostics);
|
|
const pipelineGate = record(summary.pipelineRunGate);
|
|
if (pipelineGate.ok !== true) return false;
|
|
const observedSourceCommit = observedCloseoutSourceCommit(latest, diagnostics);
|
|
if (sourceCommit !== null && observedSourceCommit !== sourceCommit) return false;
|
|
const sourceObservation = record(summary.sourceObservation);
|
|
if (Object.keys(sourceObservation).length > 0) {
|
|
if (sourceObservation.valid !== true || stringValue(sourceObservation.sourceCommit) !== observedSourceCommit) return false;
|
|
if (stringValue(sourceObservation.contract) === "hwlab-g14-ci-plan" && stringValue(sourceObservation.mode) === "delivery") {
|
|
const deliveryArtifact = record(summary.artifact);
|
|
const catalog = record(deliveryArtifact.catalog);
|
|
const digests = Array.isArray(deliveryArtifact.digests) ? deliveryArtifact.digests : [];
|
|
if (catalog.present !== true
|
|
|| stringValue(catalog.status) !== "published"
|
|
|| catalog.registryVerified !== true
|
|
|| stringValue(catalog.sourceCommit) !== observedSourceCommit
|
|
|| digests.length === 0) return false;
|
|
}
|
|
}
|
|
if (stringValue(diagnostics.code) === "pac-ready-no-runtime-change") return true;
|
|
const artifact = record(summary.artifact);
|
|
if (stringValue(artifact.imageStatus) === "disabled") return true;
|
|
return stringValue(artifact.gitopsCommit) !== "-";
|
|
}
|
|
|
|
function observedCloseoutSourceCommit(latest: Record<string, unknown>, diagnostics: Record<string, unknown>): string {
|
|
return stringValue(latest.sourceCommit) !== "-" ? stringValue(latest.sourceCommit) : stringValue(diagnostics.sourceCommit);
|
|
}
|
|
|
|
function closeoutBlocker(value: Record<string, unknown>, expected: string | null, observed: string, sourceMatched: boolean, gitOpsMirrorFlush: unknown): Record<string, unknown> {
|
|
if (!sourceMatched) {
|
|
return {
|
|
code: "pac-closeout-source-mismatch",
|
|
reason: `expected source commit ${expected ?? "-"} but latest observed source commit is ${observed}`,
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
const summary = record(value.summary);
|
|
const latest = record(summary.latestPipelineRun);
|
|
const pipelineGate = pipelineRunGate(latest);
|
|
if (!pipelineGate.ok) {
|
|
const longestTask = longestTaskRun(arrayRecords(summary.taskRuns));
|
|
const longestTaskText = longestTask === null
|
|
? "longestTask=-"
|
|
: `longestTask=${stringValue(longestTask.name)} status=${statusText(longestTask)} duration=${stringValue(longestTask.durationSeconds)}s`;
|
|
return {
|
|
code: pipelineGate.code,
|
|
reason: `${pipelineGate.reason}; pipelineRun=${stringValue(latest.name)} duration=${stringValue(latest.durationSeconds)}s; ${longestTaskText}`,
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
const flush = record(gitOpsMirrorFlush);
|
|
if (flush.ok === false) {
|
|
const next = record(flush.next);
|
|
return {
|
|
code: stringValue(flush.degradedReason, "pac-closeout-gitops-mirror-flush-failed"),
|
|
reason: `GitOps mirror flush did not complete: mode=${stringValue(flush.mode)} required=${stringValue(flush.required)} next=${stringValue(next.flush ?? next.status)}`,
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
const diagnostics = record(summary.diagnostics);
|
|
return {
|
|
code: stringValue(diagnostics.code, "pac-closeout-not-ready"),
|
|
reason: stringValue(diagnostics.hint, "PaC status is not ready; inspect status/history for the selected consumer"),
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
|
|
|
|
function pipelineRunGate(latest: Record<string, unknown>): Record<string, unknown> {
|
|
const name = stringValue(latest.name);
|
|
if (name === "-") {
|
|
return {
|
|
ok: false,
|
|
code: "pipeline-missing",
|
|
reason: "no matching PipelineRun is visible yet",
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
const status = stringValue(latest.status);
|
|
const reason = stringValue(latest.reason);
|
|
if (status === "True") {
|
|
return {
|
|
ok: true,
|
|
code: "pipeline-succeeded",
|
|
reason: reason === "-" ? "PipelineRun completed successfully" : `PipelineRun ${reason}`,
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
if (status === "False") {
|
|
return {
|
|
ok: false,
|
|
code: "pipeline-failed",
|
|
reason: reason === "-" ? "PipelineRun completed with failure" : `PipelineRun failed: ${reason}`,
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
return {
|
|
ok: false,
|
|
code: "pipeline-running",
|
|
reason: statusText(latest) === "-" ? "PipelineRun is not terminal" : `PipelineRun is ${statusText(latest)}`,
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
function longestTaskRun(taskRuns: Record<string, unknown>[]): Record<string, unknown> | null {
|
|
let selected: Record<string, unknown> | null = null;
|
|
let selectedDuration = -1;
|
|
for (const taskRun of taskRuns) {
|
|
const duration = numericValue(taskRun.durationSeconds);
|
|
if (duration === null || duration < selectedDuration) continue;
|
|
selected = taskRun;
|
|
selectedDuration = duration;
|
|
}
|
|
return selected;
|
|
}
|
|
|
|
function positiveInteger(obj: Record<string, unknown>, key: string, path: string): number {
|
|
const value = y.integerField(obj, key, path);
|
|
if (value < 1) throw new Error(`${configLabel}.${path}.${key} must be positive`);
|
|
return value;
|
|
}
|
|
|
|
function urlField(obj: Record<string, unknown>, key: string, path: string): string {
|
|
const value = y.stringField(obj, key, path);
|
|
const parsed = new URL(value);
|
|
if (!["http:", "https:"].includes(parsed.protocol) || parsed.search || parsed.hash) throw new Error(`${configLabel}.${path}.${key} must be an http(s) URL without query or hash`);
|
|
return value.replace(/\/+$/u, "");
|
|
}
|
|
|
|
function validateTimeZone(value: string, path: string): void {
|
|
try {
|
|
new Intl.DateTimeFormat("en-US", { timeZone: value }).format(new Date(0));
|
|
} catch (error) {
|
|
throw new Error(`${path} must be a valid IANA time zone: ${(error as Error).message}`);
|
|
}
|
|
}
|
|
|
|
function stringRecord(obj: Record<string, unknown>, path: string): Record<string, string> {
|
|
const out: Record<string, string> = {};
|
|
for (const [key, value] of Object.entries(obj)) {
|
|
if (typeof value !== "string" || value.length === 0) throw new Error(`${configLabel}.${path}.${key} must be a non-empty string`);
|
|
out[key] = value;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function secretFingerprint(value: string): string {
|
|
return sha256Fingerprint(value).slice(0, 23);
|
|
}
|
|
|
|
function rendered(result: Record<string, unknown>, command: string, lines: string[]): RenderedCliResult {
|
|
return { ok: result.ok !== false, command, renderedText: lines.join("\n"), contentType: "text/plain" };
|
|
}
|
|
|
|
function record(value: unknown): Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function arrayRecords(value: unknown): Record<string, unknown>[] {
|
|
return Array.isArray(value) ? value.filter((item) => typeof item === "object" && item !== null && !Array.isArray(item)) as Record<string, unknown>[] : [];
|
|
}
|
|
|
|
function stringValue(value: unknown, fallback = "-"): string {
|
|
if (typeof value === "string" && value.length > 0) return value;
|
|
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
return fallback;
|
|
}
|
|
|
|
function numericValue(value: unknown): number | null {
|
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
if (typeof value !== "string" || value.trim().length === 0) return null;
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
}
|
|
|
|
function boolText(value: unknown): string {
|
|
return value === true ? "true" : "false";
|
|
}
|
|
|
|
function short(value: string, length = 12): string {
|
|
if (value === "-" || value.length <= length) return value;
|
|
return value.slice(0, length);
|
|
}
|
|
|
|
function isoShort(value: string): string {
|
|
if (value === "-") return value;
|
|
return value.replace(/:\d\d(?:\.\d+)?Z$/u, "Z");
|
|
}
|
|
|
|
function statusText(row: Record<string, unknown>): string {
|
|
const status = stringValue(row.status);
|
|
const reason = stringValue(row.reason);
|
|
if (status === "-" && reason === "-") return "-";
|
|
if (status === "True" && reason !== "-") return reason;
|
|
if (reason === "-") return status;
|
|
if (status === "-") return reason;
|
|
return `${status}/${reason}`;
|
|
}
|
|
|
|
function envReuseText(envReuse: Record<string, unknown>): string {
|
|
const status = stringValue(envReuse.status);
|
|
const skipped = stringValue(envReuse.buildSkippedCount);
|
|
const reused = stringValue(envReuse.serviceReusedCount);
|
|
const cache = stringValue(envReuse.buildCache);
|
|
const parts = [
|
|
status === "-" ? null : status,
|
|
skipped === "-" ? null : `skip=${skipped}`,
|
|
reused === "-" ? null : `reuse=${reused}`,
|
|
cache === "-" ? null : `cache=${cache}`,
|
|
].filter((item): item is string => item !== null);
|
|
return parts.length === 0 ? "-" : parts.join(",");
|
|
}
|
|
|
|
function stageTimingsText(timings: Record<string, unknown>): string {
|
|
const parts = Object.entries(timings)
|
|
.filter((entry): entry is [string, number] => typeof entry[1] === "number" && Number.isFinite(entry[1]))
|
|
.map(([name, value]) => `${name}=${Math.round(value)}${name.endsWith("Seconds") ? "s" : "ms"}`);
|
|
return parts.length === 0 ? "-" : parts.join(",");
|
|
}
|
|
|
|
function registryText(registry: Record<string, unknown>): string {
|
|
if (Object.keys(registry).length === 0) return "-";
|
|
if (registry.status === "not-configured" || registry.applicability === "not-configured") return "N/A";
|
|
const present = registry.present === true ? "present" : "missing";
|
|
const digest = short(stringValue(registry.digest), 18);
|
|
return digest === "-" ? present : `${present}:${digest}`;
|
|
}
|
|
|
|
function runtimeText(runtime: Record<string, unknown>): string {
|
|
if (Object.keys(runtime).length === 0) return "-";
|
|
const ready = `${stringValue(runtime.readyReplicas)}/${stringValue(runtime.replicas)}`;
|
|
const digest = short(stringValue(runtime.digest), 18);
|
|
return digest === "-" ? ready : `${ready}:${digest}`;
|
|
}
|
|
|
|
function renderHistoryDetail(row: Record<string, unknown>): string[] {
|
|
const envReuse = record(row.envReuse);
|
|
const sourceObservation = record(row.sourceObservation);
|
|
const deliveryPlan = record(sourceObservation.plan);
|
|
const terminalSources = record(sourceObservation.terminalSources);
|
|
const artifact = record(row.artifact);
|
|
const catalog = record(artifact.catalog);
|
|
const collector = record(row.collector);
|
|
const taskRuns = record(row.taskRuns);
|
|
const longest = record(taskRuns.longest);
|
|
const failed = record(taskRuns.failed);
|
|
const failedStep = record(failed.failedStep);
|
|
const firstBreak = record(row.firstBreak);
|
|
const typedError = record(row.error);
|
|
return [
|
|
"",
|
|
"DETAIL",
|
|
...table(["FIELD", "VALUE"], [
|
|
["id", stringValue(row.id ?? row.pipelineRun)],
|
|
["deliveryClass", stringValue(row.deliveryClass)],
|
|
["deliveryAuthorityEligible", stringValue(row.deliveryAuthorityEligible)],
|
|
["deliveryOwner", stringValue(row.deliveryOwner)],
|
|
["executionOwner", stringValue(row.executionOwner)],
|
|
["parentRelation", stringValue(row.parentRelation)],
|
|
["pipeline", stringValue(row.pipeline)],
|
|
["branch", stringValue(row.branch)],
|
|
["commit", stringValue(row.commit)],
|
|
["started", stringValue(row.displayStartTime) === "-" ? stringValue(row.startTime) : stringValue(row.displayStartTime)],
|
|
["completed", stringValue(row.displayCompletionTime) === "-" ? stringValue(row.completionTime) : stringValue(row.displayCompletionTime)],
|
|
["taskRuns", stringValue(taskRuns.count)],
|
|
["longestTask", stringValue(longest.name)],
|
|
["longestTaskDurationS", stringValue(longest.durationSeconds)],
|
|
["failedTask", stringValue(failed.name)],
|
|
["failedStep", stringValue(failedStep.name)],
|
|
["failedStepExit", stringValue(failedStep.exitCode)],
|
|
["failedStepTermination", stringValue(failedStep.terminationReason ?? failedStep.reason)],
|
|
["failure", compactLine(stringValue(failed.message))],
|
|
["traceId", stringValue(row.traceId)],
|
|
["firstBreak", stringValue(firstBreak.code)],
|
|
["firstBreakPhase", stringValue(firstBreak.phase)],
|
|
["errorType", stringValue(typedError.type)],
|
|
["errorCode", stringValue(typedError.code)],
|
|
["errorHttpStatus", stringValue(typedError.httpStatus)],
|
|
["errorExitCode", stringValue(typedError.exitCode)],
|
|
["errorSummary", compactLine(stringValue(typedError.stderrSummary ?? typedError.message))],
|
|
["envReuseSource", stringValue(envReuse.source)],
|
|
["envIdentity", short(stringValue(envReuse.identity), 24)],
|
|
["stageTimings", stageTimingsText(record(envReuse.stageTimings))],
|
|
["deliveryMode", stringValue(sourceObservation.mode)],
|
|
["deliveryValid", stringValue(sourceObservation.valid)],
|
|
["deliverySourceMatched", stringValue(sourceObservation.sourceMatched)],
|
|
["deliveryReason", stringValue(sourceObservation.reason)],
|
|
["affectedServices", stringValue(deliveryPlan.affectedServiceCount)],
|
|
["rolloutServices", stringValue(deliveryPlan.rolloutServiceCount)],
|
|
["buildServices", stringValue(deliveryPlan.buildServiceCount)],
|
|
["reusedServices", stringValue(deliveryPlan.reusedServiceCount)],
|
|
...featureConfigSchemaHistoryFields(sourceObservation.featureConfigSchema),
|
|
]),
|
|
"",
|
|
"TERMINAL EVIDENCE",
|
|
...table(["STEP", "PRESENT", "STATUS", "SOURCE", "MARKER", "TASKRUN"], [
|
|
["plan-artifacts", boolText(record(terminalSources.planArtifacts).present), stringValue(record(terminalSources.planArtifacts).status), stringValue(record(terminalSources.planArtifacts).source), record(terminalSources.planArtifacts).markerPresent === true ? stringValue(record(terminalSources.planArtifacts).markerSource) : "-", short(stringValue(record(terminalSources.planArtifacts).taskRun), 52)],
|
|
["collect-artifacts", boolText(record(terminalSources.collectArtifacts).present), stringValue(record(terminalSources.collectArtifacts).status), stringValue(record(terminalSources.collectArtifacts).source), record(terminalSources.collectArtifacts).markerPresent === true ? stringValue(record(terminalSources.collectArtifacts).markerSource) : "-", short(stringValue(record(terminalSources.collectArtifacts).taskRun), 52)],
|
|
["gitops-promote", boolText(record(terminalSources.gitopsPromote).present), stringValue(record(terminalSources.gitopsPromote).status), stringValue(record(terminalSources.gitopsPromote).source), record(terminalSources.gitopsPromote).markerPresent === true ? stringValue(record(terminalSources.gitopsPromote).markerSource) : "-", short(stringValue(record(terminalSources.gitopsPromote).taskRun), 52)],
|
|
]),
|
|
"",
|
|
"ARTIFACT / COLLECTOR",
|
|
...table(["CATALOG", "VERIFIED", "REUSED", "DIGESTS", "GITOPS", "TASKS", "TERMINALS", "LOG_ERRORS"], [[
|
|
stringValue(catalog.status),
|
|
boolText(catalog.registryVerified),
|
|
stringValue(catalog.reusedCount),
|
|
stringValue(catalog.digestCount),
|
|
short(stringValue(artifact.gitopsCommit), 16),
|
|
stringValue(collector.contractTaskCount),
|
|
stringValue(collector.terminalRecordCount),
|
|
stringValue(collector.logErrorCount),
|
|
]]),
|
|
"",
|
|
"LOGS",
|
|
` ${stringValue(taskRuns.logsCommand)}`,
|
|
];
|
|
}
|
|
|
|
function compactLine(value: string): string {
|
|
const trimmed = value.replace(/\s+/gu, " ").trim();
|
|
return trimmed.length > 0 ? trimmed.slice(0, 120) : "-";
|
|
}
|
|
|
|
function compactResultNames(value: unknown): string {
|
|
if (!Array.isArray(value) || value.length === 0) return "-";
|
|
const names = value.filter((item): item is string => typeof item === "string");
|
|
if (names.length <= 2) return names.join(",");
|
|
return `${names.length}:${names.slice(0, 2).join(",")},...`;
|
|
}
|
|
|
|
function table(headers: string[], rows: string[][]): string[] {
|
|
const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => (row[index] ?? "").length)));
|
|
const format = (row: string[]) => row.map((cell, index) => cell.padEnd(widths[index])).join(" ");
|
|
return [format(headers), format(headers.map((header, index) => "-".repeat(Math.max(header.length, widths[index])))), ...rows.map(format)];
|
|
}
|