diff --git a/.agents/skills/unidesk-cicd/references/branch-follower.md b/.agents/skills/unidesk-cicd/references/branch-follower.md index d120f16e..5dc520e1 100644 --- a/.agents/skills/unidesk-cicd/references/branch-follower.md +++ b/.agents/skills/unidesk-cicd/references/branch-follower.md @@ -34,6 +34,8 @@ Do not debug the same state/read/write problem by repeatedly pushing empty or ti When a branch-follower issue remains ambiguous after a debug step or drill-down, split the CLI into a smaller single-step probe before any new end-to-end run. Add or use a focused `debug-step`, follower-scoped drill-down, or bounded target-side diagnostic for the exact missing edge, such as PipelineRun -> Pipeline spec, controller refresh apply object, state write, closeout re-read, or log/timing extraction. Do not use another source PR, merge, or full automatic follower loop as the next diagnostic action until the narrower step can show the needed evidence. +For HWLAB native `control-plane-refresh`, the bounded evidence chain must preserve both the rendered Pipeline summary and the applied cluster object summary for the same source commit: rendered Pipeline name, bounded `runtime-ready` task/when summary, source commit/stage ref, applied Pipeline name, resourceVersion, and a short annotation/label subset proving which object was patched. If the Job TTL has already removed the original Job, status/events/logs must show `-` or a bounded missing reason from stored state instead of inferring the missing edge. + CI/CD validation must be decomposable into ordered single-step gates before a full rollout observation is accepted: first validate the reuse plan, then CI parallelism/TaskRun plan, then CD rollout plan, then post-deploy monitoring/health evidence. Each gate must have a CLI/debug-step/drill-down entry that can be run and fixed independently on the target side. Do not use issue comments, repeated PR merges, or end-to-end follower loops as substitutes for a missing single-step validator; add the missing bounded CLI step first. When a repeated runtime pitfall or visibility defect is found during branch-follower work, update this reference or the skill entry first, then continue with the narrow debug step. Do not proceed to `run-once`, controller loop observation, automatic follower validation, or source-commit-driven integration until the relevant `state-read`, `status-read`, `decide`, and `state-write` debug steps pass for the affected follower. diff --git a/scripts/native/cicd/hwlab-node-control-plane-refresh.mjs b/scripts/native/cicd/hwlab-node-control-plane-refresh.mjs index 2ed59e6d..6abee5df 100644 --- a/scripts/native/cicd/hwlab-node-control-plane-refresh.mjs +++ b/scripts/native/cicd/hwlab-node-control-plane-refresh.mjs @@ -26,8 +26,8 @@ try { prepareYamlDependency(); applyDeployOverlay(); renderControlPlane(); - await applyPipeline(); - emit({ ok: true, status: "applied" }); + const evidence = await applyPipeline(); + emit({ ok: true, status: "applied", ...evidence }); } finally { rmSync(workDir, { recursive: true, force: true }); } @@ -143,16 +143,21 @@ async function applyPipeline() { if (typeof renderedPipelineName !== "string" || renderedPipelineName.length === 0) { throw new Error(`rendered Pipeline metadata.name missing: ${pipelinePath}`); } + const render = summarizeRenderedPipeline(pipeline, renderedPipelineName); const pipelineName = requiredOverlayString("pipelineName"); pipeline.metadata = pipeline.metadata && typeof pipeline.metadata === "object" ? pipeline.metadata : {}; pipeline.metadata.name = pipelineName; const pipelineText = YAML.stringify(pipeline); - await kubeRequest( + const applyText = await kubeRequest( "PATCH", `/apis/tekton.dev/v1/namespaces/${encodeURIComponent(tektonNamespace)}/pipelines/${encodeURIComponent(pipelineName)}?fieldManager=${encodeURIComponent(fieldManager)}&force=true`, pipelineText, "application/apply-patch+yaml", ); + return { + render, + apply: summarizeAppliedPipeline(parseJsonObject(applyText), pipelineName, tektonNamespace), + }; } function yamlModule() { @@ -281,6 +286,102 @@ function requiredNonNegativeNumber(name) { return Math.floor(value); } +function summarizeRenderedPipeline(pipeline, pipelineName) { + const tasks = Array.isArray(pipeline?.spec?.tasks) ? pipeline.spec.tasks : []; + const runtimeReady = tasks.find((task) => recordOrNull(task)?.name === "runtime-ready"); + return { + pipelineName, + taskCount: tasks.length, + runtimeReadyTask: summarizeRuntimeReadyTask(runtimeReady), + }; +} + +function summarizeRuntimeReadyTask(value) { + const task = recordOrNull(value); + if (task === null) return { present: false, name: null, runAfter: [], when: [] }; + return { + present: true, + name: stringOrNull(task.name), + runAfter: compactStringArray(task.runAfter, 4), + when: compactWhenList(task.when, 4), + }; +} + +function summarizeAppliedPipeline(value, pipelineName, namespace) { + const metadata = recordOrNull(value?.metadata); + return { + pipelineName: stringOrNull(metadata?.name) || pipelineName, + namespace: stringOrNull(metadata?.namespace) || namespace, + resourceVersion: stringOrNull(metadata?.resourceVersion), + annotations: compactMetadataMap(metadata?.annotations, [ + "sourceConfig", + "ciContract", + "policy", + "hwlab.pikastech.local/source-commit", + "tekton.dev/pipelines.minVersion", + ], 6), + labels: compactMetadataMap(metadata?.labels, [ + "hwlab.pikastech.local/source-commit", + "app.kubernetes.io/name", + "app.kubernetes.io/part-of", + "app.kubernetes.io/component", + ], 6), + degradedReason: metadata === null ? "apply-response-metadata-missing" : null, + }; +} + +function compactMetadataMap(value, preferredKeys, limit) { + const record = recordOrNull(value); + if (record === null) return null; + const output = {}; + for (const key of preferredKeys) { + const item = stringOrNull(record[key]); + if (item === null || output[key] !== undefined) continue; + output[key] = item; + if (Object.keys(output).length >= limit) return output; + } + for (const key of Object.keys(record).sort()) { + const item = stringOrNull(record[key]); + if (item === null || output[key] !== undefined) continue; + output[key] = item; + if (Object.keys(output).length >= limit) break; + } + return Object.keys(output).length === 0 ? null : output; +} + +function compactStringArray(value, limit) { + return Array.isArray(value) + ? value.map((item) => stringOrNull(item)).filter(Boolean).slice(0, limit) + : []; +} + +function compactWhenList(value, limit) { + return Array.isArray(value) + ? value.map((item) => recordOrNull(item)).filter(Boolean).slice(0, limit).map((item) => ({ + input: stringOrNull(item.input), + operator: stringOrNull(item.operator), + values: compactStringArray(item.values, 4), + })) + : []; +} + +function parseJsonObject(text) { + try { + const parsed = JSON.parse(text); + return recordOrNull(parsed); + } catch { + return null; + } +} + +function recordOrNull(value) { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null; +} + +function stringOrNull(value) { + return typeof value === "string" && value.length > 0 ? value : null; +} + function emit(extra) { process.stdout.write(`${JSON.stringify({ ...extra, diff --git a/scripts/native/cicd/read-state-summary.mjs b/scripts/native/cicd/read-state-summary.mjs index 3b1d9639..63042c9e 100644 --- a/scripts/native/cicd/read-state-summary.mjs +++ b/scripts/native/cicd/read-state-summary.mjs @@ -292,16 +292,69 @@ function compactRefreshEvidence(refresh) { jobName: stringOrNull(value.jobName) ?? stringOrNull(summary.jobName), namespace: stringOrNull(value.namespace) ?? stringOrNull(summary.namespace), status: stringOrNull(summary.status), - pipeline: stringOrNull(summary.pipeline), + pipeline: stringOrNull(summary.pipeline) || stringOrNull(recordOrNull(summary.apply)?.pipelineName) || stringOrNull(recordOrNull(summary.render)?.pipelineName), sourceCommit: stringOrNull(summary.sourceCommit), sourceStageRef: stringOrNull(summary.sourceStageRef), elapsedMs: numberOrNull(summary.elapsedMs), + render: compactRefreshRender(recordOrNull(summary.render)), + apply: compactRefreshApply(recordOrNull(summary.apply)), sourceAuthority: stringOrNull(summary.sourceAuthority), statusAuthority: stringOrNull(summary.statusAuthority), parsedDownstreamCliOutput: false, }; } +function compactRefreshRender(value) { + if (value === null) return null; + return { + pipelineName: stringOrNull(value.pipelineName), + taskCount: numberOrNull(value.taskCount), + runtimeReadyTask: compactRefreshRuntimeReady(recordOrNull(value.runtimeReadyTask)), + }; +} + +function compactRefreshApply(value) { + if (value === null) return null; + return { + pipelineName: stringOrNull(value.pipelineName), + namespace: stringOrNull(value.namespace), + resourceVersion: stringOrNull(value.resourceVersion), + annotations: compactStringMap(recordOrNull(value.annotations)), + labels: compactStringMap(recordOrNull(value.labels)), + degradedReason: stringOrNull(value.degradedReason), + }; +} + +function compactRefreshRuntimeReady(value) { + if (value === null) return null; + return { + present: booleanOrNull(value.present), + name: stringOrNull(value.name), + runAfter: compactStringArray(value.runAfter, 4), + when: compactWhenList(value.when, 4), + }; +} + +function compactWhenList(value, limit) { + return Array.isArray(value) + ? value.map((item) => recordOrNull(item)).filter(Boolean).slice(0, limit).map((item) => ({ + input: stringOrNull(item.input), + operator: stringOrNull(item.operator), + values: compactStringArray(item.values, 4), + })) + : []; +} + +function compactStringMap(value) { + if (value === null) return null; + const output = {}; + for (const [key, item] of Object.entries(value).slice(0, 8)) { + const text = stringOrNull(item); + if (text !== null) output[key] = text; + } + return Object.keys(output).length === 0 ? null : output; +} + function compactArgo(argo) { const value = recordOrNull(argo); if (value === null) return null; @@ -492,6 +545,14 @@ function numberOrNull(value) { return typeof value === "number" && Number.isFinite(value) ? value : null; } +function booleanOrNull(value) { + return value === true ? true : value === false ? false : null; +} + +function compactStringArray(value, limit) { + return Array.isArray(value) ? value.map((item) => stringOrNull(item)).filter(Boolean).slice(0, limit) : []; +} + const result = await readConfigMap(); const errors = []; const stateByFollower = {}; diff --git a/scripts/src/cicd-debug.ts b/scripts/src/cicd-debug.ts index e769bae3..d74c3af4 100644 --- a/scripts/src/cicd-debug.ts +++ b/scripts/src/cicd-debug.ts @@ -457,16 +457,73 @@ function compactRefreshEvidence(value: unknown): Record | null jobName: stringOrNull(refresh.jobName), namespace: stringOrNull(refresh.namespace), status: stringOrNull(refresh.status), - pipeline: stringOrNull(refresh.pipeline), + pipeline: stringOrNull(refresh.pipeline) ?? stringOrNull(asOptionalRecord(refresh.apply)?.pipelineName) ?? stringOrNull(asOptionalRecord(refresh.render)?.pipelineName), sourceCommit: stringOrNull(refresh.sourceCommit), sourceStageRef: stringOrNull(refresh.sourceStageRef), elapsedMs: numberOrNull(refresh.elapsedMs), + render: compactRefreshRender(asOptionalRecord(refresh.render)), + apply: compactRefreshApply(asOptionalRecord(refresh.apply)), sourceAuthority: stringOrNull(refresh.sourceAuthority), statusAuthority: stringOrNull(refresh.statusAuthority), parsedDownstreamCliOutput: false, }; } +function compactRefreshRender(value: Record | null): Record | null { + if (value === null) return null; + return { + pipelineName: stringOrNull(value.pipelineName), + taskCount: numberOrNull(value.taskCount), + runtimeReadyTask: compactRefreshRuntimeReady(asOptionalRecord(value.runtimeReadyTask)), + }; +} + +function compactRefreshApply(value: Record | null): Record | null { + if (value === null) return null; + return { + pipelineName: stringOrNull(value.pipelineName), + namespace: stringOrNull(value.namespace), + resourceVersion: stringOrNull(value.resourceVersion), + annotations: compactStringMap(asOptionalRecord(value.annotations)), + labels: compactStringMap(asOptionalRecord(value.labels)), + degradedReason: stringOrNull(value.degradedReason), + }; +} + +function compactRefreshRuntimeReady(value: Record | null): Record | null { + if (value === null) return null; + return { + present: booleanOrNull(value.present), + name: stringOrNull(value.name), + runAfter: compactStringArray(value.runAfter, 4), + when: compactWhenList(value.when, 4), + }; +} + +function compactWhenList(value: unknown, limit: number): Array> { + return Array.isArray(value) + ? value + .map((item) => asOptionalRecord(item)) + .filter((item): item is Record => item !== null) + .slice(0, limit) + .map((item) => ({ + input: stringOrNull(item.input), + operator: stringOrNull(item.operator), + values: compactStringArray(item.values, 4), + })) + : []; +} + +function compactStringMap(value: Record | null): Record | null { + if (value === null) return null; + const output: Record = {}; + for (const [key, item] of Object.entries(value).slice(0, 8)) { + const text = stringOrNull(item); + if (text !== null) output[key] = text; + } + return Object.keys(output).length === 0 ? null : output; +} + function arrayRecords(value: unknown): Record[] { return Array.isArray(value) ? value.filter((item): item is Record => typeof item === "object" && item !== null && !Array.isArray(item)) : []; } @@ -555,6 +612,14 @@ function numberOrNull(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; } +function booleanOrNull(value: unknown): boolean | null { + return value === true ? true : value === false ? false : null; +} + +function compactStringArray(value: unknown, limit: number): string[] { + return Array.isArray(value) ? value.map((item) => stringOrNull(item)).filter((item): item is string => item !== null).slice(0, limit) : []; +} + function shortSha(value: string | null): string { if (value === null) return "-"; return value.length > 12 ? value.slice(0, 12) : value; diff --git a/scripts/src/cicd-drilldown-render.ts b/scripts/src/cicd-drilldown-render.ts index c208602d..2bf60cac 100644 --- a/scripts/src/cicd-drilldown-render.ts +++ b/scripts/src/cicd-drilldown-render.ts @@ -37,6 +37,7 @@ function renderJobHuman(payload: Record): string { const pods = arrayRecords(result?.pods); const logs = arrayRecords(result?.logs); const errors = arrayRecords(result?.errors); + const summaryEvidence = refreshEvidenceRows(asOptionalRecord(result?.summary)); const command = asOptionalRecord(payload.command); const identity = asOptionalRecord(command?.identity); return [ @@ -59,6 +60,7 @@ function renderJobHuman(payload: Record): string { pods.length === 0 ? "" : `\nPODS\n${table(["POD", "PHASE", "READY", "START", "CONTAINERS", "REASON"], pods.map(jobPodRow))}`, logs.length === 0 ? "" : `\nLOG TAILS\n${table(["POD", "CONTAINER", "STATUS", "REASON", "LINES", "BYTES", "TIMING", "MESSAGE"], logs.map(logRow))}`, errors.length === 0 ? "" : `\nERRORS\n${table(["POD", "CONTAINER", "REASON", "MESSAGE"], errors.map((item) => [item.pod, item.container, item.degradedReason, item.message]))}`, + summaryEvidence.length === 0 ? "" : `\nEVIDENCE\n${table(["TYPE", "STATUS", "DETAIL", "OBJECT"], summaryEvidence)}`, command === null ? "" : `\nTARGET COMMAND\n${table(["ROUTE", "SCRIPT", "EXIT", "PARSE_ERROR"], [[identity?.route ?? "-", identity?.script ?? "-", command.exitCode ?? "-", command.parseError ?? "-"]])}`, command?.stdoutTail ? `\nSTDOUT_TAIL\n${command.stdoutTail}` : "", command?.stderrTail ? `\nSTDERR_TAIL\n${command.stderrTail}` : "", @@ -287,11 +289,37 @@ function nativeGateRows(native: Record | null): unknown[][] { `${shortSha(stringOrNull(refresh.sourceCommit))}/${stringOrNull(refresh.pipeline) ?? "-"}`, stringOrNull(refresh.jobName) ?? "-", ]); + rows.push(...refreshEvidenceRows(refresh)); } for (const error of arrayTextItems(native.errors).slice(0, 5)) rows.push(["error", "present", error, "-"]); return rows; } +function refreshEvidenceRows(value: Record | null): unknown[][] { + if (value === null) return []; + const rows: unknown[][] = []; + const render = asOptionalRecord(value.render); + const renderRuntimeReady = asOptionalRecord(render?.runtimeReadyTask); + if (render !== null) { + rows.push([ + "control-plane-render", + renderRuntimeReady?.present === true ? "runtime-ready-present" : renderRuntimeReady?.present === false ? "runtime-ready-absent" : "-", + whenSummary(arrayRecords(renderRuntimeReady?.when)[0]), + stringOrNull(render.pipelineName) ?? "-", + ]); + } + const apply = asOptionalRecord(value.apply); + if (apply !== null) { + rows.push([ + "control-plane-apply", + stringOrNull(apply.resourceVersion) ?? stringOrNull(apply.degradedReason) ?? "-", + applyMetadataSummary(apply), + stringOrNull(apply.pipelineName) ?? "-", + ]); + } + return rows; +} + function taskRunDetail(item: Record | undefined): string { if (item === undefined) return "-"; const duration = numberOrNull(item.durationSeconds); @@ -308,6 +336,24 @@ function argoDetail(argo: Record): string { ?? shortSha(stringOrNull(argo.revision)); } +function whenSummary(value: Record | undefined): string { + if (value === undefined) return "-"; + const values = arrayTextItems(value.values).join(","); + return `${stringOrNull(value.input) ?? "-"} ${stringOrNull(value.operator) ?? "-"} ${values || "-"}`; +} + +function applyMetadataSummary(value: Record): string { + const annotations = asOptionalRecord(value.annotations); + const labels = asOptionalRecord(value.labels); + return `ann:${firstEntry(annotations)} label:${firstEntry(labels)}`; +} + +function firstEntry(value: Record | null): string { + if (value === null) return "-"; + const [key, item] = Object.entries(value)[0] ?? []; + return key === undefined ? "-" : `${key}=${stringOrNull(item) ?? "-"}`; +} + function asOptionalRecord(value: unknown): Record | null { return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : null; } diff --git a/scripts/src/cicd-evidence.ts b/scripts/src/cicd-evidence.ts index 9bbe097f..e59a4bae 100644 --- a/scripts/src/cicd-evidence.ts +++ b/scripts/src/cicd-evidence.ts @@ -9,10 +9,12 @@ export function compactRefreshEvidence(value: Record | null): R jobName: stringOrNull(value.jobName) ?? stringOrNull(summary.jobName), namespace: stringOrNull(value.namespace) ?? stringOrNull(summary.namespace), status: stringOrNull(summary.status), - pipeline: stringOrNull(summary.pipeline), + pipeline: stringOrNull(summary.pipeline) ?? stringOrNull(asOptionalRecord(summary.apply)?.pipelineName) ?? stringOrNull(asOptionalRecord(summary.render)?.pipelineName), sourceCommit: stringOrNull(summary.sourceCommit), sourceStageRef: stringOrNull(summary.sourceStageRef), elapsedMs: numberOrNull(summary.elapsedMs), + render: compactRefreshRender(asOptionalRecord(summary.render)), + apply: compactRefreshApply(asOptionalRecord(summary.apply)), sourceAuthority: stringOrNull(summary.sourceAuthority), statusAuthority: stringOrNull(summary.statusAuthority), parsedDownstreamCliOutput: false, @@ -34,7 +36,10 @@ export function followerEvidenceSummary(input: { if (tekton === null && pipeline === null && refresh === null) return null; const pipelineRefName = stringOrNull(tekton?.pipelineRefName); const pipelineName = stringOrNull(asOptionalRecord(pipeline?.metadata)?.name); - const refreshPipeline = stringOrNull(refresh?.pipeline); + const refreshRender = asOptionalRecord(refresh?.render); + const refreshApply = asOptionalRecord(refresh?.apply); + const refreshRenderedPipeline = stringOrNull(refreshRender?.pipelineName); + const refreshPipeline = stringOrNull(refreshApply?.pipelineName) ?? stringOrNull(refresh?.pipeline); const refreshSourceCommit = stringOrNull(refresh?.sourceCommit); return { pipelineRunRefName: pipelineRefName, @@ -46,11 +51,67 @@ export function followerEvidenceSummary(input: { ...refresh, pipelineRefMatches: pipelineRefName === null || refreshPipeline === null ? null : pipelineRefName === refreshPipeline, pipelineSpecMatches: pipelineName === null || refreshPipeline === null ? null : pipelineName === refreshPipeline, + renderedPipelineMatchesApplied: refreshRenderedPipeline === null || refreshPipeline === null ? null : refreshRenderedPipeline === refreshPipeline, sourceCommitMatches: input.observedSha === null || refreshSourceCommit === null ? null : input.observedSha === refreshSourceCommit, }, }; } +function compactRefreshRender(value: Record | null): Record | null { + if (value === null) return null; + return { + pipelineName: stringOrNull(value.pipelineName), + taskCount: numberOrNull(value.taskCount), + runtimeReadyTask: compactRefreshRuntimeReady(asOptionalRecord(value.runtimeReadyTask)), + }; +} + +function compactRefreshApply(value: Record | null): Record | null { + if (value === null) return null; + return { + pipelineName: stringOrNull(value.pipelineName), + namespace: stringOrNull(value.namespace), + resourceVersion: stringOrNull(value.resourceVersion), + annotations: compactStringMap(asOptionalRecord(value.annotations)), + labels: compactStringMap(asOptionalRecord(value.labels)), + degradedReason: stringOrNull(value.degradedReason), + }; +} + +function compactRefreshRuntimeReady(value: Record | null): Record | null { + if (value === null) return null; + return { + present: booleanOrNull(value.present), + name: stringOrNull(value.name), + runAfter: compactStringArray(value.runAfter, 4), + when: compactWhenList(value.when, 4), + }; +} + +function compactWhenList(value: unknown, limit: number): Array> { + return Array.isArray(value) + ? value + .map((item) => asOptionalRecord(item)) + .filter((item): item is Record => item !== null) + .slice(0, limit) + .map((item) => ({ + input: stringOrNull(item.input), + operator: stringOrNull(item.operator), + values: compactStringArray(item.values, 4), + })) + : []; +} + +function compactStringMap(value: Record | null): Record | null { + if (value === null) return null; + const output: Record = {}; + for (const [key, item] of Object.entries(value).slice(0, 8)) { + const text = stringOrNull(item); + if (text !== null) output[key] = text; + } + return Object.keys(output).length === 0 ? null : output; +} + function firstRecord(...values: Array | null>): Record | null { for (const value of values) { if (value !== null) return value; @@ -69,3 +130,11 @@ function stringOrNull(value: unknown): string | null { function numberOrNull(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; } + +function booleanOrNull(value: unknown): boolean | null { + return value === true ? true : value === false ? false : null; +} + +function compactStringArray(value: unknown, limit: number): string[] { + return Array.isArray(value) ? value.map((item) => stringOrNull(item)).filter((item): item is string => item !== null).slice(0, limit) : []; +} diff --git a/scripts/src/cicd-render.ts b/scripts/src/cicd-render.ts index 19935379..c3370e7f 100644 --- a/scripts/src/cicd-render.ts +++ b/scripts/src/cicd-render.ts @@ -264,6 +264,27 @@ function evidenceRowsForFollower(item: Record): unknown[][] { : `${shortSha(stringOrNull(refresh.sourceCommit))}/${boolMatch(refresh.pipelineRefMatches)}/${boolMatch(refresh.pipelineSpecMatches)}`, refresh === null ? "-" : stringOrNull(refresh.pipeline) ?? "-", ]); + const refreshRender = asOptionalRecord(refresh?.render); + const refreshRenderRuntimeReady = asOptionalRecord(refreshRender?.runtimeReadyTask); + if (refreshRender !== null) { + rows.push([ + item.id, + "refresh-render", + refreshRenderRuntimeReady?.present === true ? "runtime-ready-present" : refreshRenderRuntimeReady?.present === false ? "runtime-ready-absent" : "-", + whenSummary(arrayRecords(refreshRenderRuntimeReady?.when)[0]), + stringOrNull(refreshRender.pipelineName) ?? "-", + ]); + } + const refreshApply = asOptionalRecord(refresh?.apply); + if (refreshApply !== null) { + rows.push([ + item.id, + "refresh-apply", + stringOrNull(refreshApply.resourceVersion) ?? stringOrNull(refreshApply.degradedReason) ?? "-", + applyMetadataSummary(refreshApply), + stringOrNull(refreshApply.pipelineName) ?? "-", + ]); + } return rows; } @@ -313,6 +334,25 @@ function boolMatch(value: unknown): string { return value === true ? "match" : value === false ? "mismatch" : "-"; } +function whenSummary(value: Record | undefined): string { + if (value === undefined) return "-"; + const values = arrayText(value.values); + return `${stringOrNull(value.input) ?? "-"} ${stringOrNull(value.operator) ?? "-"} ${values || "-"}`; +} + +function applyMetadataSummary(value: Record): string { + const annotations = asOptionalRecord(value.annotations); + const labels = asOptionalRecord(value.labels); + const annotation = annotations === null ? "-" : `${firstEntry(annotations)}`; + const label = labels === null ? "-" : `${firstEntry(labels)}`; + return `ann:${annotation} label:${label}`; +} + +function firstEntry(value: Record): string { + const [key, item] = Object.entries(value)[0] ?? []; + return key === undefined ? "-" : `${key}=${stringOrNull(item) ?? "-"}`; +} + function asOptionalRecord(value: unknown): Record | null { return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : null; }