import { createHash, 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"; 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 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; } 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; }; release: { version: string; manifestUrl: string; namespace: string; controllerServiceName: string; controllerServicePort: number; waitTimeoutSeconds: 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[]; } 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; } interface PacConsumer { id: string; node: string; lane: string; namespace: string; pipeline: string; pipelineRunPrefix: string; argoNamespace: string; argoApplication: string; repositoryRef: string; } interface CommonOptions { targetId: string | null; consumerId: string | null; full: boolean; raw: boolean; } interface HistoryOptions extends CommonOptions { limit: number; detailId: string | null; } interface CloseoutOptions extends CommonOptions { sourceCommit: string | null; wait: boolean; } interface ApplyOptions extends CommonOptions { confirm: boolean; dryRun: boolean; wait: boolean; } interface WebhookTestOptions extends CommonOptions { confirm: boolean; } interface SecretMaterial { adminUsername: string; adminPassword: string; adminFingerprint: string; webhookSecret: string; webhookFingerprint: string; webhookPath: string; } export async function runPlatformInfraPipelinesAsCodeCommand(config: UniDeskConfig, args: string[]): Promise | RenderedCliResult> { const [action = "plan"] = args; if (action === "help" || action === "--help") return help(); if (action === "plan") { const options = parseCommonOptions(args.slice(1)); const result = plan(options); return options.full || options.raw ? result : renderPlan(result); } if (action === "apply") { const options = parseApplyOptions(args.slice(1)); const result = await apply(config, options); return options.full || options.raw ? result : renderApply(result); } if (action === "status") { const options = parseCommonOptions(args.slice(1)); const result = await status(config, options); return options.full || options.raw ? result : renderStatus(result); } if (action === "closeout") { const options = parseCloseoutOptions(args.slice(1)); const result = await closeout(config, options); return options.full || options.raw ? result : renderCloseout(result); } if (action === "history" || action === "runs") { const options = parseHistoryOptions(args.slice(1)); const result = await history(config, options); return options.full || options.raw ? result : renderHistory(result); } if (action === "webhook-test") { const options = parseWebhookTestOptions(args.slice(1)); const result = await webhookTest(config, options); return options.full || options.raw ? result : renderWebhookTest(result); } return { ok: false, error: "unsupported-platform-infra-pipelines-as-code-command", args, help: help() }; } function help(): Record { return { command: "platform-infra pipelines-as-code plan|apply|status|closeout|history|webhook-test", configTruth: configLabel, usage: [ "bun scripts/cli.ts platform-infra pipelines-as-code plan --target JD01", "bun scripts/cli.ts platform-infra pipelines-as-code apply --target JD01 --dry-run", "bun scripts/cli.ts platform-infra pipelines-as-code apply --target JD01 --confirm", "bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01 [--full|--raw]", "bun scripts/cli.ts platform-infra pipelines-as-code closeout --target JD01 --consumer --source-commit --wait", "bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 [--consumer hwlab-jd01-v03] [--limit 10]", "bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --id ", "bun scripts/cli.ts platform-infra pipelines-as-code webhook-test --target JD01 --confirm", ], boundary: "Sole CI trigger path for GH-1552: Gitea webhook -> Pipelines-as-Code -> Tekton -> Argo/k8s runtime.", }; } function readPacConfig(): PacConfig { const root = readYamlRecord>(configFile, "platform-infra-pipelines-as-code"); const release = y.objectField(root, "release", ""); const gitea = y.objectField(root, "gitea", ""); const display = y.objectField(root, "display", ""); const admin = y.objectField(gitea, "admin", "gitea"); const webhook = y.objectField(gitea, "webhook", "gitea"); const repositories = root.repositories === undefined ? [parseRepository(y.objectField(root, "repository", ""), "repository")] : y.arrayOfRecords(root.repositories, "repositories").map((item, index) => parseRepository(item, `repositories[${index}]`)); const consumers = root.consumers === undefined ? [parseConsumer(y.objectField(root, "consumer", ""), "consumer", repositories[0]?.id ?? "default")] : y.arrayOfRecords(root.consumers, "consumers").map((item, index) => parseConsumer(item, `consumers[${index}]`, repositories[0]?.id ?? "default")); const defaults = y.objectField(root, "defaults", ""); 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: typeof defaults.consumerId === "string" && defaults.consumerId.length > 0 ? defaults.consumerId : consumers[0]?.id ?? "default", }, display: { timeZone: y.stringField(display, "timeZone", "display"), }, 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"), }, 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, consumers, }; validateConfig(parsed); return parsed; } function parseRepository(repository: Record, 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, 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)}`; 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), repositoryRef: typeof consumer.repositoryRef === "string" && consumer.repositoryRef.length > 0 ? consumer.repositoryRef : defaultRepositoryRef, }; } function parseTarget(record: Record, 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), }; } function validateConfig(config: PacConfig): void { 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`); resolveConsumer(config, config.defaults.consumerId); const ids = new Set(); for (const repository of config.repositories) { if (ids.has(repository.id)) throw new Error(`${configLabel}.repositories id must be unique: ${repository.id}`); ids.add(repository.id); if (repository.providerType !== "gitea") throw new Error(`${configLabel}.repositories.${repository.id}.providerType must be gitea`); 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`); } const consumerIds = new Set(); for (const consumer of config.consumers) { if (consumerIds.has(consumer.id)) throw new Error(`${configLabel}.consumers id must be unique: ${consumer.id}`); consumerIds.add(consumer.id); const repository = resolveRepository(config, consumer.repositoryRef); if (repository.namespace !== consumer.namespace) throw new Error(`${configLabel}.consumers.${consumer.id}.namespace must match repository namespace`); } if (!config.gitea.webhook.events.includes("push")) throw new Error(`${configLabel}.gitea.webhook.events must include push`); validateTimeZone(config.display.timeZone, `${configLabel}.display.timeZone`); } 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 consumer = config.consumers.find((item) => item.id.toLowerCase() === id.toLowerCase()); if (consumer === undefined) throw new Error(`unknown Pipelines-as-Code consumer ${id}; known consumers: ${config.consumers.map((item) => item.id).join(", ")}`); return consumer; } function resolveRepository(config: PacConfig, repositoryRef: string): PacRepository { const repository = config.repositories.find((item) => item.id === repositoryRef || item.name === repositoryRef); if (repository === undefined) throw new Error(`unknown Pipelines-as-Code repository ${repositoryRef}; known repositories: ${config.repositories.map((item) => item.id).join(", ")}`); return repository; } function plan(options: CommonOptions): Record { const pac = readPacConfig(); 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, secrets, policy: policyChecks(repository), next: nextCommands(target.id, consumer.id, pac.defaults.consumerId), }; } async function apply(config: UniDeskConfig, options: ApplyOptions): Promise> { const pac = readPacConfig(); 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, 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, secrets: secretSummaries(pac), remote: parsed ?? compactCapture(result, { full: true }), next: nextCommands(target.id, consumer.id, pac.defaults.consumerId), }; } async function status(config: UniDeskConfig, options: CommonOptions): Promise> { const pac = readPacConfig(); 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); return { ok: result.exitCode === 0 && summary?.ready === true, action: "platform-infra-pipelines-as-code-status", mutation: false, target: targetSummary(target), config: compactConfigSummary(pac, consumer, repository), consumer, coverage: consumerCoverage(pac, target.id), summary, remote: parsed === null ? compactCapture(result, { full: true }) : options.raw || options.full ? parsed : summary, next: nextCommands(target.id, consumer.id, pac.defaults.consumerId), }; } async function closeout(config: UniDeskConfig, options: CloseoutOptions): Promise> { const pac = readPacConfig(); const target = resolveTarget(pac, options.targetId); const consumer = resolveConsumer(pac, options.consumerId); const startedAt = Date.now(); const waitMs = Math.max(1, pac.release.waitTimeoutSeconds) * 1000; let attempts = 0; let current = await status(config, options); attempts += 1; while (options.wait && !closeoutReady(current, options.sourceCommit) && Date.now() - startedAt < waitMs) { await sleep(3000); current = await status(config, options); attempts += 1; } const summary = record(current.summary); const latest = record(summary.latestPipelineRun); const diagnostics = record(summary.diagnostics); const observedSourceCommit = stringValue(latest.sourceCommit) !== "-" ? stringValue(latest.sourceCommit) : stringValue(diagnostics.sourceCommit); const sourceMatched = options.sourceCommit === null || observedSourceCommit === options.sourceCommit; const ready = current.ok === true && sourceMatched; return { ok: ready, action: "platform-infra-pipelines-as-code-closeout", mutation: false, target: targetSummary(target), consumer, sourceCommit: options.sourceCommit, observedSourceCommit, sourceMatched, ready, wait: { requested: options.wait, attempts, elapsedMs: Date.now() - startedAt, budgetSeconds: pac.release.waitTimeoutSeconds, source: `${configLabel}.release.waitTimeoutSeconds`, valuesPrinted: false, }, summary, status: current, blocker: ready ? null : closeoutBlocker(current, options.sourceCommit, observedSourceCommit, sourceMatched), next: nextCommands(target.id, consumer.id, pac.defaults.consumerId), valuesPrinted: false, }; } async function history(config: UniDeskConfig, options: HistoryOptions): Promise> { const pac = readPacConfig(); const target = resolveTarget(pac, options.targetId); const selectedConsumers = options.consumerId === null ? pac.consumers : [resolveConsumer(pac, options.consumerId)]; 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); 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: arrayRecords(record(remote).rows), historyErrors, remote, next: nextCommands(target.id, options.consumerId ?? pac.defaults.consumerId, pac.defaults.consumerId), }; } async function webhookTest(config: UniDeskConfig, options: WebhookTestOptions): Promise> { const pac = readPacConfig(); const target = resolveTarget(pac, options.targetId); const consumer = resolveConsumer(pac, options.consumerId); const repository = resolveRepository(pac, consumer.repositoryRef); if (!options.confirm) return { ok: false, action: "platform-infra-pipelines-as-code-webhook-test", mutation: false, mode: "missing-confirm", error: "webhook-test requires --confirm" }; const secrets = ensureSecrets(pac, false); const result = await capture(config, target.route, ["sh"], remoteScript("webhook-test", pac, target, repository, consumer, { ...options, dryRun: false, wait: false }, secrets, "")); const parsed = parseJsonOutput(result.stdout); return { ok: result.exitCode === 0 && parsed?.ok === true, action: "platform-infra-pipelines-as-code-webhook-test", mutation: true, target: targetSummary(target), consumer, remote: parsed ?? compactCapture(result, { full: true }), next: nextCommands(target.id, consumer.id, pac.defaults.consumerId), }; } async function fetchReleaseManifest(pac: PacConfig): Promise { 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" | "webhook-test", pac: PacConfig, target: PacTarget, repository: PacRepository, consumer: PacConsumer, options: ApplyOptions | WebhookTestOptions | 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 env: Record = { UNIDESK_PAC_ACTION: action, 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_PARAMS_JSON: JSON.stringify(repository.params), UNIDESK_PAC_PIPELINE_NAME: consumer.pipeline, UNIDESK_PAC_PIPELINE_RUN_PREFIX: consumer.pipelineRunPrefix, 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_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 historyConsumerConfig(pac: PacConfig, consumer: PacConsumer): Record { const repository = resolveRepository(pac, consumer.repositoryRef); return { id: consumer.id, namespace: consumer.namespace, pipeline: consumer.pipeline, pipelineRunPrefix: consumer.pipelineRunPrefix, repository: repository.name, repo: `${repository.owner}/${repository.repo}`, repoUrl: repository.url, }; } function credentialPath(root: string, sourceRef: string): string { return sourceRef.startsWith("/") ? sourceRef : join(root, sourceRef); } function pacPartOf(consumerId: string): string { if (consumerId.startsWith("sentinel")) return "hwlab-web-probe-sentinel"; if (consumerId.startsWith("hwlab-")) return "hwlab"; return "agentrun"; } 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 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> { 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): Record { 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 artifact = { ...record(payload.artifact), imageStatus: record(payload.artifact).imageStatus ?? (runtime.image === undefined ? undefined : "runtime"), digest: record(payload.artifact).digest ?? runtime.digest, gitopsCommit: record(payload.artifact).gitopsCommit ?? argo.revision, }; const pipelineGate = pipelineRunGate(latest); return { ready: payload.crdPresent === true && String(payload.controllerReady ?? "0/0") !== "0/0" && pipelineGate.ok && diagnostics.ok !== false, crdPresent: payload.crdPresent === true, controllerReady: payload.controllerReady, repositoryCondition: payload.repositoryCondition, webhookCount: arrayRecords(payload.webhooks).length, latestPipelineRun: latest, taskRuns, pipelineRunGate: pipelineGate, artifact, argo, runtime, diagnostics, valuesPrinted: false, }; } function policyChecks(repository: PacRepository): Array> { 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: "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 targetSummary(target: PacTarget): Record { return { id: target.id, route: target.route, namespace: target.namespace, role: target.role }; } function configSummary(pac: PacConfig, consumer: PacConsumer, repository: PacRepository): Record { return { path: configLabel, metadata: pac.metadata, release: pac.release, 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, repository: repositorySummary(repository), consumer, valuesPrinted: false, }; } function compactConfigSummary(pac: PacConfig, consumer: PacConsumer, repository: PacRepository): Record { return { path: configLabel, releaseVersion: pac.release.version, repository: repository.name, providerType: repository.providerType, sourceUrl: repository.cloneUrl, displayTimeZone: pac.display.timeZone, consumer: `${consumer.node}/${consumer.lane}`, consumerId: consumer.id, pipeline: consumer.pipeline, valuesPrinted: false, }; } function repositorySummary(repository: PacRepository): Record { 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 consumerCoverage(pac: PacConfig, targetId: string): Record[] { return pac.consumers.map((consumer) => { const repository = resolveRepository(pac, consumer.repositoryRef); const suffix = consumerSuffix(consumer.id, pac.defaults.consumerId); return { consumer: consumer.id, source: `${repository.owner}/${repository.repo}@${repository.params.source_branch ?? "-"}`, namespace: consumer.namespace, pipeline: consumer.pipeline, argoApplication: consumer.argoApplication, statusCommand: `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${targetId}${suffix}`, historyCommand: `bun scripts/cli.ts platform-infra pipelines-as-code history --target ${targetId}${suffix}`, }; }); } function consumerSuffix(consumerId: string, defaultConsumerId: string): string { return consumerId === defaultConsumerId ? "" : ` --consumer ${consumerId}`; } function nextCommands(targetId: string, consumerId: string, defaultConsumerId: string): Record { const suffix = consumerSuffix(consumerId, defaultConsumerId); return { apply: `bun scripts/cli.ts platform-infra pipelines-as-code apply --target ${targetId}${suffix} --confirm`, closeout: `bun scripts/cli.ts platform-infra pipelines-as-code closeout --target ${targetId}${suffix} --wait`, status: `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${targetId}${suffix}`, history: `bun scripts/cli.ts platform-infra pipelines-as-code history --target ${targetId}${suffix}`, webhookTest: `bun scripts/cli.ts platform-infra pipelines-as-code webhook-test --target ${targetId}${suffix} --confirm`, }; } function renderPlan(result: Record): RenderedCliResult { const target = record(result.target); const config = record(result.config); const repository = record(result.repository); const consumer = record(result.consumer); const secrets = arrayRecords(result.secrets); const policy = arrayRecords(result.policy); 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)])), "", "NEXT", ` apply: ${stringValue(record(result.next).apply)}`, ` status: ${stringValue(record(result.next).status)}`, ]; return rendered(result, "platform-infra pipelines-as-code plan", lines); } function renderApply(result: Record): RenderedCliResult { const target = record(result.target); const remote = record(result.remote); 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", "VALUES"], [[stringValue(remote.controllerReady), stringValue(record(remote.webhook).present), stringValue(remote.repository), "false"]]), "", "NEXT", ` status: ${stringValue(record(result.next).status)}`, ]; return rendered(result, "platform-infra pipelines-as-code apply", lines); } function renderStatus(result: Record): RenderedCliResult { const summary = record(result.summary); const consumer = record(result.consumer); const coverage = arrayRecords(result.coverage); const latest = record(summary.latestPipelineRun); const taskRuns = arrayRecords(summary.taskRuns); const artifact = record(summary.artifact); const argo = record(summary.argo); const diagnostics = record(summary.diagnostics); const lines = [ "PLATFORM-INFRA PIPELINES-AS-CODE STATUS", ...(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", "CRD", "CONTROLLER", "WEBHOOKS", "REPOSITORY"], [[stringValue(consumer.id), boolText(summary.ready), boolText(summary.crdPresent), stringValue(summary.controllerReady), stringValue(summary.webhookCount), compactLine(stringValue(summary.repositoryCondition))]]), "", "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))]]), "", "TASKRUN DURATIONS", ...(taskRuns.length === 0 ? ["-"] : table(["TASKRUN", "STATUS", "REASON", "DURATION_S"], taskRuns.map((item) => [short(stringValue(item.name), 56), stringValue(item.status), stringValue(item.reason), 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))]]), "", "ARGO", ...table(["SYNC", "HEALTH", "REVISION"], [[stringValue(argo.sync), stringValue(argo.health), short(stringValue(argo.revision))]]), "", "CICD DIAGNOSIS", ...table(["OK", "CODE", "PHASE", "SOURCE", "REGISTRY", "GITOPS", "RUNTIME"], [[ boolText(diagnostics.ok !== false), stringValue(diagnostics.code), stringValue(diagnostics.phase), 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", ` closeout: ${stringValue(record(result.next).closeout)}`, ` full: ${stringValue(record(result.next).status)} --full`, ` all-history: ${stringValue(record(result.next).history).replace(/ --consumer [^ ]+/u, "")} --limit 10`, ]; return rendered(result, "platform-infra pipelines-as-code status", lines); } function renderCloseout(result: Record): 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 blocker = record(result.blocker); 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", "ATTEMPTS", "ELAPSED_MS", "BUDGET_S", "SOURCE"], [[stringValue(wait.requested), stringValue(wait.attempts), stringValue(wait.elapsedMs), stringValue(wait.budgetSeconds), stringValue(wait.source)]]), "", "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)]]), "", "RUNTIME", ...table(["IMAGE_STATUS", "ENV_REUSE", "DIGEST", "GITOPS", "ARGO", "RUNTIME"], [[stringValue(artifact.imageStatus), stringValue(artifact.envReuse), short(stringValue(artifact.digest), 18), short(stringValue(artifact.gitopsCommit), 12), `${stringValue(argo.sync)}/${stringValue(argo.health)}`, runtimeText(runtime)]]), "", "CICD DIAGNOSIS", ...table(["OK", "CODE", "PHASE", "HINT"], [[boolText(diagnostics.ok !== false), stringValue(diagnostics.code), stringValue(diagnostics.phase), compactLine(stringValue(diagnostics.hint))]]), "", Object.keys(blocker).length === 0 ? "BLOCKER\n-" : ["BLOCKER", table(["CODE", "REASON"], [[stringValue(blocker.code), compactLine(stringValue(blocker.reason))]])].join("\n"), "", "NEXT", ` status: ${stringValue(record(result.next).status)}`, ` history: ${stringValue(record(result.next).history)} --limit 10`, 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); } function renderHistory(result: Record): RenderedCliResult { const rows = arrayRecords(result.rows); const config = record(result.config); const historyErrors = arrayRecords(result.historyErrors); 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)}`, ...(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", "REPO", "STATUS", "DUR_S", "COMMIT", "ENV_REUSE", "ID"], rows.map((row) => [ stringValue(row.displayTime) === "-" ? isoShort(stringValue(row.triggeredAt)) : stringValue(row.displayTime), stringValue(row.consumer), stringValue(row.repo), statusText(row), 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)); } function renderWebhookTest(result: Record): RenderedCliResult { const remote = record(result.remote); const lines = [ "PLATFORM-INFRA PIPELINES-AS-CODE WEBHOOK TEST", ...table(["OK", "MUTATION", "HOOK", "SENT"], [[boolText(result.ok), boolText(result.mutation), stringValue(remote.hookId), boolText(remote.webhookTestSent)]]), "", "NEXT", ` status: ${stringValue(record(result.next).status)}`, ]; return rendered(result, "platform-infra pipelines-as-code webhook-test", 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 parseWebhookTestOptions(args: string[]): WebhookTestOptions { const commonArgs: string[] = []; let confirm = false; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === "--confirm") confirm = true; else { commonArgs.push(arg); if (arg === "--target" || arg === "--node" || arg === "--consumer") { commonArgs.push(args[index + 1] ?? ""); index += 1; } } } return { ...parseCommonOptions(commonArgs), confirm }; } function parseCloseoutOptions(args: string[]): CloseoutOptions { const commonArgs: string[] = []; let sourceCommit: string | null = null; let wait = false; 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 { commonArgs.push(arg); if (arg === "--target" || arg === "--node" || arg === "--consumer") { commonArgs.push(args[index + 1] ?? ""); index += 1; } } } return { ...parseCommonOptions(commonArgs), sourceCommit, wait }; } 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; 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 { throw new Error(`unsupported pipelines-as-code option: ${arg}`); } } return { targetId, consumerId, full, raw }; } function closeoutReady(value: Record, sourceCommit: string | null): boolean { if (value.ok !== true) return false; if (sourceCommit === null) return true; const summary = record(value.summary); const latest = record(summary.latestPipelineRun); const diagnostics = record(summary.diagnostics); const observedSourceCommit = stringValue(latest.sourceCommit) !== "-" ? stringValue(latest.sourceCommit) : stringValue(diagnostics.sourceCommit); return observedSourceCommit === sourceCommit; } function closeoutBlocker(value: Record, expected: string | null, observed: string, sourceMatched: boolean): Record { 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 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 sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } function pipelineRunGate(latest: Record): Record { 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[]): Record | null { let selected: Record | 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, 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, 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, path: string): Record { const out: Record = {}; 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, command: string, lines: string[]): RenderedCliResult { return { ok: result.ok !== false, command, renderedText: lines.join("\n"), contentType: "text/plain" }; } function record(value: unknown): Record { return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; } function arrayRecords(value: unknown): Record[] { return Array.isArray(value) ? value.filter((item) => typeof item === "object" && item !== null && !Array.isArray(item)) as Record[] : []; } 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 { 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 { 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 registryText(registry: Record): string { if (Object.keys(registry).length === 0) return "-"; const present = registry.present === true ? "present" : "missing"; const digest = short(stringValue(registry.digest), 18); return digest === "-" ? present : `${present}:${digest}`; } function runtimeText(runtime: Record): 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[] { const envReuse = record(row.envReuse); const taskRuns = record(row.taskRuns); const longest = record(taskRuns.longest); return [ "", "DETAIL", ...table(["FIELD", "VALUE"], [ ["id", stringValue(row.id ?? row.pipelineRun)], ["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)], ["envReuseSource", stringValue(envReuse.source)], ]), ]; } function compactLine(value: string): string { const trimmed = value.replace(/\s+/gu, " ").trim(); return trimmed.length > 0 ? trimmed.slice(0, 120) : "-"; } 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)]; }