feat(sentinel): recover cicd child json dumps
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { resolveSentinelChildJson } from "./hwlab-node-web-sentinel-cicd-shared";
|
||||
|
||||
describe("sentinel CI/CD child JSON recovery", () => {
|
||||
test("recovers remote probe JSON from trans truncation dump", () => {
|
||||
const dir = join(tmpdir(), `unidesk-sentinel-cicd-${Date.now()}-${process.pid}`);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const dumpPath = join(dir, "stdout.json");
|
||||
writeFileSync(dumpPath, JSON.stringify({
|
||||
ok: true,
|
||||
present: true,
|
||||
digest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
valuesRedacted: true,
|
||||
}));
|
||||
const summary = {
|
||||
stdout: {
|
||||
stream: "stdout",
|
||||
truncated: true,
|
||||
dumpPath,
|
||||
valuesRedacted: true,
|
||||
},
|
||||
valuesRedacted: true,
|
||||
};
|
||||
const resolved = resolveSentinelChildJson({
|
||||
stdout: "tail fragment that is not json",
|
||||
stderr: `UNIDESK_SSH_TRUNCATION_SUMMARY ${JSON.stringify(summary)}\n`,
|
||||
exitCode: 0,
|
||||
timedOut: false,
|
||||
}, "web-probe-sentinel-test-probe");
|
||||
expect(resolved.parsed?.ok).toBe(true);
|
||||
expect(resolved.parsed?.present).toBe(true);
|
||||
expect(resolved.diagnostics.stdoutKind).toBe("ssh-truncation-summary");
|
||||
expect(resolved.diagnostics.dumpPath).toBe(dumpPath);
|
||||
expect(resolved.diagnostics.source).toBe("dump");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,986 @@
|
||||
// SPEC: PJ2026-01060508 Web哨兵 draft-2026-07-01-p16-cicd-source-snapshot.
|
||||
// Responsibility: shared types, config helpers and text render utilities for web-probe sentinel CI/CD.
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { resolveCliChildJsonCommandResult } from "./cli-child-json-recovery";
|
||||
import type { CommandResult } from "./command";
|
||||
import { rootPath } from "./config";
|
||||
import type { HwlabRuntimeLaneSpec } from "./hwlab-node-lanes";
|
||||
import type { RenderedCliResult } from "./output";
|
||||
|
||||
export type WebProbeSentinelConfigAction = "plan" | "status";
|
||||
export type WebProbeSentinelImageAction = "status" | "build";
|
||||
export type WebProbeSentinelControlPlaneAction = "plan" | "apply" | "status" | "trigger-current";
|
||||
export type WebProbeSentinelPublishAction = "publish-current";
|
||||
export type WebProbeSentinelMaintenanceAction = "status" | "start" | "stop";
|
||||
export type WebProbeSentinelDashboardAction = "verify" | "screenshot" | "trigger";
|
||||
export type WebProbeSentinelReportView = "summary" | "turn-summary" | "findings" | "trace-frame" | "auth-session-switch-summary";
|
||||
|
||||
export type WebProbeSentinelOptions =
|
||||
| {
|
||||
readonly kind: "config";
|
||||
readonly action: WebProbeSentinelConfigAction;
|
||||
readonly node: string;
|
||||
readonly lane: string;
|
||||
readonly sentinelId: string | null;
|
||||
readonly dryRun: boolean;
|
||||
}
|
||||
| {
|
||||
readonly kind: "image";
|
||||
readonly action: WebProbeSentinelImageAction;
|
||||
readonly node: string;
|
||||
readonly lane: string;
|
||||
readonly sentinelId: string | null;
|
||||
readonly dryRun: boolean;
|
||||
readonly confirm: boolean;
|
||||
readonly wait: boolean;
|
||||
readonly timeoutSeconds: number;
|
||||
}
|
||||
| {
|
||||
readonly kind: "control-plane";
|
||||
readonly action: WebProbeSentinelControlPlaneAction;
|
||||
readonly node: string;
|
||||
readonly lane: string;
|
||||
readonly sentinelId: string | null;
|
||||
readonly dryRun: boolean;
|
||||
readonly confirm: boolean;
|
||||
readonly wait: boolean;
|
||||
readonly timeoutSeconds: number;
|
||||
readonly rerun: boolean;
|
||||
}
|
||||
| {
|
||||
readonly kind: "publish";
|
||||
readonly action: WebProbeSentinelPublishAction;
|
||||
readonly node: string;
|
||||
readonly lane: string;
|
||||
readonly sentinelId: string | null;
|
||||
readonly dryRun: boolean;
|
||||
readonly confirm: boolean;
|
||||
readonly wait: boolean;
|
||||
readonly timeoutSeconds: number;
|
||||
readonly rerun: boolean;
|
||||
}
|
||||
| {
|
||||
readonly kind: "maintenance";
|
||||
readonly action: WebProbeSentinelMaintenanceAction;
|
||||
readonly node: string;
|
||||
readonly lane: string;
|
||||
readonly sentinelId: string | null;
|
||||
readonly dryRun: boolean;
|
||||
readonly confirm: boolean;
|
||||
readonly wait: boolean;
|
||||
readonly timeoutSeconds: number;
|
||||
readonly releaseId: string | null;
|
||||
readonly reason: string | null;
|
||||
readonly quickVerify: boolean;
|
||||
}
|
||||
| {
|
||||
readonly kind: "validate";
|
||||
readonly action: "validate";
|
||||
readonly node: string;
|
||||
readonly lane: string;
|
||||
readonly sentinelId: string | null;
|
||||
readonly dryRun: boolean;
|
||||
readonly confirm: boolean;
|
||||
readonly wait: boolean;
|
||||
readonly timeoutSeconds: number;
|
||||
readonly quickVerify: boolean;
|
||||
}
|
||||
| {
|
||||
readonly kind: "report";
|
||||
readonly action: "report";
|
||||
readonly node: string;
|
||||
readonly lane: string;
|
||||
readonly sentinelId: string | null;
|
||||
readonly view: WebProbeSentinelReportView;
|
||||
readonly runId: string | null;
|
||||
readonly latest: boolean;
|
||||
readonly traceId: string | null;
|
||||
readonly sampleSeq: number | null;
|
||||
readonly raw: boolean;
|
||||
readonly full: boolean;
|
||||
readonly timeoutSeconds: number;
|
||||
}
|
||||
| {
|
||||
readonly kind: "dashboard";
|
||||
readonly action: WebProbeSentinelDashboardAction;
|
||||
readonly node: string;
|
||||
readonly lane: string;
|
||||
readonly sentinelId: string | null;
|
||||
readonly viewport: string;
|
||||
readonly localDir: string;
|
||||
readonly name: string | null;
|
||||
readonly runId: string | null;
|
||||
readonly timeoutMs: number;
|
||||
readonly waitTimeoutMs: number;
|
||||
readonly timeoutSeconds: number;
|
||||
readonly commandTimeoutSeconds: number;
|
||||
readonly fullPage: boolean;
|
||||
readonly raw: boolean;
|
||||
};
|
||||
|
||||
export interface SentinelCicdState {
|
||||
readonly spec: HwlabRuntimeLaneSpec;
|
||||
readonly sentinelId: string;
|
||||
readonly configRefs: Record<string, string>;
|
||||
readonly configReady: boolean;
|
||||
readonly runtime: Record<string, unknown>;
|
||||
readonly cicd: Record<string, unknown>;
|
||||
readonly scenarios: unknown;
|
||||
readonly publicExposure: Record<string, unknown>;
|
||||
readonly secrets: Record<string, unknown>;
|
||||
readonly controlPlaneTarget: Record<string, unknown>;
|
||||
readonly controlPlaneNode: Record<string, unknown>;
|
||||
readonly sourceHead: SourceHead;
|
||||
readonly image: SentinelImagePlan;
|
||||
readonly manifests: readonly Record<string, unknown>[];
|
||||
readonly manifestSha256: string;
|
||||
readonly valuesRedacted: true;
|
||||
}
|
||||
|
||||
export interface SourceHead {
|
||||
readonly ok: boolean;
|
||||
readonly repository: string;
|
||||
readonly branch: string;
|
||||
readonly commit: string | null;
|
||||
readonly stageRef: string | null;
|
||||
readonly mirrorCommit: string | null;
|
||||
readonly sourceAuthority: "git-mirror-cache" | "git-mirror-snapshot";
|
||||
readonly latestDrift: boolean;
|
||||
readonly result: CompactCommandResult;
|
||||
}
|
||||
|
||||
export interface SentinelImagePlan {
|
||||
readonly repository: string;
|
||||
readonly tag: string;
|
||||
readonly ref: string;
|
||||
readonly digestRef: string | null;
|
||||
readonly baseImage: string;
|
||||
readonly buildContext: string;
|
||||
readonly entrypoint: string;
|
||||
readonly dockerfileSha256: string;
|
||||
readonly dockerfilePreview: string;
|
||||
readonly monitorWeb: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SentinelObservedStatus {
|
||||
readonly sourceMirror: Record<string, unknown>;
|
||||
readonly registry: Record<string, unknown>;
|
||||
readonly gitMirror: Record<string, unknown>;
|
||||
readonly gitops: Record<string, unknown>;
|
||||
readonly argo: Record<string, unknown>;
|
||||
readonly runtime: Record<string, unknown>;
|
||||
readonly cadence: Record<string, unknown>;
|
||||
readonly wait?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SentinelObservedExpectation {
|
||||
readonly gitopsRevision: string | null;
|
||||
readonly runtimeImage: string | null;
|
||||
}
|
||||
|
||||
export interface SentinelRemoteJobResult {
|
||||
readonly ok: boolean;
|
||||
readonly phase: string;
|
||||
readonly resourceKind?: "Job" | "PipelineRun";
|
||||
readonly jobName: string;
|
||||
readonly payload: Record<string, unknown>;
|
||||
readonly polls?: number;
|
||||
readonly elapsedMs?: number;
|
||||
readonly create?: Record<string, unknown>;
|
||||
readonly probe?: Record<string, unknown>;
|
||||
readonly diagnostics?: Record<string, unknown>;
|
||||
readonly valuesRedacted: true;
|
||||
}
|
||||
|
||||
export interface CompactCommandResult {
|
||||
readonly exitCode: number | null;
|
||||
readonly timedOut: boolean;
|
||||
readonly stdoutBytes: number;
|
||||
readonly stderrBytes: number;
|
||||
readonly stdoutPreview: string;
|
||||
readonly stderrPreview: string;
|
||||
}
|
||||
|
||||
export interface ChildCliResult {
|
||||
readonly ok: boolean;
|
||||
readonly parsed: Record<string, unknown> | null;
|
||||
readonly result: CompactCommandResult & { stdoutTail: string; stderrTail: string };
|
||||
}
|
||||
|
||||
export function sentinelSourceSnapshotStageRefPrefix(cicd: Record<string, unknown>): string {
|
||||
const branch = stringAt(cicd, "source.branch");
|
||||
const repository = stringAt(cicd, "source.repository");
|
||||
const prefix = stringAt(cicd, "sourceSnapshot.stageRefPrefix")
|
||||
.replaceAll("{branch}", branch)
|
||||
.replaceAll("{repository}", repository)
|
||||
.replace(/\/+$/u, "");
|
||||
if (!prefix.startsWith("refs/")) throw new Error("sourceSnapshot.stageRefPrefix must resolve to a git ref prefix");
|
||||
return prefix;
|
||||
}
|
||||
|
||||
export function sentinelSourceSnapshotRef(cicd: Record<string, unknown>, commit: string): string {
|
||||
return `${sentinelSourceSnapshotStageRefPrefix(cicd)}/${commit}`;
|
||||
}
|
||||
|
||||
export function monitorWebCicdPlan(spec: HwlabRuntimeLaneSpec, cicd: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
stack: stringAtNullable(cicd, "monitorWeb.frontendStack") ?? "vue3-vendored-browser-build",
|
||||
runtimeMode: stringAtNullable(cicd, "monitorWeb.runtimeMode") ?? "runner-served-bridge",
|
||||
assetRoot: stringAtNullable(cicd, "monitorWeb.assetRoot") ?? "scripts/assets/web-probe-sentinel-monitor-web",
|
||||
verifyCommand: "bun scripts/verify-web-probe-sentinel-monitor-web.ts",
|
||||
gitMirrorReadUrl: stringAt(cicd, "source.gitMirrorReadUrl"),
|
||||
sourceMode: stringAt(cicd, "builder.sourceMode"),
|
||||
envReuseMode: stringAtNullable(cicd, "monitorWeb.envReuse.mode") ?? "k8s-buildkit-and-ci-node-deps",
|
||||
envReuseNodeDepsPath: stringAtNullable(cicd, "monitorWeb.envReuse.nodeDepsPath") ?? "/opt/hwlab-ci-node-deps/node_modules",
|
||||
verifyPhase: stringAtNullable(cicd, "monitorWeb.imageBuild.verifyPhase") ?? "pre-image-build",
|
||||
imageBuildBuilder: spec.buildkit?.sidecarImage ?? null,
|
||||
imageBuildPackageMode: stringAtNullable(cicd, "monitorWeb.imageBuild.packageMode") ?? "copy-only-containerfile",
|
||||
imageBuildNetworkMode: monitorWebImageBuildNetworkMode(cicd),
|
||||
imageBuildProxySource: stringAtNullable(cicd, "monitorWeb.imageBuild.proxySource") ?? "node.networkProfile.imageBuildProxy",
|
||||
imageBuildContextIgnore: stringAtNullable(cicd, "monitorWeb.imageBuild.contextIgnore") ?? "generated",
|
||||
imageBuildState: monitorWebBuildkitStatePlan(cicd),
|
||||
ciBudgetSeconds: numberAtNullable(cicd, "monitorWeb.ciBudget.maxSeconds") ?? numberAt(cicd, "confirmWait.maxSeconds"),
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function monitorWebImageBuildNetworkMode(cicd: Record<string, unknown>): "default" | "host" {
|
||||
const value = stringAtNullable(cicd, "monitorWeb.imageBuild.networkMode") ?? "host";
|
||||
if (value !== "default" && value !== "host") throw new Error(`monitorWeb.imageBuild.networkMode must be default or host, got ${value}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function monitorWebBuildkitStatePlan(cicd: Record<string, unknown>): Record<string, unknown> {
|
||||
const state = recordTarget(valueAtPath(cicd, "monitorWeb.imageBuild.buildkitState"), "monitorWeb.imageBuild.buildkitState");
|
||||
const mode = stringAt(state, "mode");
|
||||
if (mode === "hostPath") {
|
||||
return {
|
||||
mode,
|
||||
path: stringAt(state, "path"),
|
||||
type: stringAt(state, "type"),
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
if (mode === "persistentVolumeClaim") {
|
||||
return {
|
||||
mode,
|
||||
claimName: stringAt(state, "claimName"),
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
if (mode === "emptyDir") {
|
||||
return {
|
||||
mode,
|
||||
sizeLimit: stringAt(state, "sizeLimit"),
|
||||
valuesRedacted: true,
|
||||
};
|
||||
}
|
||||
throw new Error(`monitorWeb.imageBuild.buildkitState.mode must be hostPath, persistentVolumeClaim or emptyDir, got ${mode}`);
|
||||
}
|
||||
|
||||
export function secretSourcePaths(sourceRef: string): string[] {
|
||||
if (sourceRef.startsWith(".env/")) return ownerFileSourcePaths(sourceRef);
|
||||
const paths = [join(repoRoot, ".state", "secrets", sourceRef)];
|
||||
const marker = "/.worktree/";
|
||||
const index = repoRoot.indexOf(marker);
|
||||
if (index >= 0) paths.push(join(repoRoot.slice(0, index), ".state", "secrets", sourceRef));
|
||||
return [...new Set(paths)];
|
||||
}
|
||||
|
||||
export function ownerFileSourcePaths(sourceRef: string): string[] {
|
||||
if (sourceRef.includes("..") || sourceRef.includes("\0")) return [];
|
||||
const marker = "/.worktree/";
|
||||
const index = repoRoot.indexOf(marker);
|
||||
const roots = index >= 0 ? [repoRoot.slice(0, index), repoRoot] : [repoRoot];
|
||||
return [...new Set(roots.map((root) => join(root, sourceRef)))];
|
||||
}
|
||||
|
||||
export function parseEnvFile(textValue: string): Record<string, string> {
|
||||
const values: Record<string, string> = {};
|
||||
for (const rawLine of textValue.split(/\r?\n/u)) {
|
||||
const line = rawLine.trim();
|
||||
if (line.length === 0 || line.startsWith("#")) continue;
|
||||
const index = line.indexOf("=");
|
||||
if (index <= 0) continue;
|
||||
const key = line.slice(0, index).trim();
|
||||
let value = line.slice(index + 1).trim();
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) value = value.slice(1, -1);
|
||||
values[key] = value;
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
export function stringAtNullable(value: unknown, path: string): string | null {
|
||||
const found = valueAtPath(value, path);
|
||||
return typeof found === "string" && found.length > 0 ? found : null;
|
||||
}
|
||||
|
||||
export function numberAtNullable(value: unknown, path: string): number | null {
|
||||
const found = valueAtPath(value, path);
|
||||
return typeof found === "number" && Number.isFinite(found) ? found : null;
|
||||
}
|
||||
|
||||
export function booleanAtNullable(value: unknown, path: string): boolean | null {
|
||||
const found = valueAtPath(value, path);
|
||||
return typeof found === "boolean" ? found : null;
|
||||
}
|
||||
|
||||
export function displayPath(pathValue: string): string {
|
||||
if (pathValue.startsWith(`${repoRoot}/`)) return pathValue.slice(repoRoot.length + 1);
|
||||
const marker = "/.worktree/";
|
||||
const index = repoRoot.indexOf(marker);
|
||||
if (index >= 0) {
|
||||
const mainRoot = repoRoot.slice(0, index);
|
||||
if (pathValue.startsWith(`${mainRoot}/`)) return pathValue.slice(mainRoot.length + 1);
|
||||
}
|
||||
return pathValue;
|
||||
}
|
||||
|
||||
export function sentinelPipelineRunName(state: SentinelCicdState, rerun = false): string {
|
||||
const commit = state.sourceHead.commit ?? "source";
|
||||
const base = `hwlab-web-probe-sentinel-${safeKubernetesSegment(state.sentinelId, 24)}-${commit.slice(0, 12)}`;
|
||||
if (!rerun) return base;
|
||||
const suffix = `-r${Date.now().toString(36)}`;
|
||||
return `${base.slice(0, Math.max(1, 63 - suffix.length)).replace(/-+$/u, "")}${suffix}`;
|
||||
}
|
||||
|
||||
export function sentinelCliSuffix(state: SentinelCicdState): string {
|
||||
return ` --sentinel ${state.sentinelId}`;
|
||||
}
|
||||
|
||||
export function safeJobSegment(value: string): string {
|
||||
return value.replace(/[^A-Za-z0-9_]+/gu, "_").replace(/^_+|_+$/gu, "").slice(0, 48) || "sentinel";
|
||||
}
|
||||
|
||||
export function safeKubernetesSegment(value: string, maxLength: number): string {
|
||||
const normalized = value.toLowerCase().replace(/[^a-z0-9-]+/gu, "-").replace(/^-+|-+$/gu, "");
|
||||
return (normalized || "sentinel").slice(0, Math.max(1, maxLength)).replace(/-+$/u, "") || "sentinel";
|
||||
}
|
||||
|
||||
export function renderPublishResult(publish: Record<string, unknown>): string {
|
||||
const payload = record(publish.payload);
|
||||
const diagnostics = record(publish.diagnostics);
|
||||
const diagnosticEnvReuse = record(diagnostics.envReuse);
|
||||
const envReuse = Object.keys(record(payload.envReuse)).length > 0 ? record(payload.envReuse) : diagnosticEnvReuse;
|
||||
const imageBuild = record(payload.imageBuild);
|
||||
const imageBuildProxy = record(imageBuild.proxy);
|
||||
const payloadStageTimings = record(payload.stageTimings);
|
||||
const diagnosticStageTimings = record(diagnostics.stageTimings);
|
||||
const timings = Object.keys(payloadStageTimings).length > 0 ? payloadStageTimings : diagnosticStageTimings;
|
||||
const commands = record(diagnostics.commands);
|
||||
const proxySummary = [imageBuildProxy.httpProxyPresent, imageBuildProxy.httpsProxyPresent, imageBuildProxy.allProxyPresent].some((item) => item === true) ? "present" : "none";
|
||||
const runColumn = diagnostics.resourceKind === "PipelineRun" || publish.resourceKind === "PipelineRun" ? "PIPELINERUN" : "JOB";
|
||||
const lines = [
|
||||
"PUBLISH",
|
||||
table(["OK", "PHASE", runColumn, "ELAPSED", "POD", "CURRENT", "DIGEST", "GITOPS"], [[
|
||||
publish.ok,
|
||||
publish.phase,
|
||||
publish.jobName,
|
||||
publish.elapsedMs ?? "-",
|
||||
diagnostics.pod ?? "-",
|
||||
diagnostics.currentPhase ?? "-",
|
||||
short(payload.digestRef),
|
||||
short(payload.gitopsCommit),
|
||||
]]),
|
||||
];
|
||||
if (Object.keys(envReuse).length > 0) {
|
||||
lines.push(
|
||||
"",
|
||||
"PUBLISH_ENV_REUSE",
|
||||
table(["MODE", "NODE_DEPS", "PRESENT", "ENTRIES", "LINKED", "DEPENDENCY"], [[
|
||||
envReuse.mode,
|
||||
envReuse.nodeDepsPath,
|
||||
envReuse.nodeDepsPresent,
|
||||
envReuse.nodeDepsEntries,
|
||||
envReuse.linkedNodeDeps ?? "-",
|
||||
envReuse.dependencyReuse,
|
||||
]]),
|
||||
);
|
||||
}
|
||||
if (Object.keys(imageBuild).length > 0 || Object.keys(timings).length > 0) {
|
||||
lines.push(
|
||||
"",
|
||||
"PUBLISH_BUILD",
|
||||
table(["BUILDER", "PACKAGE", "NETWORK", "PROXY", "IGNORE", "CACHE", "CACHE_LINES", "STEP_LINES", "SOURCE_MS", "VERIFY_MS", "IMAGE_MS", "GITOPS_MS", "TOTAL_MS"], [[
|
||||
imageBuild.builder ?? "-",
|
||||
imageBuild.packageMode ?? "-",
|
||||
imageBuild.networkMode ?? "-",
|
||||
proxySummary,
|
||||
imageBuild.contextIgnoreEntries ?? "-",
|
||||
imageBuild.layerCache ?? "-",
|
||||
imageBuild.cacheHitLines ?? "-",
|
||||
imageBuild.stepLines ?? "-",
|
||||
timings.sourceFetchMs ?? "-",
|
||||
timings.monitorWebVerifyMs ?? "-",
|
||||
timings.imageBuildMs ?? "-",
|
||||
timings.gitopsMs ?? "-",
|
||||
timings.totalMs ?? payload.elapsedMs ?? "-",
|
||||
]]),
|
||||
);
|
||||
}
|
||||
if (Object.keys(diagnostics).length > 0) {
|
||||
lines.push(
|
||||
"",
|
||||
"PUBLISH_DIAGNOSTICS",
|
||||
table(["TASKRUN", "POD_PHASE", "ACTIVE", "CONDITION", "COMPLETED", "RECENT_LOG"], [[
|
||||
diagnostics.taskRun ?? "-",
|
||||
diagnostics.podPhase ?? "-",
|
||||
diagnostics.active ?? "-",
|
||||
diagnostics.conditionReason ?? diagnostics.conditionStatus ?? "-",
|
||||
Array.isArray(diagnostics.completedStages) ? diagnostics.completedStages.join(",") : "-",
|
||||
diagnostics.recentLogSummary ?? "-",
|
||||
]]),
|
||||
);
|
||||
}
|
||||
if (publish.ok !== true && Object.keys(commands).length > 0) {
|
||||
lines.push(
|
||||
"",
|
||||
"PUBLISH_DRILLDOWN",
|
||||
` status: ${commands.cliStatus ?? "-"}`,
|
||||
` logs: ${commands.logs ?? "-"}`,
|
||||
` describe: ${commands.describe ?? "-"}`,
|
||||
` publish-current: ${commands.publishCurrent ?? "-"}`,
|
||||
` git-mirror: ${commands.gitMirrorStatus ?? "-"}`,
|
||||
` sync: ${commands.gitMirrorSync ?? "-"}`,
|
||||
` flush: ${commands.gitMirrorFlush ?? "-"}`,
|
||||
` apply: ${commands.controlPlaneApply ?? "-"}`,
|
||||
);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function renderPublishCurrentResult(result: Record<string, unknown>): string {
|
||||
const source = record(result.source);
|
||||
const image = record(result.image);
|
||||
const controlPlane = record(result.controlPlane);
|
||||
const publish = record(controlPlane.publish);
|
||||
const publishPayload = record(publish.payload);
|
||||
const observed = record(controlPlane.observed);
|
||||
const gitops = record(observed.gitops);
|
||||
const argo = record(observed.argo);
|
||||
const runtime = record(observed.runtime);
|
||||
const runtimeDeployment = record(record(runtime.probe).deployment);
|
||||
const health = record(result.health);
|
||||
const healthBody = record(record(health.health).bodyJson);
|
||||
const timings = record(result.timings);
|
||||
const budget = record(result.budget);
|
||||
const stageBudgets = record(result.stageBudgets);
|
||||
const validationPlan = record(result.validationPlan);
|
||||
const blocker = record(result.blocker);
|
||||
const recoveryNext = record(controlPlane.recoveryNext);
|
||||
const next = record(result.next);
|
||||
const warnings = Array.isArray(result.warnings) ? result.warnings : [];
|
||||
const slowStages = Array.isArray(result.slowStages) ? result.slowStages.map(record) : [];
|
||||
const lines = [
|
||||
String(result.command),
|
||||
"",
|
||||
table(["NODE", "LANE", "SENTINEL", "STATUS", "MODE", "BUDGET_S", "ELAPSED_S"], [[
|
||||
result.node,
|
||||
result.lane,
|
||||
result.sentinelId,
|
||||
result.ok === true ? "ok" : "blocked",
|
||||
result.mode,
|
||||
budget.maxSeconds ?? "-",
|
||||
finiteNumberOrNull(result.elapsedMs) === null ? "-" : Math.round((finiteNumberOrNull(result.elapsedMs) ?? 0) / 1000),
|
||||
]]),
|
||||
"",
|
||||
table(["SOURCE", "COMMIT", "AUTHORITY", "STAGE_REF", "IMAGE_REF", "DIGEST", "PIPELINERUN"], [[
|
||||
`${source.repository ?? "-"}@${source.branch ?? "-"}`,
|
||||
short(source.commit),
|
||||
source.sourceAuthority ?? "-",
|
||||
short(source.stageRef),
|
||||
image.ref ?? "-",
|
||||
short(publishPayload.digestRef ?? record(record(observed.registry).probe).digest),
|
||||
result.pipelineRun ?? publish.jobName ?? "-",
|
||||
]]),
|
||||
"",
|
||||
table(["GITOPS_REV", "ARGO_REV", "ARGO", "RUNTIME_IMAGE", "RUNTIME_READY", "HEALTH"], [[
|
||||
short(gitops.revision),
|
||||
short(argo.revision),
|
||||
`${argo.syncStatus ?? "-"}/${argo.healthStatus ?? "-"}`,
|
||||
short(runtimeDeployment.image),
|
||||
`${runtimeDeployment.readyReplicas ?? "-"}/${runtimeDeployment.desiredReplicas ?? "-"}`,
|
||||
health.ok === true ? "pass" : health.skipped === true ? `skipped:${text(health.reason)}` : Object.keys(health).length === 0 ? "planned" : "blocked",
|
||||
]]),
|
||||
"",
|
||||
table(["SOURCE_SYNC_MS", "SOURCE_FETCH_MS", "VERIFY_MS", "IMAGE_MS", "GITOPS_MS", "ARGO_RUNTIME_MS", "VALIDATION_MS", "TOTAL_MS"], [[
|
||||
timings.sourceSyncMs ?? "-",
|
||||
timings.sourceFetchMs ?? "-",
|
||||
timings.monitorWebVerifyMs ?? "-",
|
||||
timings.imageBuildMs ?? "-",
|
||||
timings.gitopsMs ?? "-",
|
||||
timings.argoRuntimeMs ?? "-",
|
||||
timings.healthValidationMs ?? "-",
|
||||
timings.totalMs ?? "-",
|
||||
]]),
|
||||
"",
|
||||
table(["BUDGET_SOURCE", "SOURCE_SYNC", "SOURCE_FETCH", "VERIFY", "IMAGE", "GITOPS", "ARGO_RUNTIME", "VALIDATION"], [[
|
||||
"YAML publishCurrent",
|
||||
stageBudgets.sourceSyncSeconds ?? "-",
|
||||
stageBudgets.sourceFetchSeconds ?? "-",
|
||||
stageBudgets.monitorWebVerifySeconds ?? "-",
|
||||
stageBudgets.imageBuildSeconds ?? "-",
|
||||
stageBudgets.gitopsSeconds ?? "-",
|
||||
stageBudgets.argoRuntimeSeconds ?? "-",
|
||||
stageBudgets.dashboardVerifySeconds ?? "-",
|
||||
]]),
|
||||
];
|
||||
if (Object.keys(publish).length > 0) {
|
||||
const payloadImageBuild = record(publishPayload.imageBuild);
|
||||
const payloadEnvReuse = record(publishPayload.envReuse);
|
||||
lines.push(
|
||||
"",
|
||||
table(["ENV_REUSE", "NODE_DEPS", "BUILD_PACKAGE", "BUILD_NETWORK", "CACHE", "CACHE_LINES"], [[
|
||||
payloadEnvReuse.dependencyReuse ?? "-",
|
||||
payloadEnvReuse.nodeDepsPresent ?? "-",
|
||||
payloadImageBuild.packageMode ?? "-",
|
||||
payloadImageBuild.networkMode ?? "-",
|
||||
payloadImageBuild.layerCache ?? "-",
|
||||
payloadImageBuild.cacheHitLines ?? "-",
|
||||
]]),
|
||||
);
|
||||
}
|
||||
lines.push(
|
||||
"",
|
||||
Object.keys(health).length === 0
|
||||
? "HEALTH_VALIDATION\n-"
|
||||
: table(["ENDPOINT", "HTTP", "OK", "STATUS", "PUBLIC_URL", "INTERNAL_URL"], [[
|
||||
health.endpoint ?? validationPlan.endpoint ?? "-",
|
||||
health.httpStatus ?? "-",
|
||||
healthBody.ok ?? "-",
|
||||
healthBody.status ?? "-",
|
||||
health.publicUrl ?? "-",
|
||||
health.internalUrl ?? "-",
|
||||
]]),
|
||||
"",
|
||||
slowStages.length === 0 ? "SLOW_STAGES\n-" : [
|
||||
"SLOW_STAGES",
|
||||
table(["STAGE", "ELAPSED_MS", "BUDGET_S", "SUGGESTION"], slowStages.map((stage) => [stage.stage, stage.elapsedMs, stage.budgetSeconds, stage.suggestion])),
|
||||
].join("\n"),
|
||||
"",
|
||||
warnings.length === 0 ? "WARNINGS\n-" : ["WARNINGS", ...warnings.map((item) => `- ${text(item)}`)].join("\n"),
|
||||
"",
|
||||
Object.keys(blocker).length === 0 ? "BLOCKER\n-" : ["BLOCKER", table(["CODE", "REASON"], [[blocker.code, blocker.reason]])].join("\n"),
|
||||
"",
|
||||
Object.keys(recoveryNext).length === 0 ? "RECOVERY_NEXT\n-" : [
|
||||
"RECOVERY_NEXT",
|
||||
table(["REASON", "PIPELINERUN", "DIGEST", "GITOPS"], [[recoveryNext.reason, recoveryNext.pipelineRun ?? "-", short(recoveryNext.digestRef), short(recoveryNext.gitopsCommit)]]),
|
||||
` publish-current: ${recoveryNext.publishCurrent ?? "-"}`,
|
||||
` status: ${recoveryNext.nextStatus ?? "-"}`,
|
||||
` git-mirror: ${recoveryNext.gitMirrorStatus ?? "-"}`,
|
||||
` sync: ${recoveryNext.gitMirrorSync ?? "-"}`,
|
||||
` flush: ${recoveryNext.gitMirrorFlush ?? "-"}`,
|
||||
` apply: ${recoveryNext.controlPlaneApply ?? "-"}`,
|
||||
].join("\n"),
|
||||
"",
|
||||
"NEXT",
|
||||
` publish-current: ${next.publishCurrent ?? "-"}`,
|
||||
` status: ${next.controlPlaneStatus ?? "-"}`,
|
||||
` post-deploy-dashboard: ${next.dashboardVerify ?? "-"}`,
|
||||
` git-mirror: ${next.gitMirrorStatus ?? "-"}`,
|
||||
` sync: ${next.gitMirrorSync ?? "-"}`,
|
||||
` flush: ${next.gitMirrorFlush ?? "-"}`,
|
||||
"",
|
||||
"DISCLOSURE",
|
||||
` end-to-end and stage budgets are read from ${Object.keys(validationPlan).length > 0 ? "publishCurrent YAML and runtime.healthPath" : "YAML-required publishCurrent fields"}.`,
|
||||
" CI/CD validation only checks the configured health endpoint; web-probe, Playwright and browser dashboard checks are post-deploy evidence, not this gate.",
|
||||
" image build uses Tekton PipelineRun and BuildKit; this command does not require Docker daemon/socket/build.",
|
||||
);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function renderImageResult(result: Record<string, unknown>): string {
|
||||
const source = record(result.source);
|
||||
const sourceMirror = record(result.sourceMirror);
|
||||
const sourceMirrorSync = record(result.sourceMirrorSync);
|
||||
const image = record(result.image);
|
||||
const monitorWeb = record(image.monitorWeb);
|
||||
const registry = record(result.registry);
|
||||
const publish = record(result.publish);
|
||||
const blocker = record(result.blocker);
|
||||
const next = record(result.next);
|
||||
const warnings = Array.isArray(result.warnings) ? result.warnings : [];
|
||||
return [
|
||||
String(result.command),
|
||||
"",
|
||||
table(["NODE", "LANE", "STATUS", "MODE", "MUTATION"], [[result.node, result.lane, result.ok === true ? "ok" : "blocked", result.mode, result.mutation]]),
|
||||
"",
|
||||
table(["SOURCE_REPO", "BRANCH", "COMMIT", "AUTHORITY", "STAGE_REF", "MIRROR"], [[source.repository, source.branch, short(source.commit), source.sourceAuthority ?? "-", short(source.stageRef), short(source.mirrorCommit)]]),
|
||||
"",
|
||||
Object.keys(sourceMirror).length === 0 ? "SOURCE_MIRROR\n-" : table(["OK", "MODE", "COMMIT", "EXPECTED", "READ_URL"], [[sourceMirror.ok, record(sourceMirror.probe).mode, short(record(sourceMirror.probe).commit), short(record(sourceMirror.probe).expectedCommit), record(sourceMirror.probe).readUrl ?? "-"]]),
|
||||
"",
|
||||
table(["IMAGE", "BASE", "ENTRYPOINT", "DOCKERFILE"], [[image.ref, image.baseImage, image.entrypoint, short(image.dockerfileSha256)]]),
|
||||
"",
|
||||
Object.keys(monitorWeb).length === 0 ? "MONITOR_WEB\n-" : table(["STACK", "MODE", "ASSETS", "VERIFY", "ENV_REUSE", "IMAGE_BUILDER", "BUILD_PKG", "BUILD_NET", "BUILD_STATE", "CTX_IGNORE"], [[monitorWeb.stack, monitorWeb.runtimeMode, monitorWeb.assetRoot, monitorWeb.verifyCommand, `${monitorWeb.envReuseMode}:${monitorWeb.envReuseNodeDepsPath}`, monitorWeb.imageBuildBuilder ?? "-", monitorWeb.imageBuildPackageMode, monitorWeb.imageBuildNetworkMode, `${record(monitorWeb.imageBuildState).mode ?? "-"}:${record(monitorWeb.imageBuildState).path ?? record(monitorWeb.imageBuildState).claimName ?? record(monitorWeb.imageBuildState).sizeLimit ?? "-"}`, monitorWeb.imageBuildContextIgnore]]),
|
||||
"",
|
||||
Object.keys(registry).length === 0 ? "REGISTRY\n-" : table(["PROBED", "PRESENT", "DIGEST"], [[record(registry.probe).url ?? "-", record(registry.probe).present ?? "-", short(record(registry.probe).digest)]]),
|
||||
"",
|
||||
Object.keys(sourceMirrorSync).length === 0 ? "SOURCE_MIRROR_SYNC\n-" : table(["OK", "PHASE", "JOB", "COMMIT", "STAGE_REF", "ELAPSED"], [[sourceMirrorSync.ok, sourceMirrorSync.phase, sourceMirrorSync.jobName, short(record(sourceMirrorSync.payload).mirrorCommit), short(record(sourceMirrorSync.payload).stageRef), sourceMirrorSync.elapsedMs ?? "-"]]),
|
||||
"",
|
||||
Object.keys(publish).length === 0 ? "PUBLISH\n-" : renderPublishResult(publish),
|
||||
"",
|
||||
warnings.length === 0 ? "WARNINGS\n-" : ["WARNINGS", ...warnings.map((item) => `- ${text(item)}`)].join("\n"),
|
||||
"",
|
||||
Object.keys(blocker).length === 0 ? "BLOCKER\n-" : ["BLOCKER", table(["CODE", "REASON"], [[blocker.code, blocker.reason]])].join("\n"),
|
||||
"",
|
||||
"NEXT",
|
||||
` status: ${next.status ?? "-"}`,
|
||||
` dry-run: ${next.dryRun ?? "-"}`,
|
||||
` confirm: ${next.confirm ?? "-"}`,
|
||||
` trigger: ${next.controlPlaneTrigger ?? "-"}`,
|
||||
` control-plane: ${next.controlPlanePlan ?? "-"}`,
|
||||
"",
|
||||
"DISCLOSURE",
|
||||
" valuesRedacted=true; image status shows refs, hashes and object names only.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function renderControlPlaneResult(result: Record<string, unknown>): string {
|
||||
const source = record(result.source);
|
||||
const image = record(result.image);
|
||||
const gitops = record(result.gitops);
|
||||
const argo = record(result.argo);
|
||||
const validation = record(result.validation);
|
||||
const observability = record(result.observability);
|
||||
const observed = record(result.observed);
|
||||
const sourceMirrorSync = record(result.sourceMirrorSync);
|
||||
const publish = record(result.publish);
|
||||
const flush = record(result.flush);
|
||||
const runtimeSecretsApply = record(result.runtimeSecretsApply);
|
||||
const publicExposureApply = record(result.publicExposureApply);
|
||||
const publicExposureCaddy = record(publicExposureApply.caddy);
|
||||
const argoApply = record(result.argoApply);
|
||||
const blocker = record(result.blocker);
|
||||
const statusDiagnosis = record(result.statusDiagnosis);
|
||||
const targetValidation = record(result.targetValidation);
|
||||
const targetValidationBusiness = record(targetValidation.businessStatus);
|
||||
const recoveryNext = record(result.recoveryNext);
|
||||
const next = record(result.next);
|
||||
const warnings = Array.isArray(result.warnings) ? result.warnings : [];
|
||||
return [
|
||||
String(result.command),
|
||||
"",
|
||||
table(["NODE", "LANE", "STATUS", "MODE", "PIPELINERUN"], [[result.node, result.lane, result.ok === true ? "ok" : "blocked", result.mode, result.pipelineRun]]),
|
||||
"",
|
||||
table(["SOURCE", "COMMIT", "AUTHORITY", "STAGE_REF", "IMAGE", "MANIFEST"], [[`${source.repository}@${source.branch}`, short(source.commit), source.sourceAuthority ?? "-", short(source.stageRef), image.ref, short(gitops.manifestSha256)]]),
|
||||
"",
|
||||
table(["GITOPS_PATH", "ARGO_APP", "TARGET_REV", "OBJECTS"], [[gitops.path, argo.applicationName, gitops.targetRevision, gitops.manifestObjects]]),
|
||||
"",
|
||||
table(["SCENARIO", "MAX_SECONDS", "CI_WAIT", "QVERIFY", "SECOND_PATH"], [[validation.scenarioId, validation.maxSeconds, validation.controlPlaneWaitMaxSeconds ?? "-", validation.quickVerifyMode ?? "-", validation.automaticSecondPath]]),
|
||||
"",
|
||||
Object.keys(observability).length === 0 ? "OTEL\n-" : table(["ENABLED", "ENDPOINT", "SERVICE", "COVERAGE"], [[observability.enabled, observability.endpointConfigured, observability.serviceName, observability.coverage]]),
|
||||
"",
|
||||
renderObservedStatus(observed),
|
||||
"",
|
||||
Object.keys(statusDiagnosis).length === 0 ? "STATUS_DIAGNOSIS\n-" : [
|
||||
"STATUS_DIAGNOSIS",
|
||||
table(["CODE", "PHASE", "PIPELINERUN", "SOURCE", "REGISTRY", "GIT_MIRROR", "GITOPS", "ARGO", "RUNTIME"], [[
|
||||
statusDiagnosis.code,
|
||||
statusDiagnosis.phase,
|
||||
statusDiagnosis.pipelineRun,
|
||||
statusDiagnosis.sourceMirror,
|
||||
statusDiagnosis.registry,
|
||||
statusDiagnosis.gitMirror,
|
||||
statusDiagnosis.gitops,
|
||||
statusDiagnosis.argo,
|
||||
statusDiagnosis.runtime,
|
||||
]]),
|
||||
].join("\n"),
|
||||
"",
|
||||
Object.keys(sourceMirrorSync).length === 0 ? "SOURCE_MIRROR_SYNC\n-" : table(["OK", "PHASE", "JOB", "COMMIT", "STAGE_REF", "ELAPSED"], [[sourceMirrorSync.ok, sourceMirrorSync.phase, sourceMirrorSync.jobName, short(record(sourceMirrorSync.payload).mirrorCommit), short(record(sourceMirrorSync.payload).stageRef), sourceMirrorSync.elapsedMs ?? "-"]]),
|
||||
"",
|
||||
Object.keys(targetValidation).length === 0 ? "TARGET_VALIDATION\n-" : table(["OK", "STATUS", "BUSINESS", "ERROR_TITLE", "SCENARIO", "RUN", "OBSERVER", "REPORT", "FINDINGS", "ARTIFACTS"], [[
|
||||
targetValidation.ok,
|
||||
targetValidation.status,
|
||||
targetValidationBusiness.status ?? "-",
|
||||
targetValidation.errorTitleZh ?? targetValidation.failureTitleZh ?? targetValidationBusiness.errorTitleZh ?? "-",
|
||||
targetValidation.scenarioId,
|
||||
targetValidation.runId,
|
||||
targetValidation.observerId,
|
||||
short(targetValidation.reportJsonSha256),
|
||||
targetValidation.findingCount,
|
||||
targetValidation.artifactCount,
|
||||
]]),
|
||||
"",
|
||||
Object.keys(publish).length === 0 ? "PUBLISH\n-" : renderPublishResult(publish),
|
||||
"",
|
||||
Object.keys(flush).length === 0
|
||||
? "FLUSH\n-"
|
||||
: flush.mode === "async-job"
|
||||
? table(["OK", "MODE", "JOB", "STATUS"], [[flush.ok, flush.mode, record(flush.job).id, record(flush.next).status]])
|
||||
: table(["OK", "EXIT", "TIMED_OUT", "PREVIEW"], [[flush.ok, record(flush.result).exitCode, record(flush.result).timedOut, record(flush.result).stdoutPreview]]),
|
||||
"",
|
||||
Object.keys(runtimeSecretsApply).length === 0 ? "RUNTIME_SECRETS\n-" : table(["OK", "SECRETS", "KEYS", "SKIPPED"], [[runtimeSecretsApply.ok, runtimeSecretsApply.secretCount ?? "-", runtimeSecretsApply.keyCount ?? "-", runtimeSecretsApply.skippedKeyCount ?? "-"]]),
|
||||
"",
|
||||
Object.keys(publicExposureApply).length === 0 ? "PUBLIC_EXPOSURE_APPLY\n-" : table(["OK", "SECRET", "CADDY", "HOST", "ROUTE_HTTP"], [[publicExposureApply.ok, record(publicExposureApply.secret).ok, record(publicExposureApply.caddy).ok, publicExposureApply.hostname, record(publicExposureApply.caddy).routeProbeHttpStatus ?? "-"]]),
|
||||
"",
|
||||
Object.keys(publicExposureCaddy).length === 0 || publicExposureCaddy.ok === true
|
||||
? "CADDY_APPLY_DETAIL\n-"
|
||||
: table(["PY", "VALIDATE", "RELOAD", "PROBE", "HTTP", "BLOCK", "ACTIVE", "ERROR", "STDOUT", "STDERR"], [[publicExposureCaddy.pythonExitCode, publicExposureCaddy.validateExitCode, publicExposureCaddy.reloadExitCode, publicExposureCaddy.routeProbeExitCode, publicExposureCaddy.routeProbeHttpStatus, publicExposureCaddy.afterBlockPresent, publicExposureCaddy.active, short(publicExposureCaddy.errorPreview), short(record(publicExposureCaddy.result).stdoutPreview), short(record(publicExposureCaddy.result).stderrPreview)]]),
|
||||
"",
|
||||
Object.keys(argoApply).length === 0 ? "ARGO_APPLY\n-" : table(["OK", "EXIT", "PREVIEW"], [[argoApply.ok, record(argoApply.result).exitCode, record(argoApply.result).stdoutPreview]]),
|
||||
"",
|
||||
warnings.length === 0 ? "WARNINGS\n-" : ["WARNINGS", ...warnings.map((item) => `- ${text(item)}`)].join("\n"),
|
||||
"",
|
||||
Object.keys(blocker).length === 0 ? "BLOCKER\n-" : ["BLOCKER", table(["CODE", "REASON"], [[blocker.code, blocker.reason]])].join("\n"),
|
||||
"",
|
||||
Object.keys(recoveryNext).length === 0 ? "RECOVERY_NEXT\n-" : [
|
||||
"RECOVERY_NEXT",
|
||||
table(["REASON", "PIPELINERUN", "DIGEST", "GITOPS"], [[recoveryNext.reason, recoveryNext.pipelineRun ?? "-", short(recoveryNext.digestRef), short(recoveryNext.gitopsCommit)]]),
|
||||
` publish-current: ${recoveryNext.publishCurrent ?? "-"}`,
|
||||
` status: ${recoveryNext.nextStatus ?? "-"}`,
|
||||
` git-mirror: ${recoveryNext.gitMirrorStatus ?? "-"}`,
|
||||
` sync: ${recoveryNext.gitMirrorSync ?? "-"}`,
|
||||
` flush: ${recoveryNext.gitMirrorFlush ?? "-"}`,
|
||||
` apply: ${recoveryNext.controlPlaneApply ?? "-"}`,
|
||||
].join("\n"),
|
||||
"",
|
||||
"NEXT",
|
||||
` plan: ${next.plan ?? "-"}`,
|
||||
` status: ${next.status ?? "-"}`,
|
||||
` image: ${next.image ?? "-"}`,
|
||||
` trigger-current: ${next.triggerCurrent ?? "-"}`,
|
||||
` apply: ${next.apply ?? "-"}`,
|
||||
` validate: ${next.validate ?? "-"}`,
|
||||
` quick-verify: ${next.quickVerify ?? "-"}`,
|
||||
` git-mirror: ${next.gitMirrorStatus ?? "-"}`,
|
||||
` sync: ${next.gitMirrorSync ?? "-"}`,
|
||||
` flush: ${next.gitMirrorFlush ?? "-"}`,
|
||||
"",
|
||||
"DISCLOSURE",
|
||||
" default view is a bounded CI/CD summary; full manifest content is represented by object counts and sha256.",
|
||||
" sentinel unavailable policy is structured-failure; no automatic second execution path is rendered.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function renderObservedStatus(observed: Record<string, unknown>): string {
|
||||
const rows = [
|
||||
observedStatusRow("source", observed.sourceMirror),
|
||||
observedStatusRow("registry", observed.registry),
|
||||
observedStatusRow("git-mirror", observed.gitMirror),
|
||||
observedStatusRow("gitops", observed.gitops),
|
||||
observedStatusRow("argo", observed.argo),
|
||||
observedStatusRow("runtime", observed.runtime),
|
||||
observedStatusRow("cadence", observed.cadence),
|
||||
].filter((row) => row !== null);
|
||||
if (rows.length === 0) return "OBSERVED\n-";
|
||||
return table(["CHECK", "OK", "DETAIL", "EXIT", "TIMED_OUT", "PREVIEW"], rows);
|
||||
}
|
||||
|
||||
export function observedStatusRow(name: string, value: unknown): unknown[] | null {
|
||||
const item = record(value);
|
||||
if (Object.keys(item).length === 0) return null;
|
||||
const result = record(item.result);
|
||||
return [name, item.ok, observedDetail(name, item), result.exitCode, result.timedOut, result.stdoutPreview];
|
||||
}
|
||||
|
||||
export function observedDetail(name: string, item: Record<string, unknown>): string {
|
||||
if (name === "source") return `${record(item.probe).mode ?? "mirror"} ${short(record(item.probe).commit)}/${short(record(item.probe).expectedCommit)}`;
|
||||
if (name === "registry") return `${record(item.probe).present === true ? "present" : "missing"} ${short(record(item.probe).digest)}`;
|
||||
if (name === "git-mirror" && item.skipped === true) return `${item.reason ?? "skipped"}`;
|
||||
if (name === "gitops") return `${short(item.revision)} image=${short(item.image)}`;
|
||||
if (name === "argo") {
|
||||
const diagnostics = record(item.diagnostics);
|
||||
const problems = Array.isArray(diagnostics.problemResources) ? diagnostics.problemResources : [];
|
||||
const first = problems.find((entry) => typeof entry === "object" && entry !== null && !Array.isArray(entry)) as Record<string, unknown> | undefined;
|
||||
const problemText = Number(diagnostics.problemResourceCount ?? 0) > 0
|
||||
? ` degraded=${diagnostics.problemResourceCount}:${first?.kind ?? "-"} ${first?.namespace ?? "-"}/${first?.name ?? "-"} ${first?.healthStatus ?? first?.status ?? "-"}`
|
||||
: "";
|
||||
return `${item.syncStatus ?? "-"} ${item.healthStatus ?? "-"} ${short(item.revision)}/${short(item.expectedRevision)}${problemText}`;
|
||||
}
|
||||
if (name === "runtime") {
|
||||
const probe = record(item.probe);
|
||||
const deployment = record(probe.deployment);
|
||||
return `ready=${deployment.readyReplicas ?? "-"} image=${short(deployment.image)}/${short(deployment.expectedImage)}`;
|
||||
}
|
||||
if (name === "cadence") {
|
||||
if (item.skipped === true) return `${item.reason ?? "skipped"}`;
|
||||
const probe = record(item.probe);
|
||||
return `${probe.code ?? "ok"} schedule=${probe.schedule ?? "-"}/${probe.expectedSchedule ?? "-"} last=${probe.lastScheduleTime ?? "-"} jobs=${probe.jobCount ?? "-"}`;
|
||||
}
|
||||
return "-";
|
||||
}
|
||||
|
||||
export function renderAsyncJobResult(result: Record<string, unknown>): string {
|
||||
const job = record(result.job);
|
||||
const next = record(result.next);
|
||||
return [
|
||||
String(result.command),
|
||||
"",
|
||||
table(["NODE", "LANE", "MODE", "MUTATION", "JOB"], [[result.node, result.lane, result.mode, result.mutation, job.id]]),
|
||||
"",
|
||||
table(["STATUS", "NAME", "CREATED"], [[job.status, job.name, job.createdAt]]),
|
||||
"",
|
||||
"NEXT",
|
||||
` status: ${next.status ?? "-"}`,
|
||||
` wait: ${next.wait ?? "-"}`,
|
||||
"",
|
||||
"DISCLOSURE",
|
||||
" confirmed operation is delegated to UniDesk job status to keep interactive calls bounded.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function rendered(ok: boolean, command: string, text: string): RenderedCliResult {
|
||||
return { ok, command, renderedText: `${text.trimEnd()}\n`, contentType: "text/plain" };
|
||||
}
|
||||
|
||||
export function readConfigFile(file: string): unknown {
|
||||
if (file.startsWith("/") || file.includes("..") || !file.startsWith("config/")) throw new Error(`unsafe configRef file: ${file}`);
|
||||
const abs = rootPath(file);
|
||||
if (!existsSync(abs)) throw new Error(`${file} does not exist`);
|
||||
return Bun.YAML.parse(readFileSync(abs, "utf8")) as unknown;
|
||||
}
|
||||
|
||||
export function configRefFile(ref: string): string {
|
||||
const [file, path, extra] = ref.split("#");
|
||||
if (extra !== undefined || file === undefined || path === undefined || file.length === 0 || path.length === 0) throw new Error(`invalid configRef: ${ref}`);
|
||||
return file;
|
||||
}
|
||||
|
||||
export function valueAtPath(value: unknown, path: string): unknown {
|
||||
let current: unknown = value;
|
||||
for (const segment of path.split(".")) {
|
||||
const match = /^([A-Za-z0-9_-]+)?(?:\[(\d+)\])?$/u.exec(segment);
|
||||
if (match === null) return undefined;
|
||||
if (match[1] !== undefined) {
|
||||
if (!isRecord(current)) return undefined;
|
||||
current = current[match[1]];
|
||||
}
|
||||
if (match[2] !== undefined) {
|
||||
if (!Array.isArray(current)) return undefined;
|
||||
current = current[Number(match[2])];
|
||||
}
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
export function stringAt(value: unknown, path: string): string {
|
||||
const found = valueAtPath(value, path);
|
||||
if (typeof found !== "string" || found.length === 0) throw new Error(`${path} must be a non-empty string`);
|
||||
return found;
|
||||
}
|
||||
|
||||
export function nonEmptyString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
export function stringField(value: Record<string, unknown>, path: string): string {
|
||||
return stringAt(value, path);
|
||||
}
|
||||
|
||||
export function stringTarget(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || value.length === 0) throw new Error(`${label} must resolve to a non-empty string`);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function numberAt(value: unknown, path: string): number {
|
||||
const found = valueAtPath(value, path);
|
||||
if (typeof found !== "number" || !Number.isFinite(found)) throw new Error(`${path} must be a number`);
|
||||
return found;
|
||||
}
|
||||
|
||||
export function booleanAt(value: unknown, path: string): boolean {
|
||||
const found = valueAtPath(value, path);
|
||||
if (typeof found !== "boolean") throw new Error(`${path} must be a boolean`);
|
||||
return found;
|
||||
}
|
||||
|
||||
export function finiteNumberOrNull(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
export function arrayAt(value: unknown, path: string): unknown[] {
|
||||
const found = valueAtPath(value, path);
|
||||
if (!Array.isArray(found)) throw new Error(`${path} must be an array`);
|
||||
return found;
|
||||
}
|
||||
|
||||
export function arrayAtNullable(value: unknown, path: string): Record<string, unknown>[] {
|
||||
const found = valueAtPath(value, path);
|
||||
return Array.isArray(found) ? found.map(record) : [];
|
||||
}
|
||||
|
||||
export function recordTarget(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!isRecord(value)) throw new Error(`${label} must resolve to an object`);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function record(value: unknown): Record<string, unknown> {
|
||||
return isRecord(value) ? value : {};
|
||||
}
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function manifestObjectSummary(items: readonly Record<string, unknown>[]): readonly Record<string, unknown>[] {
|
||||
return items.map((item) => ({
|
||||
kind: item.kind ?? null,
|
||||
name: record(item.metadata).name ?? null,
|
||||
namespace: record(item.metadata).namespace ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
export function compactCommand(result: CommandResult): CompactCommandResult {
|
||||
return {
|
||||
exitCode: result.exitCode,
|
||||
timedOut: result.timedOut,
|
||||
stdoutBytes: Buffer.byteLength(result.stdout),
|
||||
stderrBytes: Buffer.byteLength(result.stderr),
|
||||
stdoutPreview: result.stdout.trim().slice(0, 500),
|
||||
stderrPreview: result.stderr.trim().slice(0, 500),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveSentinelChildJson(result: Pick<CommandResult, "stdout" | "stderr" | "exitCode" | "timedOut">, requestedStdoutType: string): { parsed: Record<string, unknown> | null; diagnostics: Record<string, unknown> } {
|
||||
const resolved = resolveCliChildJsonCommandResult({
|
||||
result,
|
||||
requestedStdoutType,
|
||||
acceptParsed: (value) => Object.keys(value).length > 0,
|
||||
});
|
||||
return { parsed: resolved.parsed, diagnostics: resolved.diagnostics };
|
||||
}
|
||||
|
||||
export function parseJsonObject(text: string): Record<string, unknown> | null {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.length === 0) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as unknown;
|
||||
return isRecord(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function table(headers: string[], rows: unknown[][]): string {
|
||||
const normalized = [headers, ...rows.map((row) => row.map(text))];
|
||||
const widths = headers.map((_, index) => Math.max(...normalized.map((row) => text(row[index] ?? "").length)));
|
||||
return normalized.map((row) => row.map((cell, index) => text(cell).padEnd(widths[index])).join(" ").trimEnd()).join("\n");
|
||||
}
|
||||
|
||||
export function text(value: unknown): string {
|
||||
if (value === undefined || value === null || value === "") return "-";
|
||||
if (typeof value === "boolean") return value ? "true" : "false";
|
||||
return String(value).replace(/\s+/gu, " ").trim();
|
||||
}
|
||||
|
||||
export function short(value: unknown): string {
|
||||
const raw = text(value);
|
||||
if (raw === "-") return raw;
|
||||
if (/^sha256:[0-9a-f]{64}$/iu.test(raw)) return `${raw.slice(0, 19)}...`;
|
||||
if (/^[0-9a-f]{40}$/iu.test(raw)) return raw.slice(0, 12);
|
||||
return raw.length > 42 ? `${raw.slice(0, 39)}...` : raw;
|
||||
}
|
||||
|
||||
export function sha256(textValue: string): string {
|
||||
return `sha256:${createHash("sha256").update(textValue).digest("hex")}`;
|
||||
}
|
||||
|
||||
export function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/gu, "'\\''")}'`;
|
||||
}
|
||||
|
||||
export function tomlEscape(value: string): string {
|
||||
return value.replace(/\\/gu, "\\\\").replace(/"/gu, '\\"');
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user