1471 lines
64 KiB
TypeScript
1471 lines
64 KiB
TypeScript
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 { parseNodeScopedDelegatedOptions } from "./hwlab-node/plan";
|
|
import { nodeRuntimeEnsureGitMirrorFlushed } from "./hwlab-node/status";
|
|
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;
|
|
};
|
|
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[];
|
|
}
|
|
|
|
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;
|
|
repositoryRef: string;
|
|
closeoutGitOpsMirrorFlush: boolean;
|
|
}
|
|
|
|
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<Record<string, unknown> | 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<string, unknown> {
|
|
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 <consumer> --source-commit <sha> --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 <pipelinerun>",
|
|
"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<Record<string, unknown>>(configFile, "platform-infra-pipelines-as-code");
|
|
const release = y.objectField(root, "release", "");
|
|
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 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"),
|
|
},
|
|
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,
|
|
consumers,
|
|
};
|
|
validateConfig(parsed);
|
|
return parsed;
|
|
}
|
|
|
|
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)}`;
|
|
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,
|
|
closeoutGitOpsMirrorFlush: consumer.closeoutGitOpsMirrorFlush === undefined ? false : y.booleanField(consumer, "closeoutGitOpsMirrorFlush", path),
|
|
};
|
|
}
|
|
|
|
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),
|
|
};
|
|
}
|
|
|
|
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`);
|
|
if (config.closeout.gitOpsMirrorFlush.waitTimeoutSeconds > 55) throw new Error(`${configLabel}.closeout.gitOpsMirrorFlush.waitTimeoutSeconds must fit the 60s trans budget`);
|
|
resolveConsumer(config, config.defaults.consumerId);
|
|
const ids = new Set<string>();
|
|
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<string>();
|
|
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<string, unknown> {
|
|
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<Record<string, unknown>> {
|
|
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<Record<string, unknown>> {
|
|
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<Record<string, unknown>> {
|
|
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 baseReady = current.ok === true && sourceMatched;
|
|
const gitOpsMirrorFlush = runCloseoutGitOpsMirrorFlush(pac, consumer, latest, baseReady, observedSourceCommit);
|
|
const gitOpsMirrorFlushReady = record(gitOpsMirrorFlush).ok !== false;
|
|
const ready = baseReady && gitOpsMirrorFlushReady;
|
|
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,
|
|
gitOpsMirrorFlush,
|
|
blocker: ready ? null : closeoutBlocker(current, options.sourceCommit, observedSourceCommit, sourceMatched, gitOpsMirrorFlush),
|
|
next: nextCommands(target.id, consumer.id, pac.defaults.consumerId),
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
async function history(config: UniDeskConfig, options: HistoryOptions): Promise<Record<string, unknown>> {
|
|
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<Record<string, unknown>> {
|
|
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<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" | "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<string, string> = {
|
|
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<string, unknown> {
|
|
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<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 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<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: "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<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,
|
|
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<string, unknown> {
|
|
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<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 consumerCoverage(pac: PacConfig, targetId: string): Record<string, unknown>[] {
|
|
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<string, string> {
|
|
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<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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<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 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", "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))]]),
|
|
"",
|
|
"GITOPS MIRROR FLUSH",
|
|
...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`,
|
|
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<string, unknown>): 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<string, unknown>): 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<string, unknown>, 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<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 sleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function runCloseoutGitOpsMirrorFlush(pac: PacConfig, consumer: PacConsumer, latest: Record<string, unknown>, baseReady: boolean, observedSourceCommit: string): Record<string, unknown> {
|
|
const cfg = pac.closeout.gitOpsMirrorFlush;
|
|
const configSource = `${configLabel}.closeout.gitOpsMirrorFlush`;
|
|
const required = cfg.enabled && consumer.closeoutGitOpsMirrorFlush;
|
|
if (!required) {
|
|
return {
|
|
ok: true,
|
|
enabled: cfg.enabled,
|
|
required: false,
|
|
mode: cfg.enabled ? "consumer-not-enabled" : "disabled",
|
|
executed: false,
|
|
configSource,
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
if (!baseReady) {
|
|
return {
|
|
ok: true,
|
|
enabled: true,
|
|
required: true,
|
|
mode: "waiting-for-pac-ready",
|
|
executed: false,
|
|
configSource,
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
const pipelineRun = stringValue(latest.name);
|
|
if (observedSourceCommit === "-" || pipelineRun === "-") {
|
|
return {
|
|
ok: false,
|
|
enabled: true,
|
|
required: true,
|
|
mode: "missing-closeout-context",
|
|
executed: false,
|
|
configSource,
|
|
degradedReason: "pac-closeout-gitops-mirror-context-missing",
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
const deadlineMs = Date.now() + (cfg.waitTimeoutSeconds * 1000);
|
|
const scoped = parseNodeScopedDelegatedOptions("git-mirror", [
|
|
"flush",
|
|
"--node",
|
|
consumer.node,
|
|
"--lane",
|
|
consumer.lane,
|
|
"--confirm",
|
|
"--wait",
|
|
"--timeout-seconds",
|
|
String(cfg.waitTimeoutSeconds),
|
|
]);
|
|
const progressLines: string[] = [];
|
|
const flush = captureStderrLines(progressLines, () => nodeRuntimeEnsureGitMirrorFlushed(scoped, "post", observedSourceCommit, pipelineRun, null, {
|
|
deadlineMs,
|
|
maxAttempts: cfg.maxAttempts,
|
|
}));
|
|
return {
|
|
ok: flush.ok === true,
|
|
enabled: true,
|
|
required: true,
|
|
mode: stringValue(flush.mode, flush.ok === true ? "flushed" : "failed"),
|
|
executed: flush.executed === true,
|
|
sourceCommit: observedSourceCommit,
|
|
pipelineRun,
|
|
configSource,
|
|
waitTimeoutSeconds: cfg.waitTimeoutSeconds,
|
|
maxAttempts: cfg.maxAttempts,
|
|
beforeSummary: flush.beforeSummary ?? null,
|
|
afterSummary: flush.afterSummary ?? null,
|
|
jobName: flush.jobName ?? null,
|
|
degradedReason: flush.degradedReason ?? undefined,
|
|
next: flush.next ?? undefined,
|
|
flush,
|
|
progressLines,
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
function captureStderrLines<T>(lines: string[], callback: () => T): T {
|
|
const stderr = process.stderr as typeof process.stderr & { write: (...args: unknown[]) => boolean };
|
|
const originalWrite = stderr.write.bind(process.stderr);
|
|
stderr.write = ((chunk: unknown, ...args: unknown[]): boolean => {
|
|
lines.push(Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk));
|
|
const callbackArg = args.find((arg) => typeof arg === "function") as (() => void) | undefined;
|
|
callbackArg?.();
|
|
return true;
|
|
}) as typeof stderr.write;
|
|
try {
|
|
return callback();
|
|
} finally {
|
|
stderr.write = originalWrite as typeof stderr.write;
|
|
}
|
|
}
|
|
|
|
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 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 registryText(registry: Record<string, unknown>): 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, 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 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)];
|
|
}
|