fix(cicd): add NC01 PaC status closeout

This commit is contained in:
Codex
2026-07-08 19:55:43 +02:00
parent 1ae53ffc4d
commit e3951035f3
16 changed files with 644 additions and 37 deletions
+1 -1
View File
@@ -270,7 +270,7 @@ async function main(): Promise<void> {
if (top === "cicd") {
const { runCicdCommand } = await import("./src/cicd");
const result = await runCicdCommand(null, args.slice(1));
const result = await runCicdCommand(readConfig(), args.slice(1));
const ok = (result as { ok?: unknown }).ok !== false;
if (isRenderedCliResult(result)) {
emitText(result.renderedText, result.command || commandName);
+3 -2
View File
@@ -649,8 +649,9 @@ function agentRunManagerManifests(spec: AgentRunLaneSpec, sourceCommit: string,
imagePullPolicy: "IfNotPresent",
ports: [{ name: "http", containerPort: 8080 }],
env: managerEnv(spec, sourceCommit, imageRef, image.envIdentity),
readinessProbe: { httpGet: { path: "/health/readiness", port: "http" } },
livenessProbe: { httpGet: { path: "/health/live", port: "http" } },
readinessProbe: { httpGet: { path: "/health/readiness", port: "http" }, timeoutSeconds: 2 },
livenessProbe: { httpGet: { path: "/health/live", port: "http" }, timeoutSeconds: 2 },
startupProbe: { httpGet: { path: "/health/live", port: "http" }, periodSeconds: 10, timeoutSeconds: 2, failureThreshold: 90 },
resources: spec.deployment.manager.resources,
},
],
+8
View File
@@ -378,6 +378,7 @@ export async function statusPipelinesAsCodeLane(config: UniDeskConfig, options:
...(expectedGitopsRevision === null || argoSyncedToGitops ? [] : ["argo-revision-stale"]),
...(runtimePayload.runtimeNamespaceExists === true ? [] : ["runtime-namespace-missing"]),
...(manager.deploymentExists === true ? [] : ["manager-deployment-missing"]),
...(manager.deploymentExists === true && manager.ready !== true ? ["manager-not-ready"] : []),
...(manager.deploymentExists !== true || sourceCommit === null || managerSourceMatchesExpected ? [] : ["manager-source-stale"]),
...(manager.serviceExists === true ? [] : ["manager-service-missing"]),
...(spec.database.mode === "external-postgres" && database.secretPresent !== true ? ["database-secret-missing"] : []),
@@ -446,9 +447,12 @@ export async function statusPipelinesAsCodeLane(config: UniDeskConfig, options:
namespaceExists: runtimePayload.runtimeNamespaceExists ?? false,
manager: {
deploymentExists: manager.deploymentExists ?? false,
ready: manager.ready ?? false,
serviceExists: manager.serviceExists ?? false,
sourceCommit: stringOrNull(manager.sourceCommit),
sourceMatchesExpected: managerSourceMatchesExpected,
replicas: manager.replicas ?? null,
conditions: manager.conditions ?? [],
envIdentity: manager.envIdentity ?? null,
},
databaseSecretPresent: database.secretPresent ?? null,
@@ -590,6 +594,7 @@ export async function statusYamlLane(config: UniDeskConfig, options: StatusOptio
...(argo.exists !== true || expectedGitopsRevision === null || argoSyncedToGitops ? [] : ["argo-revision-stale"]),
...(runtimePayload.runtimeNamespaceExists === true ? [] : ["runtime-namespace-missing"]),
...(manager.deploymentExists === true ? [] : ["manager-deployment-missing"]),
...(manager.deploymentExists === true && manager.ready !== true ? ["manager-not-ready"] : []),
...(manager.deploymentExists !== true || sourceCommit === null || managerSourceMatchesExpected ? [] : ["manager-source-stale"]),
...(manager.serviceExists === true ? [] : ["manager-service-missing"]),
...(spec.database.mode === "external-postgres" && database.secretPresent !== true ? ["database-secret-missing"] : []),
@@ -678,9 +683,12 @@ export async function statusYamlLane(config: UniDeskConfig, options: StatusOptio
namespaceExists: runtimePayload.runtimeNamespaceExists ?? false,
manager: {
deploymentExists: manager.deploymentExists ?? false,
ready: manager.ready ?? false,
serviceExists: manager.serviceExists ?? false,
sourceCommit: stringOrNull(manager.sourceCommit),
sourceMatchesExpected: managerSourceMatchesExpected,
replicas: manager.replicas ?? null,
conditions: manager.conditions ?? [],
},
databaseSecretPresent: database.secretPresent ?? null,
secretsReady: secrets.ready ?? null,
+6 -2
View File
@@ -43,7 +43,7 @@ import { agentRunGetKindHelp, runAgentRunResourceCommand } from "./resource-acti
import { runAgentRunRestCompatCommand, runGitMirrorJob, startAsyncAgentRunJob } from "./rest-bridge";
import { exposeAgentRun, restartYamlLane, secretSync, triggerCurrent } from "./trigger";
import { unsupported } from "./utils";
import { cleanupReleasedPvs, cleanupRunners, cleanupRuns, cleanupSessionPvcs, refresh } from "./yaml-lane";
import { cleanupLocalPostgres, cleanupReleasedPvs, cleanupRunners, cleanupRuns, cleanupSessionPvcs, refresh } from "./yaml-lane";
export function agentRunHelp(): unknown {
return {
@@ -92,6 +92,8 @@ export function agentRunHelp(): unknown {
"bun scripts/cli.ts agentrun control-plane cleanup-runs --min-age-minutes 30 --limit 200 --confirm",
"bun scripts/cli.ts agentrun control-plane cleanup-released-pvs --limit 200 --dry-run",
"bun scripts/cli.ts agentrun control-plane cleanup-released-pvs --limit 200 --confirm",
"bun scripts/cli.ts agentrun control-plane cleanup-local-postgres --node NC01 --lane nc01-v02 --dry-run",
"bun scripts/cli.ts agentrun control-plane cleanup-local-postgres --node NC01 --lane nc01-v02 --confirm",
"bun scripts/cli.ts agentrun git-mirror status",
"bun scripts/cli.ts agentrun git-mirror status --full",
"bun scripts/cli.ts agentrun git-mirror sync --confirm",
@@ -147,6 +149,7 @@ export async function runAgentRunCommand(config: UniDeskConfig | null, args: str
if (action === "cleanup-session-pvcs") {
return await cleanupSessionPvcs(config, parseCleanupSessionPvcsOptions(actionArgs));
}
if (action === "cleanup-local-postgres") return await cleanupLocalPostgres(config, parseLaneConfirmOptions(actionArgs));
if (action === "cleanup-released-pvs") return await cleanupReleasedPvs(config, parseCleanupReleasedPvOptions(actionArgs));
}
if (group === "git-mirror") {
@@ -275,7 +278,7 @@ export function agentRunHelpText(args: string[]): string {
return [
"Usage: bun scripts/cli.ts agentrun control-plane <action> [options]",
"",
"Actions: plan, apply, status, secret-sync, expose, trigger-current, refresh, cleanup-runners, cleanup-runs, cleanup-session-pvcs, cleanup-released-pvs",
"Actions: plan, apply, status, secret-sync, expose, trigger-current, refresh, cleanup-runners, cleanup-runs, cleanup-session-pvcs, cleanup-local-postgres, cleanup-released-pvs",
"Examples:",
" bun scripts/cli.ts agentrun control-plane plan --node D601 --lane v02",
" bun scripts/cli.ts agentrun control-plane apply --node D601 --lane v02 --dry-run",
@@ -288,6 +291,7 @@ export function agentRunHelpText(args: string[]): string {
" bun scripts/cli.ts agentrun control-plane trigger-current --dry-run",
" bun scripts/cli.ts agentrun control-plane cleanup-runners --node D601 --lane v02 --dry-run",
" bun scripts/cli.ts agentrun control-plane cleanup-session-pvcs --node JD01 --lane jd01-v02 --dry-run",
" bun scripts/cli.ts agentrun control-plane cleanup-local-postgres --node NC01 --lane nc01-v02 --dry-run",
" bun scripts/cli.ts agentrun control-plane cleanup-runs --min-age-minutes 30 --limit 200 --dry-run",
].join("\n");
}
+10 -2
View File
@@ -50,6 +50,14 @@ export function agentRunControlPlaneStatusCommand(spec: AgentRunLaneSpec, option
].filter(Boolean).join(" ");
}
function deploymentReplicaSummary(manager: Record<string, unknown>): string {
const replicas = record(manager.replicas);
const desired = displayValue(replicas.desired);
const ready = displayValue(replicas.ready);
const available = displayValue(replicas.available);
return `${ready}/${desired} available=${available}`;
}
export function renderAgentRunControlPlaneStatusSummary(result: Record<string, unknown>): RenderedCliResult {
const summary = record(result.summary);
const target = record(result.target);
@@ -104,7 +112,7 @@ export function renderAgentRunControlPlaneStatusSummary(result: Record<string, u
["pipelinerun", yesNo(latestPipelineRun.status === "True"), `run=${displayValue(latestPipelineRun.name)} status=${displayValue(latestPipelineRun.status)} reason=${displayValue(latestPipelineRun.reason)} duration=${displayValue(latestPipelineRun.durationSeconds)}s`],
["image", yesNo(artifact.imageStatus === "reused" || artifact.imageStatus === "built"), `status=${displayValue(artifact.imageStatus)} env=${displayValue(artifact.envIdentity)} digest=${shortSha(artifact.digest)} gitops=${shortSha(artifact.gitopsCommit)}`],
["argo", yesNo(argo.syncedToGitops), `sync=${displayValue(argo.syncStatus)} health=${displayValue(argo.healthStatus)} revision=${shortSha(argo.revision)}`],
["runtime", yesNo(runtimeManager.sourceMatchesExpected), `manager=${shortSha(runtimeManager.sourceCommit)} secrets=${yesNo(runtime.secretsReady)} dbSecret=${yesNo(runtime.databaseSecretPresent)} localPgAbsent=${yesNo(runtime.localPostgresAbsent)}`],
["runtime", yesNo(runtimeManager.ready === true && runtimeManager.sourceMatchesExpected === true), `manager=${shortSha(runtimeManager.sourceCommit)} ready=${yesNo(runtimeManager.ready)} replicas=${deploymentReplicaSummary(runtimeManager)} secrets=${yesNo(runtime.secretsReady)} dbSecret=${yesNo(runtime.databaseSecretPresent)} localPgAbsent=${yesNo(runtime.localPostgresAbsent)}`],
],
),
"",
@@ -151,7 +159,7 @@ export function renderAgentRunControlPlaneStatusSummary(result: Record<string, u
["git-mirror", yesNo(gitMirror.alreadySynced), `read=${yesNo(gitMirror.readReady)} write=${yesNo(gitMirror.writeReady)} gitops=${shortSha(gitMirror.gitopsCommit)}`],
["ci", yesNo(ciRun.status === "True"), `run=${displayValue(ciRun.name)} status=${displayValue(ciRun.status)} reason=${displayValue(ciRun.reason)} evidenceMissing=${yesNo(ci.evidenceMissing)}`],
["argo", yesNo(argo.syncedToGitops), `sync=${displayValue(argo.syncStatus)} health=${displayValue(argo.healthStatus)} revision=${shortSha(argo.revision)}`],
["runtime", yesNo(runtimeManager.sourceMatchesExpected), `manager=${shortSha(runtimeManager.sourceCommit)} secrets=${yesNo(runtime.secretsReady)} dbSecret=${yesNo(runtime.databaseSecretPresent)} localPgAbsent=${yesNo(runtime.localPostgresAbsent)}`],
["runtime", yesNo(runtimeManager.ready === true && runtimeManager.sourceMatchesExpected === true), `manager=${shortSha(runtimeManager.sourceCommit)} ready=${yesNo(runtimeManager.ready)} replicas=${deploymentReplicaSummary(runtimeManager)} secrets=${yesNo(runtime.secretsReady)} dbSecret=${yesNo(runtime.databaseSecretPresent)} localPgAbsent=${yesNo(runtime.localPostgresAbsent)}`],
],
),
"",
+132 -2
View File
@@ -35,7 +35,7 @@ import {
} from "../agentrun-manifests";
import { sha256Fingerprint } from "../platform-infra-ops-library";
import type { CleanupReleasedPvOptions, CleanupRunnersOptions, CleanupRunsOptions, CleanupSessionPvcsOptions, RefreshOptions } from "./options";
import type { CleanupReleasedPvOptions, CleanupRunnersOptions, CleanupRunsOptions, CleanupSessionPvcsOptions, LaneConfirmOptions, RefreshOptions } from "./options";
import { cleanupReleasedPvsFinalizeNodeScript, cleanupReleasedPvsPlanNodeScript, cleanupRunnersFinalizeNodeScript, cleanupRunsFinalizeNodeScript, cleanupRunsPlanNodeScript, refreshYamlLaneScript } from "./git-mirror";
import { cleanupRunnersFactsNodeScript, cleanupRunnersPlanNodeScript, collectLaneSecretSources, createYamlLaneJobScript, yamlLaneGitopsPublishJobManifest, yamlLaneGitopsPublishPayloadFromProbe, yamlLaneJobProbeScript } from "./secrets";
import { capture, captureJsonPayload, compactCapture, progressEvent, shQuote, sleep, stringOrNull } from "./utils";
@@ -234,6 +234,105 @@ export async function cleanupSessionPvcs(config: UniDeskConfig, options: Cleanup
};
}
export async function cleanupLocalPostgres(config: UniDeskConfig, options: LaneConfirmOptions): Promise<Record<string, unknown>> {
const { configPath, spec } = resolveAgentRunLaneTarget(options);
const name = spec.deployment.localPostgres.serviceName;
const plan = {
namespace: spec.runtime.namespace,
statefulSet: name,
service: name,
pvcDisposition: "retained",
requiresDatabaseMode: "external-postgres",
valuesPrinted: false,
};
if (spec.database.mode !== "external-postgres" || spec.deployment.localPostgres.enabled || name === null) {
return {
ok: false,
command: "agentrun control-plane cleanup-local-postgres",
mode: "blocked",
mutation: false,
configPath,
target: agentRunLaneSummary(spec),
degradedReason: "lane-is-not-external-postgres",
plan,
valuesPrinted: false,
};
}
const confirmed = options.confirm && !options.dryRun;
const result = await capture(config, spec.nodeKubeRoute, ["sh", "--", cleanupLocalPostgresScript(spec.runtime.namespace, name, confirmed)]);
const payload = captureJsonPayload(result);
const base = {
ok: result.exitCode === 0 && payload.ok !== false,
command: "agentrun control-plane cleanup-local-postgres",
mode: confirmed ? "confirmed-cleanup" : "dry-run",
mutation: confirmed,
configPath,
target: agentRunLaneSummary(spec),
plan,
result: payload,
capture: compactCapture(result, { full: result.exitCode !== 0, stdoutTailChars: 3000, stderrTailChars: 3000 }),
valuesPrinted: false,
};
if (!confirmed) {
return {
...base,
next: {
confirm: `bun scripts/cli.ts agentrun control-plane cleanup-local-postgres --node ${spec.nodeId} --lane ${spec.lane} --confirm`,
status: `bun scripts/cli.ts agentrun control-plane status --node ${spec.nodeId} --lane ${spec.lane}`,
},
};
}
return {
...base,
next: {
status: `bun scripts/cli.ts agentrun control-plane status --node ${spec.nodeId} --lane ${spec.lane}`,
},
};
}
export function cleanupLocalPostgresScript(namespace: string, name: string, confirm: boolean): string {
return [
"set +e",
`namespace=${shQuote(namespace)}`,
`name=${shQuote(name)}`,
`confirm=${confirm ? "true" : "false"}`,
"tmp_dir=$(mktemp -d)",
"trap 'rm -rf \"$tmp_dir\"' EXIT",
"kubectl -n \"$namespace\" get statefulset \"$name\" -o name > \"$tmp_dir/sts-before\" 2>/dev/null; sts_before=$?",
"kubectl -n \"$namespace\" get service \"$name\" -o name > \"$tmp_dir/svc-before\" 2>/dev/null; svc_before=$?",
"delete_sts_rc=0",
"delete_svc_rc=0",
"if [ \"$confirm\" = \"true\" ]; then",
" kubectl -n \"$namespace\" delete statefulset \"$name\" --ignore-not-found > \"$tmp_dir/delete-sts.out\" 2> \"$tmp_dir/delete-sts.err\"; delete_sts_rc=$?",
" kubectl -n \"$namespace\" delete service \"$name\" --ignore-not-found > \"$tmp_dir/delete-svc.out\" 2> \"$tmp_dir/delete-svc.err\"; delete_svc_rc=$?",
"else",
" : > \"$tmp_dir/delete-sts.out\"; : > \"$tmp_dir/delete-sts.err\"; : > \"$tmp_dir/delete-svc.out\"; : > \"$tmp_dir/delete-svc.err\"",
"fi",
"kubectl -n \"$namespace\" get statefulset \"$name\" -o name > \"$tmp_dir/sts-after\" 2>/dev/null; sts_after=$?",
"kubectl -n \"$namespace\" get service \"$name\" -o name > \"$tmp_dir/svc-after\" 2>/dev/null; svc_after=$?",
"NAMESPACE=\"$namespace\" NAME=\"$name\" CONFIRM=\"$confirm\" STS_BEFORE=\"$sts_before\" SVC_BEFORE=\"$svc_before\" STS_AFTER=\"$sts_after\" SVC_AFTER=\"$svc_after\" DELETE_STS_RC=\"$delete_sts_rc\" DELETE_SVC_RC=\"$delete_svc_rc\" TMP_DIR=\"$tmp_dir\" python3 - <<'PY'",
"import json, os, pathlib",
"base = pathlib.Path(os.environ['TMP_DIR'])",
"def present(name): return os.environ.get(name) == '0'",
"def text(name):",
" try: return (base / name).read_text().strip()",
" except Exception: return ''",
"payload = {",
" 'ok': os.environ.get('DELETE_STS_RC') == '0' and os.environ.get('DELETE_SVC_RC') == '0',",
" 'namespace': os.environ.get('NAMESPACE'),",
" 'name': os.environ.get('NAME'),",
" 'confirmed': os.environ.get('CONFIRM') == 'true',",
" 'before': {'statefulSetPresent': present('STS_BEFORE'), 'servicePresent': present('SVC_BEFORE')},",
" 'after': {'statefulSetPresent': present('STS_AFTER'), 'servicePresent': present('SVC_AFTER')},",
" 'deleted': {'statefulSet': text('delete-sts.out'), 'service': text('delete-svc.out')},",
" 'pvcDisposition': 'retained',",
" 'valuesPrinted': False,",
"}",
"print(json.dumps(payload, ensure_ascii=False))",
"PY",
].join("\n");
}
export function cleanupSessionPvcsScript(options: CleanupSessionPvcsOptions, spec: AgentRunLaneSpec): string {
const retention = spec.deployment.runner.retention.sessionPvcRetention;
const script = readFileSync(rootPath("scripts/src/agentrun/cleanup-session-pvcs.mjs"), "utf8");
@@ -573,6 +672,34 @@ export function yamlLaneRuntimeStatusScript(spec: AgentRunLaneSpec, pipelineRun:
" for entry in envs:",
" if entry.get('name') == name: return entry.get('value')",
" return None",
"def int_value(value, default=0):",
" try: return int(value)",
" except Exception: return default",
"def deployment_replicas(deploy):",
" spec = (deploy or {}).get('spec') or {}",
" status = (deploy or {}).get('status') or {}",
" metadata = (deploy or {}).get('metadata') or {}",
" desired = int_value(spec.get('replicas'), 1)",
" return {",
" 'desired': desired,",
" 'ready': int_value(status.get('readyReplicas'), 0),",
" 'available': int_value(status.get('availableReplicas'), 0),",
" 'updated': int_value(status.get('updatedReplicas'), 0),",
" 'unavailable': int_value(status.get('unavailableReplicas'), 0),",
" 'generation': int_value(metadata.get('generation'), 0),",
" 'observedGeneration': int_value(status.get('observedGeneration'), 0),",
" }",
"def deployment_conditions(deploy):",
" return [{'type': c.get('type'), 'status': c.get('status'), 'reason': c.get('reason')} for c in (((deploy or {}).get('status') or {}).get('conditions') or [])]",
"def deployment_ready(deploy):",
" if deploy is None: return False",
" replicas = deployment_replicas(deploy)",
" desired = replicas['desired']",
" if desired == 0: return True",
" observed = replicas['observedGeneration']",
" generation = replicas['generation']",
" generation_current = observed == 0 or generation == 0 or observed >= generation",
" return generation_current and replicas['ready'] >= desired and replicas['available'] >= desired and replicas['updated'] >= desired",
"def pipeline_run_param(obj, name):",
" for entry in (((obj or {}).get('spec') or {}).get('params') or []):",
" if entry.get('name') == name: return entry.get('value')",
@@ -589,6 +716,9 @@ export function yamlLaneRuntimeStatusScript(spec: AgentRunLaneSpec, pipelineRun:
"ports = []",
"for port in ((((manager_svc or {}).get('spec') or {}).get('ports')) or []):",
" ports.append({'name': port.get('name'), 'port': port.get('port'), 'targetPort': port.get('targetPort')})",
"manager_replicas = deployment_replicas(manager_deploy)",
"manager_ready = exists('MANAGER_DEPLOY_EXIT') and deployment_ready(manager_deploy)",
"manager_conditions = deployment_conditions(manager_deploy)",
"matching_postgres = [line for line in re.split(r'\\r?\\n', names) if re.search('postgres', line, re.I)][:20]",
"print(json.dumps({",
" 'ok': exists('RUNTIME_NS_EXIT') and exists('CI_NS_EXIT'),",
@@ -598,7 +728,7 @@ export function yamlLaneRuntimeStatusScript(spec: AgentRunLaneSpec, pipelineRun:
" 'pipeline': {'exists': exists('PIPELINE_EXIT'), 'name': os.environ.get('pipeline_name')},",
" 'pipelineRun': {'exists': exists('PIPELINERUN_EXIT'), 'name': os.environ.get('pipeline_run') or None, 'status': c.get('status'), 'reason': c.get('reason'), 'sourceCommit': pipeline_run_param(pipeline_run, 'revision'), 'startTime': ((pipeline_run or {}).get('status') or {}).get('startTime'), 'completionTime': ((pipeline_run or {}).get('status') or {}).get('completionTime')},",
" 'argo': {'exists': exists('ARGO_EXIT'), 'namespace': os.environ.get('argo_namespace'), 'application': os.environ.get('argo_application'), 'revision': (((argo or {}).get('status') or {}).get('sync') or {}).get('revision'), 'syncStatus': (((argo or {}).get('status') or {}).get('sync') or {}).get('status'), 'healthStatus': (((argo or {}).get('status') or {}).get('health') or {}).get('status')},",
" 'manager': {'deploymentExists': exists('MANAGER_DEPLOY_EXIT'), 'serviceExists': exists('MANAGER_SVC_EXIT'), 'deployment': os.environ.get('manager_deployment'), 'service': os.environ.get('manager_service'), 'image': ((((((manager_deploy or {}).get('spec') or {}).get('template') or {}).get('spec') or {}).get('containers') or [{}])[0]).get('image'), 'sourceCommit': env_value(manager_deploy, 'AGENTRUN_SOURCE_COMMIT'), 'servicePorts': ports},",
" 'manager': {'deploymentExists': exists('MANAGER_DEPLOY_EXIT'), 'serviceExists': exists('MANAGER_SVC_EXIT'), 'ready': manager_ready, 'deployment': os.environ.get('manager_deployment'), 'service': os.environ.get('manager_service'), 'image': ((((((manager_deploy or {}).get('spec') or {}).get('template') or {}).get('spec') or {}).get('containers') or [{}])[0]).get('image'), 'sourceCommit': env_value(manager_deploy, 'AGENTRUN_SOURCE_COMMIT'), 'servicePorts': ports, 'replicas': manager_replicas, 'conditions': manager_conditions},",
" 'database': {'secretPresent': exists('DB_SECRET_EXIT'), 'secretName': os.environ.get('database_secret'), 'key': os.environ.get('database_key'), 'keyPresent': os.environ.get('database_key') in (((db_secret or {}).get('data') or {})), 'valuesPrinted': False},",
" 'secrets': secrets,",
" 'localPostgres': {'absent': len(matching_postgres) == 0, 'matchingObjects': matching_postgres},",
+170
View File
@@ -0,0 +1,170 @@
import type { UniDeskConfig } from "./config";
import { renderMachine } from "./cicd-render";
import type { RenderedCliResult } from "./output";
import { getPlatformInfraPipelinesAsCodeNodeStatus } from "./platform-infra-pipelines-as-code";
interface NodeStatusOptions {
nodeId: string | null;
targetId: string | null;
full: boolean;
raw: boolean;
output: "text" | "json";
}
export function cicdNodeStatusHelp(): Record<string, unknown> {
return {
command: "cicd status --node <NODE>",
description: "Read a node-level CI/CD closeout summary for every Pipelines-as-Code consumer on the node.",
usage: [
"bun scripts/cli.ts cicd status --node NC01",
"bun scripts/cli.ts cicd status --node NC01 --full",
"bun scripts/cli.ts cicd status --node NC01 --json",
],
output: "compact text by default; --json/--raw returns machine payload",
source: "config/platform-infra/pipelines-as-code.yaml consumers filtered by node",
};
}
export async function runCicdNodeStatusCommand(config: UniDeskConfig | null, args: string[]): Promise<RenderedCliResult> {
if (args[0] === undefined || args.some(isHelpToken)) {
return renderMachine("cicd status", cicdNodeStatusHelp(), "json");
}
const options = parseNodeStatusOptions(args);
if (config === null) throw new Error("cicd status requires UniDesk config; run through bun scripts/cli.ts cicd status --node <NODE>");
const result = await getPlatformInfraPipelinesAsCodeNodeStatus(config, {
targetId: options.targetId,
nodeId: options.nodeId,
full: options.full,
raw: options.raw,
});
if (options.output === "json" || options.raw) return renderMachine("cicd status", result, "json", result.ok !== false);
return renderNodeStatus(result, options.full);
}
function parseNodeStatusOptions(args: string[]): NodeStatusOptions {
let nodeId: string | null = null;
let targetId: string | null = null;
let full = false;
let raw = false;
let output: "text" | "json" = "text";
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "status") continue;
if (arg === "--node" || arg === "--target") {
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 node id`);
if (arg === "--node") nodeId = value;
else targetId = value;
index += 1;
} else if (arg === "--full") {
full = true;
} else if (arg === "--raw" || arg === "-o=json" || (arg === "-o" && args[index + 1] === "json")) {
raw = true;
full = true;
output = "json";
if (arg === "-o") index += 1;
} else if (arg === "--json") {
output = "json";
} else if (isHelpToken(arg)) {
output = "json";
} else {
throw new Error(`unsupported cicd status option: ${arg}`);
}
}
if (nodeId === null && targetId === null) throw new Error("cicd status requires --node <NODE>");
return { nodeId, targetId, full, raw, output };
}
function renderNodeStatus(result: Record<string, unknown>, full: boolean): RenderedCliResult {
const target = record(result.target);
const summary = record(result.summary);
const consumers = arrayRecords(result.consumers);
const rows = consumers.map((row) => [
stringValue(row.consumer),
boolText(row.ready),
stringValue(row.pipelineStatus),
`${stringValue(row.durationSeconds)}s`,
short(stringValue(row.sourceCommit)),
short(stringValue(row.gitopsCommit)),
`${stringValue(record(row.argo).sync)}/${stringValue(record(row.argo).health)}`,
runtimeText(record(row.runtime)),
stringValue(row.reason),
]);
const detailRows = consumers.map((row) => [
stringValue(row.consumer),
stringValue(row.latestPipelineRun),
short(stringValue(row.digest), 18),
stringValue(row.imageStatus),
compactLine(stringValue(record(row.diagnostics).hint)),
]);
const lines = [
"CI/CD NODE STATUS",
...table(["NODE", "TARGET", "READY", "READY_COUNT", "BLOCKED", "ELAPSED_MS"], [[
stringValue(result.node),
stringValue(target.id),
boolText(summary.ready),
`${stringValue(summary.readyCount)}/${stringValue(summary.total)}`,
stringValue(summary.blockedCount),
stringValue(summary.elapsedMs),
]]),
"",
"CONSUMERS",
...(rows.length === 0 ? ["-"] : table(["CONSUMER", "READY", "PIPELINE", "DUR", "SOURCE", "GITOPS", "ARGO", "RUNTIME", "REASON"], rows)),
"",
...(full ? [
"DETAIL",
...(detailRows.length === 0 ? ["-"] : table(["CONSUMER", "PIPELINERUN", "DIGEST", "IMAGE_STATUS", "HINT"], detailRows)),
"",
] : []),
"NEXT",
` refresh: ${stringValue(record(result.next).status)}`,
` history: ${stringValue(record(result.next).history)}`,
` closeout: ${stringValue(record(result.next).closeout)}`,
];
return { ok: result.ok !== false, command: "cicd status", renderedText: lines.join("\n"), contentType: "text/plain" };
}
function isHelpToken(value: string | undefined): boolean {
return value === "help" || value === "--help" || value === "-h";
}
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 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 runtimeText(runtime: Record<string, unknown>): string {
const ready = `${stringValue(runtime.readyReplicas)}/${stringValue(runtime.replicas)}`;
const digest = short(stringValue(runtime.digest), 18);
return digest === "-" ? ready : `${ready}:${digest}`;
}
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)];
}
+9 -2
View File
@@ -5,11 +5,16 @@ import { renderMachine } from "./cicd-render";
import type { RenderedCliResult } from "./output";
import { cicdHelp as branchFollowerHelp, runCicdCommand as runBranchFollowerCommand } from "./cicd-branch-follower";
import { cicdGiteaActionsPocHelp, runGiteaActionsPocCommand } from "./cicd-gitea-actions-poc";
import { cicdNodeStatusHelp, runCicdNodeStatusCommand } from "./cicd-node-status";
export function cicdHelp(): unknown {
return {
command: "cicd gitea-actions-poc|branch-follower",
command: "cicd status|gitea-actions-poc|branch-follower",
output: "text by default for subcommands; top-level help is json",
nodeStatus: {
primary: "bun scripts/cli.ts cicd status --node NC01",
note: "Use this for node-level CI/CD status. It aggregates all current Pipelines-as-Code consumers for the node, including AgentRun, HWLAB and Web sentinel.",
},
migration: {
issue: "https://github.com/pikasTech/unidesk/issues/1560",
primary: "platform-infra pipelines-as-code",
@@ -20,6 +25,7 @@ export function cicdHelp(): unknown {
note: "JD01 CI/CD source/trigger authority is Gitea mirror -> Pipelines-as-Code -> Tekton -> Argo/k8s runtime. Gitea Actions POC and branch-follower are historical or migration-only surfaces.",
},
subcommands: [
cicdNodeStatusHelp(),
cicdGiteaActionsPocHelp(),
branchFollowerHelp(),
],
@@ -29,7 +35,8 @@ export function cicdHelp(): unknown {
export async function runCicdCommand(config: UniDeskConfig | null, args: string[]): Promise<RenderedCliResult> {
const top = args[0];
if (top === undefined || top === "help" || top === "--help" || top === "-h") return renderMachine("cicd", cicdHelp(), "json");
if (top === "status") return await runCicdNodeStatusCommand(config, args);
if (top === "branch-follower") return await runBranchFollowerCommand(config, args);
if (top === "gitea-actions-poc" || top === "gitea-builder-poc") return await runGiteaActionsPocCommand(config, args.slice(1), top);
throw new Error("cicd usage: cicd gitea-actions-poc|branch-follower");
throw new Error("cicd usage: cicd status --node <NODE> | cicd gitea-actions-poc|branch-follower");
}
+6 -3
View File
@@ -58,7 +58,8 @@ export function rootHelp(): unknown {
{ command: "decision requirement list|create|show|update|upsert [id|docNo] [--title text] [--body-file path] [--type external_goal|internal_goal|goal|decision|blocker|debt|experiment] [--doc-no DC-...] [--doc-type ...] [--doc-priority P0|P1|P2|P3] [--signer text] [--issued-at ISO]", description: "Manage productized requirement records over the PostgreSQL records model, excluding meeting records." },
{ command: "decision show <id|docNo>", description: "Show one Decision Center record." },
{ command: "deploy check|plan|apply [--file deploy.json|--env dev|prod] [--service id] [--commit full-sha] [--dry-run] [--force]", description: "Reconcile services from origin/master:deploy.json environments; --commit overrides one reviewed artifact consumer such as frontend for release/v1 validation or rollback. code-queue artifact consumption is dev-only." },
{ command: "cicd gitea-actions-poc plan|status", description: "Recommended CI/CD migration path for GH-1548/GH-1549: Gitea mirror and Gitea Actions driven orchestration with controlled Docker/BuildKit builder-plane, runtime plane 0 Docker and env reuse as a P0 no-regression contract." },
{ command: "cicd status --node <NODE>", description: "Show one node's CI/CD closeout summary across current Pipelines-as-Code consumers, including PipelineRun, Argo and runtime readiness." },
{ command: "cicd gitea-actions-poc plan|status", description: "Historical CI/CD migration path for GH-1548/GH-1549; current node status should use cicd status --node <NODE>." },
{ command: "cicd branch-follower plan|apply|status|run-once|events|logs", description: "Deprecated migration-only Kubernetes branch follower controller; keep existing production status/debug during cutover, but do not add new self-maintained branch-following features." },
{ command: "dev-env validate|prewarm-images", description: "Validate D601 unidesk-dev guardrails or prewarm dev foundation images into native k3s containerd through a bounded async job." },
{ command: "artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service", description: "Manage the D601 host-managed CNCF Distribution registry and run pull-only artifact CD for supported services, including D601 direct, k3s-managed, and code-queue dev-only consumers." },
@@ -749,15 +750,17 @@ function webProbeHelpSummary(): unknown {
function cicdHelpSummary(): unknown {
return {
command: "cicd gitea-actions-poc plan|status | branch-follower ...",
command: "cicd status --node <NODE> | gitea-actions-poc ... | branch-follower ...",
output: "text by default; use --json, --raw, or -o json|yaml for machine output",
usage: [
"bun scripts/cli.ts cicd status --node NC01",
"bun scripts/cli.ts cicd status --node NC01 --full",
"bun scripts/cli.ts cicd gitea-actions-poc plan",
"bun scripts/cli.ts cicd gitea-actions-poc status",
"bun scripts/cli.ts cicd branch-follower plan",
"bun scripts/cli.ts cicd branch-follower status",
],
description: "Gitea Actions driven CI/CD migration path; branch-follower remains deprecated migration-only status/debug during cutover.",
description: "Node-level CI/CD status is the primary read path. Gitea Actions and branch-follower remain deprecated migration-only diagnostics.",
};
}
@@ -130,6 +130,13 @@ interface CommonOptions {
raw: boolean;
}
export interface PipelinesAsCodeNodeStatusOptions {
targetId: string | null;
nodeId: string | null;
full: boolean;
raw: boolean;
}
interface HistoryOptions extends CommonOptions {
limit: number;
detailId: string | null;
@@ -446,6 +453,75 @@ async function status(config: UniDeskConfig, options: CommonOptions): Promise<Re
};
}
export async function getPlatformInfraPipelinesAsCodeNodeStatus(config: UniDeskConfig, options: PipelinesAsCodeNodeStatusOptions): Promise<Record<string, unknown>> {
const pac = readPacConfig();
const target = resolveTarget(pac, options.targetId ?? options.nodeId);
const nodeId = options.nodeId ?? target.id;
const consumers = pac.consumers.filter((consumer) => consumer.node.toLowerCase() === nodeId.toLowerCase());
if (consumers.length === 0) {
throw new Error(`no Pipelines-as-Code consumers are configured for node ${nodeId}; known nodes: ${Array.from(new Set(pac.consumers.map((item) => item.node))).sort().join(", ")}`);
}
const startedAt = Date.now();
const statuses = await Promise.all(consumers.map(async (consumer) => {
const repository = resolveRepository(pac, consumer.repositoryRef);
try {
const current = await status(config, { targetId: target.id, consumerId: consumer.id, full: false, raw: false });
return nodeStatusRow(target.id, consumer, repository, current);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
consumer: consumer.id,
node: consumer.node,
lane: consumer.lane,
repository: `${repository.owner}/${repository.repo}`,
namespace: consumer.namespace,
pipeline: consumer.pipeline,
argoApplication: consumer.argoApplication,
ready: false,
ciReady: false,
argoReady: false,
runtimeReady: false,
diagnosticsReady: false,
reason: "status-read-failed",
hint: message,
error: message,
valuesPrinted: false,
};
}
}));
const ready = statuses.every((row) => row.ready === true);
return {
ok: ready,
action: "cicd-node-status",
mutation: false,
node: nodeId,
target: targetSummary(target),
config: {
path: configLabel,
source: "Gitea Repository CR + Tekton PipelineRun/TaskRun + Argo + runtime live objects.",
displayTimeZone: pac.display.timeZone,
valuesPrinted: false,
},
consumers: statuses,
summary: {
ready,
total: statuses.length,
readyCount: statuses.filter((row) => row.ready === true).length,
blockedCount: statuses.filter((row) => row.ready !== true).length,
elapsedMs: Date.now() - startedAt,
valuesPrinted: false,
},
next: {
status: `bun scripts/cli.ts cicd status --node ${nodeId}`,
history: `bun scripts/cli.ts platform-infra pipelines-as-code history --target ${target.id} --limit 10`,
closeout: `bun scripts/cli.ts platform-infra pipelines-as-code closeout --target ${target.id} --wait`,
valuesPrinted: false,
},
details: options.full || options.raw ? statuses : undefined,
valuesPrinted: false,
};
}
async function closeout(config: UniDeskConfig, options: CloseoutOptions): Promise<Record<string, unknown>> {
const pac = readPacConfig();
const target = resolveTarget(pac, options.targetId);
@@ -723,6 +799,75 @@ function statusSummary(payload: Record<string, unknown>): Record<string, unknown
};
}
function nodeStatusRow(targetId: string, consumer: PacConsumer, repository: PacRepository, current: Record<string, unknown>): Record<string, unknown> {
const summary = record(current.summary);
const latest = record(summary.latestPipelineRun);
const artifact = record(summary.artifact);
const argo = record(summary.argo);
const runtime = record(summary.runtime);
const diagnostics = record(summary.diagnostics);
const pipelineGate = record(summary.pipelineRunGate);
const desired = numericValue(runtime.replicas);
const readyReplicas = numericValue(runtime.readyReplicas);
const runtimeReady = desired !== null && desired > 0 && readyReplicas === desired;
const argoReady = stringValue(argo.sync) === "Synced" && stringValue(argo.health) === "Healthy";
const ciReady = pipelineGate.ok === true;
const diagnosticsReady = diagnostics.ok !== false;
const ready = current.ok === true && ciReady && argoReady && runtimeReady && diagnosticsReady;
return {
consumer: consumer.id,
node: consumer.node,
lane: consumer.lane,
repository: `${repository.owner}/${repository.repo}`,
namespace: consumer.namespace,
pipeline: consumer.pipeline,
argoApplication: consumer.argoApplication,
ready,
ciReady,
argoReady,
runtimeReady,
diagnosticsReady,
latestPipelineRun: stringValue(latest.name),
pipelineStatus: statusText(latest),
durationSeconds: latest.durationSeconds ?? null,
sourceCommit: stringValue(latest.sourceCommit),
imageStatus: stringValue(artifact.imageStatus),
envReuse: envReuseText(record(artifact.envReuse)),
digest: stringValue(artifact.digest),
gitopsCommit: stringValue(artifact.gitopsCommit),
argo: {
sync: stringValue(argo.sync),
health: stringValue(argo.health),
revision: stringValue(argo.revision),
},
runtime: {
deployment: stringValue(runtime.deployment),
readyReplicas: runtime.readyReplicas ?? null,
replicas: runtime.replicas ?? null,
digest: stringValue(runtime.digest),
image: stringValue(runtime.image),
},
diagnostics: {
ok: diagnostics.ok !== false,
code: stringValue(diagnostics.code),
phase: stringValue(diagnostics.phase),
hint: compactLine(stringValue(diagnostics.hint)),
},
reason: ready ? "ready" : nodeStatusReason({ ciReady, argoReady, runtimeReady, diagnosticsReady, pipelineStatus: statusText(latest), diagnostics }),
statusCommand: `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${targetId} --consumer ${consumer.id}`,
historyCommand: `bun scripts/cli.ts platform-infra pipelines-as-code history --target ${targetId} --consumer ${consumer.id} --limit 10`,
valuesPrinted: false,
};
}
function nodeStatusReason(input: { ciReady: boolean; argoReady: boolean; runtimeReady: boolean; diagnosticsReady: boolean; pipelineStatus: string; diagnostics: Record<string, unknown> }): string {
if (!input.ciReady) return `ci:${input.pipelineStatus}`;
if (!input.argoReady) return "argo-not-ready";
if (!input.runtimeReady) return "runtime-not-ready";
if (!input.diagnosticsReady) return stringValue(input.diagnostics.code, "diagnostics-not-ready");
return "not-ready";
}
function policyChecks(repository: PacRepository): Array<Record<string, unknown>> {
return [
{ name: "single-path", ok: true, detail: "Gitea webhook -> Pipelines-as-Code -> Tekton -> Argo/k8s runtime; no Gitea Actions/act_runner/branch-follower fallback." },