1125 lines
42 KiB
TypeScript
1125 lines
42 KiB
TypeScript
// SPEC: PJ2026-01060501 OTel追踪 draft-2026-06-19-p0.
|
|
// Responsibility: YAML-first platform-infra OpenTelemetry tracing control commands.
|
|
import { Buffer } from "node:buffer";
|
|
import { readFileSync } from "node:fs";
|
|
import type { UniDeskConfig } from "./config";
|
|
import { rootPath } from "./config";
|
|
import { startJob } from "./jobs";
|
|
import {
|
|
compactCapture,
|
|
compactUnknown,
|
|
createYamlFieldReader,
|
|
numberField,
|
|
parseJsonOutput,
|
|
redactSensitiveUnknown,
|
|
shQuote,
|
|
capture,
|
|
} from "./platform-infra-ops-library";
|
|
|
|
const configFile = rootPath("config", "platform-infra", "observability.yaml");
|
|
const configLabel = "config/platform-infra/observability.yaml";
|
|
const fieldManager = "unidesk-platform-observability";
|
|
const {
|
|
asRecord,
|
|
objectField,
|
|
arrayOfRecords,
|
|
stringField,
|
|
integerField,
|
|
booleanField,
|
|
stringArrayField,
|
|
numberArrayField,
|
|
enumField,
|
|
kubernetesNameField,
|
|
portField,
|
|
apiPathField,
|
|
} = createYamlFieldReader(configLabel);
|
|
|
|
interface ObservabilityConfig {
|
|
version: number;
|
|
kind: "platform-infra-observability";
|
|
metadata: { id: string; owner: string; spec: string; relatedIssues: number[] };
|
|
defaults: { targetId: string };
|
|
images: {
|
|
collector: ImageSpec;
|
|
tempo: ImageSpec;
|
|
};
|
|
targets: ObservabilityTarget[];
|
|
collector: {
|
|
deploymentName: string;
|
|
serviceName: string;
|
|
configMapName: string;
|
|
replicas: number;
|
|
healthPort: number;
|
|
otlp: OtlpPorts;
|
|
};
|
|
traceBackend: {
|
|
type: "tempo";
|
|
deploymentName: string;
|
|
serviceName: string;
|
|
configMapName: string;
|
|
replicas: number;
|
|
httpPort: number;
|
|
otlp: OtlpPorts;
|
|
storage: { mode: "emptyDir"; retention: string };
|
|
};
|
|
sampling: { mode: "parentbased_traceidratio"; ratio: number };
|
|
instrumentation: {
|
|
contextPropagation: string[];
|
|
serviceConnections: ServiceConnection[];
|
|
};
|
|
resourceAttributes: {
|
|
required: string[];
|
|
businessCorrelationAttributes: string[];
|
|
};
|
|
probes: {
|
|
readinessPath: string;
|
|
traceQueryPathTemplate: string;
|
|
statusEndpoints: StatusEndpoint[];
|
|
};
|
|
}
|
|
|
|
interface ImageSpec {
|
|
repository: string;
|
|
tag: string;
|
|
pullPolicy: "Always" | "IfNotPresent" | "Never";
|
|
}
|
|
|
|
interface ObservabilityTarget {
|
|
id: string;
|
|
route: string;
|
|
namespace: string;
|
|
role: "active" | "standby";
|
|
enabled: boolean;
|
|
createNamespace: boolean;
|
|
}
|
|
|
|
interface OtlpPorts {
|
|
grpcPort: number;
|
|
httpPort: number;
|
|
}
|
|
|
|
interface ServiceConnection {
|
|
serviceName: string;
|
|
owningRepo: string;
|
|
targetNode: string;
|
|
lane: string;
|
|
namespace: string;
|
|
requiredSpans: string[];
|
|
}
|
|
|
|
interface StatusEndpoint {
|
|
name: string;
|
|
service: string;
|
|
portName: string;
|
|
path: string;
|
|
}
|
|
|
|
interface CommonOptions {
|
|
targetId: string | null;
|
|
full: boolean;
|
|
raw: boolean;
|
|
}
|
|
|
|
interface ApplyOptions extends CommonOptions {
|
|
confirm: boolean;
|
|
dryRun: boolean;
|
|
wait: boolean;
|
|
}
|
|
|
|
interface TraceOptions extends CommonOptions {
|
|
traceId: string | null;
|
|
}
|
|
|
|
export function observabilityHelp(): Record<string, unknown> {
|
|
return {
|
|
command: "platform-infra observability plan|apply|status|validate|trace",
|
|
output: "json",
|
|
configTruth: "config/platform-infra/observability.yaml",
|
|
spec: "PJ2026-01060501 OTel追踪 draft-2026-06-19-p0",
|
|
usage: [
|
|
"bun scripts/cli.ts platform-infra observability plan --target D601",
|
|
"bun scripts/cli.ts platform-infra observability apply --target D601 --dry-run",
|
|
"bun scripts/cli.ts platform-infra observability apply --target D601 --confirm",
|
|
"bun scripts/cli.ts platform-infra observability status --target D601 [--full|--raw]",
|
|
"bun scripts/cli.ts platform-infra observability validate --target D601 [--full|--raw]",
|
|
"bun scripts/cli.ts platform-infra observability trace --target D601 --trace-id <traceId> [--full|--raw]",
|
|
],
|
|
boundary: "Prometheus remains the metrics source; this command owns only platform-infra OTel Collector, trace backend readiness, and trace lookup.",
|
|
};
|
|
}
|
|
|
|
export async function runPlatformObservabilityCommand(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown>> {
|
|
const [action = "plan"] = args;
|
|
if (action === "plan") return plan(parseCommonOptions(args.slice(1)));
|
|
if (action === "apply") return await apply(config, parseApplyOptions(args.slice(1)));
|
|
if (action === "status") return await status(config, parseCommonOptions(args.slice(1)));
|
|
if (action === "validate") return await validate(config, parseCommonOptions(args.slice(1)));
|
|
if (action === "trace") return await trace(config, parseTraceOptions(args.slice(1)));
|
|
return { ok: false, error: "unsupported-platform-infra-observability-command", args, help: observabilityHelp() };
|
|
}
|
|
|
|
function parseCommonOptions(args: string[]): CommonOptions {
|
|
let targetId: string | null = null;
|
|
let full = false;
|
|
let raw = false;
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index];
|
|
if (arg === "--target") {
|
|
const value = args[index + 1];
|
|
if (value === undefined || value.startsWith("--")) throw new Error("--target requires a value");
|
|
if (!/^[A-Za-z0-9._-]+$/u.test(value)) throw new Error("--target must be a simple target id");
|
|
targetId = value;
|
|
index += 1;
|
|
} else if (arg === "--full") {
|
|
full = true;
|
|
} else if (arg === "--raw") {
|
|
raw = true;
|
|
full = true;
|
|
} else {
|
|
throw new Error(`unsupported observability option: ${arg}`);
|
|
}
|
|
}
|
|
return { targetId, full, raw };
|
|
}
|
|
|
|
function parseApplyOptions(args: string[]): ApplyOptions {
|
|
const commonArgs: string[] = [];
|
|
let confirm = false;
|
|
let dryRun = false;
|
|
let wait = false;
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index];
|
|
if (arg === "--confirm") confirm = true;
|
|
else if (arg === "--dry-run") dryRun = true;
|
|
else if (arg === "--wait") wait = true;
|
|
else {
|
|
commonArgs.push(arg);
|
|
if (arg === "--target") {
|
|
commonArgs.push(args[index + 1] ?? "");
|
|
index += 1;
|
|
}
|
|
}
|
|
}
|
|
if (confirm && dryRun) throw new Error("observability apply accepts only one of --confirm or --dry-run");
|
|
return { ...parseCommonOptions(commonArgs), confirm, dryRun: dryRun || !confirm, wait };
|
|
}
|
|
|
|
function parseTraceOptions(args: string[]): TraceOptions {
|
|
const commonArgs: string[] = [];
|
|
let traceId: string | null = null;
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index];
|
|
if (arg === "--trace-id") {
|
|
const value = args[index + 1];
|
|
if (value === undefined || value.startsWith("--")) throw new Error("--trace-id requires a value");
|
|
if (!/^[A-Za-z0-9._:-]+$/u.test(value)) throw new Error("--trace-id has an unsupported format");
|
|
traceId = value;
|
|
index += 1;
|
|
} else {
|
|
commonArgs.push(arg);
|
|
if (arg === "--target") {
|
|
commonArgs.push(args[index + 1] ?? "");
|
|
index += 1;
|
|
}
|
|
}
|
|
}
|
|
return { ...parseCommonOptions(commonArgs), traceId };
|
|
}
|
|
|
|
function readObservabilityConfig(): ObservabilityConfig {
|
|
const parsed = Bun.YAML.parse(readFileSync(configFile, "utf8")) as unknown;
|
|
const root = asRecord(parsed, configLabel);
|
|
const version = integerField(root, "version", "");
|
|
const kind = stringField(root, "kind", "");
|
|
if (kind !== "platform-infra-observability") throw new Error(`${configLabel}.kind must be platform-infra-observability`);
|
|
const metadata = objectField(root, "metadata", "");
|
|
const defaults = objectField(root, "defaults", "");
|
|
const images = objectField(root, "images", "");
|
|
const collector = objectField(root, "collector", "");
|
|
const collectorOtlp = objectField(collector, "otlp", "collector");
|
|
const traceBackend = objectField(root, "traceBackend", "");
|
|
const traceBackendOtlp = objectField(traceBackend, "otlp", "traceBackend");
|
|
const traceBackendStorage = objectField(traceBackend, "storage", "traceBackend");
|
|
const sampling = objectField(root, "sampling", "");
|
|
const instrumentation = objectField(root, "instrumentation", "");
|
|
const resourceAttributes = objectField(root, "resourceAttributes", "");
|
|
const probes = objectField(root, "probes", "");
|
|
const config: ObservabilityConfig = {
|
|
version,
|
|
kind,
|
|
metadata: {
|
|
id: stringField(metadata, "id", "metadata"),
|
|
owner: stringField(metadata, "owner", "metadata"),
|
|
spec: stringField(metadata, "spec", "metadata"),
|
|
relatedIssues: numberArrayField(metadata, "relatedIssues", "metadata"),
|
|
},
|
|
defaults: { targetId: stringField(defaults, "targetId", "defaults") },
|
|
images: {
|
|
collector: imageSpec(objectField(images, "collector", "images"), "images.collector"),
|
|
tempo: imageSpec(objectField(images, "tempo", "images"), "images.tempo"),
|
|
},
|
|
targets: arrayOfRecords(root.targets, "targets").map(parseTarget),
|
|
collector: {
|
|
deploymentName: kubernetesNameField(collector, "deploymentName", "collector"),
|
|
serviceName: kubernetesNameField(collector, "serviceName", "collector"),
|
|
configMapName: kubernetesNameField(collector, "configMapName", "collector"),
|
|
replicas: integerField(collector, "replicas", "collector"),
|
|
healthPort: portField(collector, "healthPort", "collector"),
|
|
otlp: parseOtlpPorts(collectorOtlp, "collector.otlp"),
|
|
},
|
|
traceBackend: {
|
|
type: enumField(traceBackend, "type", "traceBackend", ["tempo"] as const),
|
|
deploymentName: kubernetesNameField(traceBackend, "deploymentName", "traceBackend"),
|
|
serviceName: kubernetesNameField(traceBackend, "serviceName", "traceBackend"),
|
|
configMapName: kubernetesNameField(traceBackend, "configMapName", "traceBackend"),
|
|
replicas: integerField(traceBackend, "replicas", "traceBackend"),
|
|
httpPort: portField(traceBackend, "httpPort", "traceBackend"),
|
|
otlp: parseOtlpPorts(traceBackendOtlp, "traceBackend.otlp"),
|
|
storage: {
|
|
mode: enumField(traceBackendStorage, "mode", "traceBackend.storage", ["emptyDir"] as const),
|
|
retention: stringField(traceBackendStorage, "retention", "traceBackend.storage"),
|
|
},
|
|
},
|
|
sampling: {
|
|
mode: enumField(sampling, "mode", "sampling", ["parentbased_traceidratio"] as const),
|
|
ratio: numberField(sampling, "ratio", "sampling"),
|
|
},
|
|
instrumentation: {
|
|
contextPropagation: stringArrayField(instrumentation, "contextPropagation", "instrumentation"),
|
|
serviceConnections: arrayOfRecords(instrumentation.serviceConnections, "instrumentation.serviceConnections").map(parseServiceConnection),
|
|
},
|
|
resourceAttributes: {
|
|
required: stringArrayField(resourceAttributes, "required", "resourceAttributes"),
|
|
businessCorrelationAttributes: stringArrayField(resourceAttributes, "businessCorrelationAttributes", "resourceAttributes"),
|
|
},
|
|
probes: {
|
|
readinessPath: apiPathField(probes, "readinessPath", "probes"),
|
|
traceQueryPathTemplate: stringField(probes, "traceQueryPathTemplate", "probes"),
|
|
statusEndpoints: arrayOfRecords(probes.statusEndpoints, "probes.statusEndpoints").map(parseStatusEndpoint),
|
|
},
|
|
};
|
|
if (config.targets.length === 0) throw new Error(`${configLabel}.targets must not be empty`);
|
|
assertKnownEnabledTarget(config.targets, config.defaults.targetId, "defaults.targetId");
|
|
if (config.collector.replicas < 0 || config.traceBackend.replicas < 0) throw new Error(`${configLabel} replicas must be >= 0`);
|
|
if (config.sampling.ratio < 0 || config.sampling.ratio > 1) throw new Error(`${configLabel}.sampling.ratio must be between 0 and 1`);
|
|
if (!config.probes.traceQueryPathTemplate.includes("{{traceId}}")) throw new Error(`${configLabel}.probes.traceQueryPathTemplate must include {{traceId}}`);
|
|
return config;
|
|
}
|
|
|
|
function imageSpec(record: Record<string, unknown>, path: string): ImageSpec {
|
|
const image = {
|
|
repository: stringField(record, "repository", path),
|
|
tag: stringField(record, "tag", path),
|
|
pullPolicy: enumField(record, "pullPolicy", path, ["Always", "IfNotPresent", "Never"] as const),
|
|
};
|
|
if (!/^[A-Za-z0-9._/:@-]+$/u.test(`${image.repository}:${image.tag}`)) throw new Error(`${configLabel}.${path} must render a valid image reference`);
|
|
return image;
|
|
}
|
|
|
|
function parseTarget(record: Record<string, unknown>, index: number): ObservabilityTarget {
|
|
const path = `targets[${index}]`;
|
|
return {
|
|
id: stringField(record, "id", path),
|
|
route: stringField(record, "route", path),
|
|
namespace: kubernetesNameField(record, "namespace", path),
|
|
role: enumField(record, "role", path, ["active", "standby"] as const),
|
|
enabled: booleanField(record, "enabled", path),
|
|
createNamespace: booleanField(record, "createNamespace", path),
|
|
};
|
|
}
|
|
|
|
function parseOtlpPorts(record: Record<string, unknown>, path: string): OtlpPorts {
|
|
return {
|
|
grpcPort: portField(record, "grpcPort", path),
|
|
httpPort: portField(record, "httpPort", path),
|
|
};
|
|
}
|
|
|
|
function parseServiceConnection(record: Record<string, unknown>, index: number): ServiceConnection {
|
|
const path = `instrumentation.serviceConnections[${index}]`;
|
|
return {
|
|
serviceName: stringField(record, "serviceName", path),
|
|
owningRepo: stringField(record, "owningRepo", path),
|
|
targetNode: stringField(record, "targetNode", path),
|
|
lane: stringField(record, "lane", path),
|
|
namespace: kubernetesNameField(record, "namespace", path),
|
|
requiredSpans: stringArrayField(record, "requiredSpans", path),
|
|
};
|
|
}
|
|
|
|
function parseStatusEndpoint(record: Record<string, unknown>, index: number): StatusEndpoint {
|
|
const path = `probes.statusEndpoints[${index}]`;
|
|
return {
|
|
name: stringField(record, "name", path),
|
|
service: kubernetesNameField(record, "service", path),
|
|
portName: stringField(record, "portName", path),
|
|
path: apiPathField(record, "path", path),
|
|
};
|
|
}
|
|
|
|
function assertKnownEnabledTarget(targets: ObservabilityTarget[], targetId: string, path: string): void {
|
|
const target = targets.find((item) => item.id.toLowerCase() === targetId.toLowerCase());
|
|
if (target === undefined) throw new Error(`${configLabel}.${path} references unknown target ${targetId}; known targets: ${targets.map((item) => item.id).join(", ")}`);
|
|
if (!target.enabled) throw new Error(`${configLabel}.${path} references disabled target ${target.id}`);
|
|
}
|
|
|
|
function resolveTarget(observability: ObservabilityConfig, targetId: string | null): ObservabilityTarget {
|
|
const resolved = targetId ?? observability.defaults.targetId;
|
|
const target = observability.targets.find((item) => item.id.toLowerCase() === resolved.toLowerCase());
|
|
if (target === undefined) throw new Error(`unknown observability target ${resolved}; known targets: ${observability.targets.map((item) => item.id).join(", ")}`);
|
|
if (!target.enabled) throw new Error(`observability target ${target.id} is disabled in ${configLabel}`);
|
|
return target;
|
|
}
|
|
|
|
function plan(options: CommonOptions): Record<string, unknown> {
|
|
const observability = readObservabilityConfig();
|
|
const target = resolveTarget(observability, options.targetId);
|
|
const yaml = renderManifest(observability, target);
|
|
const policy = policyChecks(yaml, target);
|
|
return {
|
|
ok: policy.every((check) => check.ok),
|
|
action: "platform-infra-observability-plan",
|
|
mutation: false,
|
|
config: configSummary(observability, target),
|
|
renderPlan: {
|
|
target: targetSummary(target),
|
|
objects: manifestObjectSummary(yaml),
|
|
otlp: {
|
|
collectorGrpcEndpoint: `${observability.collector.serviceName}.${target.namespace}.svc.cluster.local:${observability.collector.otlp.grpcPort}`,
|
|
collectorHttpEndpoint: `http://${observability.collector.serviceName}.${target.namespace}.svc.cluster.local:${observability.collector.otlp.httpPort}`,
|
|
backendGrpcEndpoint: `${observability.traceBackend.serviceName}.${target.namespace}.svc.cluster.local:${observability.traceBackend.otlp.grpcPort}`,
|
|
},
|
|
instrumentation: observability.instrumentation.serviceConnections,
|
|
resourceAttributes: observability.resourceAttributes,
|
|
},
|
|
policy,
|
|
next: {
|
|
dryRun: `bun scripts/cli.ts platform-infra observability apply --target ${target.id} --dry-run`,
|
|
apply: `bun scripts/cli.ts platform-infra observability apply --target ${target.id} --confirm`,
|
|
status: `bun scripts/cli.ts platform-infra observability status --target ${target.id}`,
|
|
validate: `bun scripts/cli.ts platform-infra observability validate --target ${target.id}`,
|
|
trace: `bun scripts/cli.ts platform-infra observability trace --target ${target.id} --trace-id <traceId>`,
|
|
},
|
|
};
|
|
}
|
|
|
|
async function apply(config: UniDeskConfig, options: ApplyOptions): Promise<Record<string, unknown>> {
|
|
const observability = readObservabilityConfig();
|
|
const target = resolveTarget(observability, options.targetId);
|
|
const yaml = renderManifest(observability, target);
|
|
const policy = policyChecks(yaml, target);
|
|
if (!policy.every((check) => check.ok)) return { ok: false, action: "platform-infra-observability-apply", mode: "policy-blocked", policy };
|
|
if (options.confirm && !options.wait) {
|
|
const job = startJob(
|
|
`platform_infra_observability_apply_${target.id.toLowerCase()}`,
|
|
["bun", "scripts/cli.ts", "platform-infra", "observability", "apply", "--target", target.id, "--confirm", "--wait"],
|
|
`Apply ${target.id} platform-infra OTel Collector and trace backend through the controlled UniDesk CLI`,
|
|
);
|
|
return {
|
|
ok: true,
|
|
action: "platform-infra-observability-apply",
|
|
mode: "async-job",
|
|
mutation: true,
|
|
target: targetSummary(target),
|
|
job,
|
|
statusCommand: `bun scripts/cli.ts job status ${job.id} --tail-bytes 12000`,
|
|
next: {
|
|
status: `bun scripts/cli.ts job status ${job.id} --tail-bytes 12000`,
|
|
rollout: `bun scripts/cli.ts platform-infra observability status --target ${target.id}`,
|
|
validate: `bun scripts/cli.ts platform-infra observability validate --target ${target.id}`,
|
|
},
|
|
};
|
|
}
|
|
const result = await capture(config, target.route, ["sh"], applyScript({
|
|
yaml,
|
|
target,
|
|
dryRun: options.dryRun,
|
|
wait: options.wait,
|
|
collectorDeploymentName: observability.collector.deploymentName,
|
|
backendDeploymentName: observability.traceBackend.deploymentName,
|
|
}));
|
|
const parsed = parseJsonOutput(result.stdout);
|
|
return {
|
|
ok: result.exitCode === 0 && parsed?.ok === true,
|
|
action: "platform-infra-observability-apply",
|
|
mode: options.dryRun ? "dry-run" : "confirmed",
|
|
mutation: !options.dryRun,
|
|
target: targetSummary(target),
|
|
policy,
|
|
remote: parsed ?? compactCapture(result, { full: true }),
|
|
};
|
|
}
|
|
|
|
async function status(config: UniDeskConfig, options: CommonOptions): Promise<Record<string, unknown>> {
|
|
const observability = readObservabilityConfig();
|
|
const target = resolveTarget(observability, options.targetId);
|
|
const result = await capture(config, target.route, ["sh"], statusScript(observability, target, options.full));
|
|
const parsed = parseJsonOutput(result.stdout);
|
|
const summary = parsed === null ? null : statusSummary(parsed);
|
|
return {
|
|
ok: result.exitCode === 0 && summary?.ready === true,
|
|
action: "platform-infra-observability-status",
|
|
mutation: false,
|
|
target: targetSummary(target),
|
|
summary,
|
|
remote: options.raw ? parsed : compactStatus(parsed, options.full) ?? compactCapture(result, { full: true }),
|
|
next: {
|
|
plan: `bun scripts/cli.ts platform-infra observability plan --target ${target.id}`,
|
|
apply: `bun scripts/cli.ts platform-infra observability apply --target ${target.id} --confirm`,
|
|
validate: `bun scripts/cli.ts platform-infra observability validate --target ${target.id}`,
|
|
},
|
|
};
|
|
}
|
|
|
|
async function validate(config: UniDeskConfig, options: CommonOptions): Promise<Record<string, unknown>> {
|
|
const observability = readObservabilityConfig();
|
|
const target = resolveTarget(observability, options.targetId);
|
|
const result = await capture(config, target.route, ["sh"], statusScript(observability, target, options.full));
|
|
const parsed = parseJsonOutput(result.stdout);
|
|
const summary = parsed === null ? null : statusSummary(parsed);
|
|
const ready = summary?.ready === true;
|
|
return {
|
|
ok: result.exitCode === 0 && ready,
|
|
action: "platform-infra-observability-validate",
|
|
mutation: false,
|
|
target: targetSummary(target),
|
|
summary,
|
|
validation: {
|
|
readiness: ready ? "passed" : "failed",
|
|
testTrace: "not-generated-by-this-stage",
|
|
traceQuery: ready ? `bun scripts/cli.ts platform-infra observability trace --target ${target.id} --trace-id <traceId>` : "blocked-until-runtime-ready",
|
|
metricsBoundary: "Prometheus/RUM remains outside this trace readiness check.",
|
|
},
|
|
remote: options.raw ? parsed : compactStatus(parsed, options.full) ?? compactCapture(result, { full: true }),
|
|
};
|
|
}
|
|
|
|
async function trace(config: UniDeskConfig, options: TraceOptions): Promise<Record<string, unknown>> {
|
|
if (options.traceId === null) throw new Error("observability trace requires --trace-id <traceId>");
|
|
const observability = readObservabilityConfig();
|
|
const target = resolveTarget(observability, options.targetId);
|
|
const tracePath = observability.probes.traceQueryPathTemplate.replaceAll("{{traceId}}", encodeURIComponent(options.traceId));
|
|
const result = await capture(config, target.route, ["sh"], traceScript(observability, target, tracePath));
|
|
const parsed = parseJsonOutput(result.stdout);
|
|
return {
|
|
ok: result.exitCode === 0 && parsed?.ok === true,
|
|
action: "platform-infra-observability-trace",
|
|
mutation: false,
|
|
target: targetSummary(target),
|
|
traceId: options.traceId,
|
|
query: {
|
|
backend: observability.traceBackend.type,
|
|
service: observability.traceBackend.serviceName,
|
|
path: tracePath,
|
|
},
|
|
result: options.raw ? redactSensitiveUnknown(parsed) : compactUnknown(redactSensitiveUnknown(parsed)),
|
|
};
|
|
}
|
|
|
|
function renderManifest(observability: ObservabilityConfig, target: ObservabilityTarget): string {
|
|
const collectorImage = imageReference(observability.images.collector);
|
|
const tempoImage = imageReference(observability.images.tempo);
|
|
return [
|
|
target.createNamespace ? namespaceManifest(target) : "",
|
|
allowAllNetworkPolicy(target),
|
|
collectorConfigMap(observability, target),
|
|
collectorDeployment(observability, target, collectorImage),
|
|
collectorService(observability, target),
|
|
tempoConfigMap(observability, target),
|
|
tempoDeployment(observability, target, tempoImage),
|
|
tempoService(observability, target),
|
|
].filter((item) => item.trim().length > 0).join("\n---\n");
|
|
}
|
|
|
|
function namespaceManifest(target: ObservabilityTarget): string {
|
|
return `apiVersion: v1
|
|
kind: Namespace
|
|
metadata:
|
|
name: ${target.namespace}
|
|
labels:
|
|
app.kubernetes.io/part-of: platform-infra
|
|
app.kubernetes.io/managed-by: unidesk
|
|
unidesk.ai/runtime-node: ${target.id}
|
|
`;
|
|
}
|
|
|
|
function allowAllNetworkPolicy(target: ObservabilityTarget): string {
|
|
return `apiVersion: networking.k8s.io/v1
|
|
kind: NetworkPolicy
|
|
metadata:
|
|
name: allow-all
|
|
namespace: ${target.namespace}
|
|
labels:
|
|
app.kubernetes.io/part-of: platform-infra
|
|
app.kubernetes.io/managed-by: unidesk
|
|
spec:
|
|
podSelector: {}
|
|
policyTypes:
|
|
- Ingress
|
|
- Egress
|
|
ingress:
|
|
- {}
|
|
egress:
|
|
- {}
|
|
`;
|
|
}
|
|
|
|
function collectorConfigMap(observability: ObservabilityConfig, target: ObservabilityTarget): string {
|
|
const config = `receivers:
|
|
otlp:
|
|
protocols:
|
|
grpc:
|
|
endpoint: 0.0.0.0:${observability.collector.otlp.grpcPort}
|
|
http:
|
|
endpoint: 0.0.0.0:${observability.collector.otlp.httpPort}
|
|
processors:
|
|
batch: {}
|
|
exporters:
|
|
otlp/tempo:
|
|
endpoint: ${observability.traceBackend.serviceName}.${target.namespace}.svc.cluster.local:${observability.traceBackend.otlp.grpcPort}
|
|
tls:
|
|
insecure: true
|
|
extensions:
|
|
health_check:
|
|
endpoint: 0.0.0.0:${observability.collector.healthPort}
|
|
service:
|
|
extensions: [health_check]
|
|
pipelines:
|
|
traces:
|
|
receivers: [otlp]
|
|
processors: [batch]
|
|
exporters: [otlp/tempo]
|
|
`;
|
|
return `apiVersion: v1
|
|
kind: ConfigMap
|
|
metadata:
|
|
name: ${observability.collector.configMapName}
|
|
namespace: ${target.namespace}
|
|
labels:
|
|
app.kubernetes.io/name: ${observability.collector.deploymentName}
|
|
app.kubernetes.io/component: tracing
|
|
app.kubernetes.io/part-of: platform-infra
|
|
app.kubernetes.io/managed-by: unidesk
|
|
annotations:
|
|
unidesk.ai/spec: "${observability.metadata.spec}"
|
|
data:
|
|
collector.yaml: |
|
|
${indent(config, 4)}
|
|
`;
|
|
}
|
|
|
|
function collectorDeployment(observability: ObservabilityConfig, target: ObservabilityTarget, image: string): string {
|
|
return `apiVersion: apps/v1
|
|
kind: Deployment
|
|
metadata:
|
|
name: ${observability.collector.deploymentName}
|
|
namespace: ${target.namespace}
|
|
labels:
|
|
app.kubernetes.io/name: ${observability.collector.deploymentName}
|
|
app.kubernetes.io/component: tracing
|
|
app.kubernetes.io/part-of: platform-infra
|
|
app.kubernetes.io/managed-by: unidesk
|
|
spec:
|
|
replicas: ${observability.collector.replicas}
|
|
selector:
|
|
matchLabels:
|
|
app.kubernetes.io/name: ${observability.collector.deploymentName}
|
|
app.kubernetes.io/component: tracing
|
|
template:
|
|
metadata:
|
|
labels:
|
|
app.kubernetes.io/name: ${observability.collector.deploymentName}
|
|
app.kubernetes.io/component: tracing
|
|
app.kubernetes.io/part-of: platform-infra
|
|
annotations:
|
|
unidesk.ai/spec: "${observability.metadata.spec}"
|
|
spec:
|
|
containers:
|
|
- name: collector
|
|
image: ${image}
|
|
imagePullPolicy: ${observability.images.collector.pullPolicy}
|
|
args:
|
|
- --config=/etc/otelcol/collector.yaml
|
|
ports:
|
|
- name: otlp-grpc
|
|
containerPort: ${observability.collector.otlp.grpcPort}
|
|
- name: otlp-http
|
|
containerPort: ${observability.collector.otlp.httpPort}
|
|
- name: health
|
|
containerPort: ${observability.collector.healthPort}
|
|
readinessProbe:
|
|
httpGet:
|
|
path: /
|
|
port: health
|
|
volumeMounts:
|
|
- name: config
|
|
mountPath: /etc/otelcol/collector.yaml
|
|
subPath: collector.yaml
|
|
readOnly: true
|
|
volumes:
|
|
- name: config
|
|
configMap:
|
|
name: ${observability.collector.configMapName}
|
|
`;
|
|
}
|
|
|
|
function collectorService(observability: ObservabilityConfig, target: ObservabilityTarget): string {
|
|
return `apiVersion: v1
|
|
kind: Service
|
|
metadata:
|
|
name: ${observability.collector.serviceName}
|
|
namespace: ${target.namespace}
|
|
labels:
|
|
app.kubernetes.io/name: ${observability.collector.deploymentName}
|
|
app.kubernetes.io/component: tracing
|
|
app.kubernetes.io/part-of: platform-infra
|
|
app.kubernetes.io/managed-by: unidesk
|
|
spec:
|
|
type: ClusterIP
|
|
selector:
|
|
app.kubernetes.io/name: ${observability.collector.deploymentName}
|
|
app.kubernetes.io/component: tracing
|
|
ports:
|
|
- name: otlp-grpc
|
|
port: ${observability.collector.otlp.grpcPort}
|
|
targetPort: otlp-grpc
|
|
- name: otlp-http
|
|
port: ${observability.collector.otlp.httpPort}
|
|
targetPort: otlp-http
|
|
- name: health
|
|
port: ${observability.collector.healthPort}
|
|
targetPort: health
|
|
`;
|
|
}
|
|
|
|
function tempoConfigMap(observability: ObservabilityConfig, _target: ObservabilityTarget): string {
|
|
const config = `server:
|
|
http_listen_port: ${observability.traceBackend.httpPort}
|
|
distributor:
|
|
receivers:
|
|
otlp:
|
|
protocols:
|
|
grpc:
|
|
endpoint: 0.0.0.0:${observability.traceBackend.otlp.grpcPort}
|
|
http:
|
|
endpoint: 0.0.0.0:${observability.traceBackend.otlp.httpPort}
|
|
ingester:
|
|
trace_idle_period: 10s
|
|
max_block_duration: 5m
|
|
compactor:
|
|
compaction:
|
|
block_retention: ${observability.traceBackend.storage.retention}
|
|
storage:
|
|
trace:
|
|
backend: local
|
|
wal:
|
|
path: /var/tempo/wal
|
|
local:
|
|
path: /var/tempo/traces
|
|
`;
|
|
return `apiVersion: v1
|
|
kind: ConfigMap
|
|
metadata:
|
|
name: ${observability.traceBackend.configMapName}
|
|
namespace: ${_target.namespace}
|
|
labels:
|
|
app.kubernetes.io/name: ${observability.traceBackend.deploymentName}
|
|
app.kubernetes.io/component: trace-backend
|
|
app.kubernetes.io/part-of: platform-infra
|
|
app.kubernetes.io/managed-by: unidesk
|
|
annotations:
|
|
unidesk.ai/spec: "${observability.metadata.spec}"
|
|
data:
|
|
tempo.yaml: |
|
|
${indent(config, 4)}
|
|
`;
|
|
}
|
|
|
|
function tempoDeployment(observability: ObservabilityConfig, target: ObservabilityTarget, image: string): string {
|
|
return `apiVersion: apps/v1
|
|
kind: Deployment
|
|
metadata:
|
|
name: ${observability.traceBackend.deploymentName}
|
|
namespace: ${target.namespace}
|
|
labels:
|
|
app.kubernetes.io/name: ${observability.traceBackend.deploymentName}
|
|
app.kubernetes.io/component: trace-backend
|
|
app.kubernetes.io/part-of: platform-infra
|
|
app.kubernetes.io/managed-by: unidesk
|
|
spec:
|
|
replicas: ${observability.traceBackend.replicas}
|
|
selector:
|
|
matchLabels:
|
|
app.kubernetes.io/name: ${observability.traceBackend.deploymentName}
|
|
app.kubernetes.io/component: trace-backend
|
|
template:
|
|
metadata:
|
|
labels:
|
|
app.kubernetes.io/name: ${observability.traceBackend.deploymentName}
|
|
app.kubernetes.io/component: trace-backend
|
|
app.kubernetes.io/part-of: platform-infra
|
|
annotations:
|
|
unidesk.ai/spec: "${observability.metadata.spec}"
|
|
spec:
|
|
containers:
|
|
- name: tempo
|
|
image: ${image}
|
|
imagePullPolicy: ${observability.images.tempo.pullPolicy}
|
|
args:
|
|
- -config.file=/etc/tempo/tempo.yaml
|
|
ports:
|
|
- name: http
|
|
containerPort: ${observability.traceBackend.httpPort}
|
|
- name: otlp-grpc
|
|
containerPort: ${observability.traceBackend.otlp.grpcPort}
|
|
- name: otlp-http
|
|
containerPort: ${observability.traceBackend.otlp.httpPort}
|
|
readinessProbe:
|
|
httpGet:
|
|
path: ${observability.probes.readinessPath}
|
|
port: http
|
|
volumeMounts:
|
|
- name: config
|
|
mountPath: /etc/tempo/tempo.yaml
|
|
subPath: tempo.yaml
|
|
readOnly: true
|
|
- name: data
|
|
mountPath: /var/tempo
|
|
volumes:
|
|
- name: config
|
|
configMap:
|
|
name: ${observability.traceBackend.configMapName}
|
|
- name: data
|
|
emptyDir: {}
|
|
`;
|
|
}
|
|
|
|
function tempoService(observability: ObservabilityConfig, target: ObservabilityTarget): string {
|
|
return `apiVersion: v1
|
|
kind: Service
|
|
metadata:
|
|
name: ${observability.traceBackend.serviceName}
|
|
namespace: ${target.namespace}
|
|
labels:
|
|
app.kubernetes.io/name: ${observability.traceBackend.deploymentName}
|
|
app.kubernetes.io/component: trace-backend
|
|
app.kubernetes.io/part-of: platform-infra
|
|
app.kubernetes.io/managed-by: unidesk
|
|
spec:
|
|
type: ClusterIP
|
|
selector:
|
|
app.kubernetes.io/name: ${observability.traceBackend.deploymentName}
|
|
app.kubernetes.io/component: trace-backend
|
|
ports:
|
|
- name: http
|
|
port: ${observability.traceBackend.httpPort}
|
|
targetPort: http
|
|
- name: otlp-grpc
|
|
port: ${observability.traceBackend.otlp.grpcPort}
|
|
targetPort: otlp-grpc
|
|
- name: otlp-http
|
|
port: ${observability.traceBackend.otlp.httpPort}
|
|
targetPort: otlp-http
|
|
`;
|
|
}
|
|
|
|
function applyScript(params: {
|
|
yaml: string;
|
|
target: ObservabilityTarget;
|
|
dryRun: boolean;
|
|
wait: boolean;
|
|
collectorDeploymentName: string;
|
|
backendDeploymentName: string;
|
|
}): string {
|
|
const encoded = Buffer.from(params.yaml, "utf8").toString("base64");
|
|
const dryRunArg = params.dryRun ? "--dry-run=server" : "";
|
|
const wait = params.dryRun || !params.wait
|
|
? "wait_disposition=skipped"
|
|
: [
|
|
`kubectl -n ${shQuote(params.target.namespace)} rollout status deployment/${shQuote(params.collectorDeploymentName)} --timeout=180s >"$tmp/collector-rollout.out" 2>"$tmp/collector-rollout.err"`,
|
|
"collector_rollout_rc=$?",
|
|
`kubectl -n ${shQuote(params.target.namespace)} rollout status deployment/${shQuote(params.backendDeploymentName)} --timeout=180s >"$tmp/backend-rollout.out" 2>"$tmp/backend-rollout.err"`,
|
|
"backend_rollout_rc=$?",
|
|
"wait_disposition=executed",
|
|
].join("\n");
|
|
return `
|
|
set -u
|
|
tmp="$(mktemp -d)"
|
|
trap 'rm -rf "$tmp"' EXIT
|
|
manifest="$tmp/platform-infra-observability.yaml"
|
|
printf '%s' '${encoded}' | base64 -d > "$manifest"
|
|
kubectl apply --server-side --field-manager=${fieldManager} ${dryRunArg} -f "$manifest" >"$tmp/apply.out" 2>"$tmp/apply.err"
|
|
apply_rc=$?
|
|
collector_rollout_rc=0
|
|
backend_rollout_rc=0
|
|
${wait}
|
|
python3 - "$apply_rc" "$collector_rollout_rc" "$backend_rollout_rc" "$wait_disposition" "$tmp/apply.out" "$tmp/apply.err" "$tmp/collector-rollout.out" "$tmp/collector-rollout.err" "$tmp/backend-rollout.out" "$tmp/backend-rollout.err" <<'PY'
|
|
import json, os, sys
|
|
def text(path, limit=6000):
|
|
try:
|
|
return open(path, encoding="utf-8", errors="replace").read()[-limit:]
|
|
except FileNotFoundError:
|
|
return ""
|
|
apply_rc = int(sys.argv[1])
|
|
collector_rc = int(sys.argv[2])
|
|
backend_rc = int(sys.argv[3])
|
|
payload = {
|
|
"ok": apply_rc == 0 and collector_rc == 0 and backend_rc == 0,
|
|
"target": "${params.target.id}",
|
|
"namespace": "${params.target.namespace}",
|
|
"dryRun": ${params.dryRun ? "True" : "False"},
|
|
"apply": {"exitCode": apply_rc, "stdout": text(sys.argv[5]), "stderr": text(sys.argv[6])},
|
|
"rollout": {
|
|
"disposition": sys.argv[4],
|
|
"collector": {"exitCode": collector_rc, "stdout": text(sys.argv[7]), "stderr": text(sys.argv[8])},
|
|
"backend": {"exitCode": backend_rc, "stdout": text(sys.argv[9]), "stderr": text(sys.argv[10])},
|
|
},
|
|
}
|
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
sys.exit(0 if payload["ok"] else 1)
|
|
PY
|
|
`;
|
|
}
|
|
|
|
function statusScript(observability: ObservabilityConfig, target: ObservabilityTarget, full: boolean): string {
|
|
const endpointsJson = JSON.stringify(observability.probes.statusEndpoints);
|
|
return `
|
|
set -u
|
|
tmp="$(mktemp -d)"
|
|
trap 'rm -rf "$tmp"' EXIT
|
|
capture_json() {
|
|
name="$1"
|
|
shift
|
|
"$@" >"$tmp/$name.json" 2>"$tmp/$name.err"
|
|
echo $? >"$tmp/$name.rc"
|
|
}
|
|
capture_raw() {
|
|
name="$1"
|
|
shift
|
|
"$@" >"$tmp/$name.out" 2>"$tmp/$name.err"
|
|
echo $? >"$tmp/$name.rc"
|
|
}
|
|
capture_json namespace kubectl get namespace ${shQuote(target.namespace)} -o json
|
|
capture_json deployments kubectl -n ${shQuote(target.namespace)} get deployment ${shQuote(observability.collector.deploymentName)} ${shQuote(observability.traceBackend.deploymentName)} -o json
|
|
capture_json services kubectl -n ${shQuote(target.namespace)} get service ${shQuote(observability.collector.serviceName)} ${shQuote(observability.traceBackend.serviceName)} -o json
|
|
capture_json pods kubectl -n ${shQuote(target.namespace)} get pods -l ${shQuote(`app.kubernetes.io/name in (${observability.collector.deploymentName},${observability.traceBackend.deploymentName})`)} -o json
|
|
python3 - "$tmp" '${endpointsJson.replaceAll("'", "'\"'\"'")}' <<'PY'
|
|
import json, subprocess, sys
|
|
tmp = sys.argv[1]
|
|
endpoints = json.loads(sys.argv[2])
|
|
namespace = "${target.namespace}"
|
|
def read(path, binary=False, limit=8000):
|
|
try:
|
|
mode = "rb" if binary else "r"
|
|
with open(path, mode, encoding=None if binary else "utf-8", errors=None if binary else "replace") as fh:
|
|
data = fh.read()
|
|
if binary:
|
|
data = data.decode("utf-8", errors="replace")
|
|
return data[-limit:]
|
|
except FileNotFoundError:
|
|
return ""
|
|
def rc(name):
|
|
try:
|
|
return int(read(f"{tmp}/{name}.rc").strip() or "1")
|
|
except ValueError:
|
|
return 1
|
|
def parsed_json(name):
|
|
raw = read(f"{tmp}/{name}.json", limit=1000000)
|
|
try:
|
|
return json.loads(raw) if raw else None
|
|
except Exception:
|
|
return None
|
|
def compact_section(name):
|
|
return {
|
|
"exitCode": rc(name),
|
|
"stderrTail": read(f"{tmp}/{name}.err", limit=2000),
|
|
"json": parsed_json(name),
|
|
}
|
|
probe_results = []
|
|
for ep in endpoints:
|
|
path = ep.get("path") or "/"
|
|
if not path.startswith("/"):
|
|
path = "/" + path
|
|
proxy_path = f"/api/v1/namespaces/{namespace}/services/http:{ep['service']}:{ep['portName']}/proxy{path}"
|
|
proc = subprocess.run(["kubectl", "get", "--raw", proxy_path], text=True, capture_output=True, timeout=20)
|
|
probe_results.append({
|
|
"name": ep["name"],
|
|
"service": ep["service"],
|
|
"portName": ep["portName"],
|
|
"path": path,
|
|
"exitCode": proc.returncode,
|
|
"stdoutTail": proc.stdout[-1000:],
|
|
"stderrTail": proc.stderr[-1000:],
|
|
"ok": proc.returncode == 0,
|
|
})
|
|
payload = {
|
|
"ok": rc("namespace") == 0 and rc("deployments") == 0 and rc("services") == 0 and all(item["ok"] for item in probe_results),
|
|
"target": "${target.id}",
|
|
"namespace": namespace,
|
|
"sections": {
|
|
"namespace": compact_section("namespace"),
|
|
"deployments": compact_section("deployments"),
|
|
"services": compact_section("services"),
|
|
"pods": compact_section("pods"),
|
|
},
|
|
"probes": probe_results,
|
|
}
|
|
print(json.dumps(payload, ensure_ascii=False, indent=2 if ${full ? "True" : "False"} else None))
|
|
sys.exit(0 if payload["ok"] else 1)
|
|
PY
|
|
`;
|
|
}
|
|
|
|
function traceScript(observability: ObservabilityConfig, target: ObservabilityTarget, tracePath: string): string {
|
|
const proxyPath = `/api/v1/namespaces/${target.namespace}/services/http:${observability.traceBackend.serviceName}:http/proxy${tracePath}`;
|
|
return `
|
|
set -u
|
|
tmp="$(mktemp -d)"
|
|
trap 'rm -rf "$tmp"' EXIT
|
|
kubectl get --raw ${shQuote(proxyPath)} >"$tmp/trace.out" 2>"$tmp/trace.err"
|
|
rc=$?
|
|
python3 - "$rc" "$tmp/trace.out" "$tmp/trace.err" <<'PY'
|
|
import json, sys
|
|
def text(path, limit=12000):
|
|
try:
|
|
return open(path, encoding="utf-8", errors="replace").read()[-limit:]
|
|
except FileNotFoundError:
|
|
return ""
|
|
body = text(sys.argv[2], 200000)
|
|
try:
|
|
parsed = json.loads(body) if body else None
|
|
except Exception:
|
|
parsed = body[-12000:]
|
|
payload = {
|
|
"ok": int(sys.argv[1]) == 0,
|
|
"path": "${tracePath}",
|
|
"proxyPath": "${proxyPath}",
|
|
"body": parsed,
|
|
"stderrTail": text(sys.argv[3], 4000),
|
|
}
|
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
sys.exit(0 if payload["ok"] else 1)
|
|
PY
|
|
`;
|
|
}
|
|
|
|
function configSummary(observability: ObservabilityConfig, target: ObservabilityTarget): Record<string, unknown> {
|
|
return {
|
|
configPath: configLabel,
|
|
spec: observability.metadata.spec,
|
|
target: targetSummary(target),
|
|
images: {
|
|
collector: imageReference(observability.images.collector),
|
|
traceBackend: imageReference(observability.images.tempo),
|
|
},
|
|
collector: observability.collector,
|
|
traceBackend: observability.traceBackend,
|
|
sampling: observability.sampling,
|
|
};
|
|
}
|
|
|
|
function targetSummary(target: ObservabilityTarget): Record<string, unknown> {
|
|
return {
|
|
id: target.id,
|
|
route: target.route,
|
|
namespace: target.namespace,
|
|
role: target.role,
|
|
createNamespace: target.createNamespace,
|
|
};
|
|
}
|
|
|
|
function policyChecks(yaml: string, target: ObservabilityTarget): Array<Record<string, unknown>> {
|
|
return [
|
|
{ name: "yaml-source-of-truth", ok: true, detail: "All concrete images, routes, namespace, ports, retention and sampling values are read from config/platform-infra/observability.yaml." },
|
|
{ name: "clusterip-only", ok: !/^\s*type:\s*(NodePort|LoadBalancer)\s*$/mu.test(yaml), detail: "Collector and trace backend stay ClusterIP-only." },
|
|
{ name: "no-ingress", ok: !/^\s*kind:\s*Ingress\s*$/mu.test(yaml), detail: "No public ingress is rendered for the first tracing backend." },
|
|
{ name: "no-host-network", ok: !/^\s*hostNetwork:\s*true\s*$/mu.test(yaml), detail: "Pods must not use host network." },
|
|
{ name: "allow-all-network-policy", ok: yaml.includes("kind: NetworkPolicy") && yaml.includes("name: allow-all") && yaml.includes(`namespace: ${target.namespace}`), detail: `NetworkPolicy/allow-all is rendered in ${target.namespace}.` },
|
|
];
|
|
}
|
|
|
|
function statusSummary(payload: Record<string, unknown>): Record<string, unknown> {
|
|
const sections = asRecord(payload.sections, "status.sections");
|
|
const deployments = objectList(sectionJson(sections, "deployments"));
|
|
const services = objectList(sectionJson(sections, "services"));
|
|
const pods = objectList(sectionJson(sections, "pods"));
|
|
const probes = Array.isArray(payload.probes) ? payload.probes as Array<Record<string, unknown>> : [];
|
|
const readyDeployments = deployments.map((item) => deploymentSummary(item));
|
|
return {
|
|
ready: payload.ok === true,
|
|
namespace: payload.namespace,
|
|
deployments: readyDeployments,
|
|
services: services.map((item) => metadataName(item)),
|
|
pods: pods.map((item) => podSummary(item)),
|
|
probes: probes.map((item) => ({
|
|
name: item.name,
|
|
ok: item.ok === true,
|
|
service: item.service,
|
|
path: item.path,
|
|
stderrTail: item.ok === true ? "" : item.stderrTail,
|
|
})),
|
|
};
|
|
}
|
|
|
|
function compactStatus(payload: Record<string, unknown> | null, full: boolean): Record<string, unknown> | null {
|
|
if (payload === null) return null;
|
|
if (full) return redactSensitiveUnknown(payload) as Record<string, unknown>;
|
|
return statusSummary(payload);
|
|
}
|
|
|
|
function sectionJson(sections: Record<string, unknown>, name: string): unknown {
|
|
const section = asRecord(sections[name], `sections.${name}`);
|
|
return section.json;
|
|
}
|
|
|
|
function objectList(value: unknown): Record<string, unknown>[] {
|
|
if (typeof value !== "object" || value === null) return [];
|
|
const items = (value as Record<string, unknown>).items;
|
|
if (!Array.isArray(items)) return [];
|
|
return items.filter((item): item is Record<string, unknown> => typeof item === "object" && item !== null && !Array.isArray(item));
|
|
}
|
|
|
|
function deploymentSummary(item: Record<string, unknown>): Record<string, unknown> {
|
|
const spec = item.spec as Record<string, unknown> | undefined;
|
|
const status = item.status as Record<string, unknown> | undefined;
|
|
const replicas = typeof spec?.replicas === "number" ? spec.replicas : null;
|
|
const available = typeof status?.availableReplicas === "number" ? status.availableReplicas : 0;
|
|
return {
|
|
name: metadataName(item),
|
|
replicas,
|
|
availableReplicas: available,
|
|
ready: replicas !== null && available >= replicas,
|
|
};
|
|
}
|
|
|
|
function podSummary(item: Record<string, unknown>): Record<string, unknown> {
|
|
const status = item.status as Record<string, unknown> | undefined;
|
|
return {
|
|
name: metadataName(item),
|
|
phase: status?.phase ?? null,
|
|
};
|
|
}
|
|
|
|
function metadataName(item: Record<string, unknown>): string | null {
|
|
const metadata = item.metadata as Record<string, unknown> | undefined;
|
|
return typeof metadata?.name === "string" ? metadata.name : null;
|
|
}
|
|
|
|
function manifestObjectSummary(yaml: string): Array<Record<string, string>> {
|
|
const docs = yaml.split(/^---$/mu);
|
|
return docs.map((doc) => {
|
|
const kind = doc.match(/^\s*kind:\s*(.+)$/mu)?.[1]?.trim() ?? "unknown";
|
|
const name = doc.match(/^\s*name:\s*(.+)$/mu)?.[1]?.trim() ?? "unknown";
|
|
return { kind, name };
|
|
});
|
|
}
|
|
|
|
function imageReference(image: ImageSpec): string {
|
|
return `${image.repository}:${image.tag}`;
|
|
}
|
|
|
|
function indent(value: string, spaces: number): string {
|
|
const prefix = " ".repeat(spaces);
|
|
return value.split("\n").map((line) => `${prefix}${line}`).join("\n");
|
|
}
|