/* observability.stack.validate — offline config lint for the SigNoz observability stack (RFC-0338). Do not introduce app-specific runtime composition or deployment behavior into this reusable package source file. RFC-0338: initial implementation. */ import { join } from "node:path"; import type { CheckResult, Diagnostic, KernelCommandInput, KernelCommandResult, KernelRuntimeContext, } from "@warpgogol/werkstatt-engine/kernel"; import { diagnosticsResult } from "@warpgogol/werkstatt-shared/checks"; const STACK_DIR = "services/observability-stack"; const REQUIRED_FILES = [ "service.config.yaml", "casting.yaml", "caddy/Caddyfile", "collector/collector-patch.yaml", "compose.extra.yaml", ".env.example", "README.md", "package.json", ] as const; const REQUIRED_RUNBOOK_SECTIONS = ["Provision", "Upgrade", "Restore", "Rotate token"] as const; const REQUIRED_ENV_VARS = [ "WARPGOGOL_OTLP_TOKEN", "SIGNOZ_SMTP_HOST", "SIGNOZ_SMTP_PORT", "SIGNOZ_SMTP_USER", "SIGNOZ_SMTP_PASSWORD", "SIGNOZ_SMTP_FROM", "RESTIC_REPOSITORY", "RESTIC_PASSWORD", ] as const; export async function runObservabilityStackValidate( _input: KernelCommandInput, context: KernelRuntimeContext, ): Promise> { const diagnostics: Diagnostic[] = []; const root = context.workspaceRoot; // OBS-STACK-01: required files for (const file of REQUIRED_FILES) { const path = join(root, STACK_DIR, file); if (!(await context.io.exists(path))) { diagnostics.push({ ruleId: "OBS-STACK-01", severity: "error", file: `${STACK_DIR}/${file}`, message: `Required file is missing: ${STACK_DIR}/${file}`, fixHint: `Create ${file} per the RFC-0338 workspace layout.`, }); } } // OBS-STACK-02: casting.yaml const castingPath = join(root, STACK_DIR, "casting.yaml"); if (await context.io.exists(castingPath)) { const casting = await context.io.readFile(castingPath); if (!casting.includes("flavor: compose")) { diagnostics.push({ ruleId: "OBS-STACK-02", severity: "error", file: `${STACK_DIR}/casting.yaml`, message: "casting.yaml must specify flavor: compose.", fixHint: "Set spec.deployment.flavor: compose.", }); } if (!casting.includes("mode: docker")) { diagnostics.push({ ruleId: "OBS-STACK-02", severity: "error", file: `${STACK_DIR}/casting.yaml`, message: "casting.yaml must specify mode: docker.", fixHint: "Set spec.deployment.mode: docker.", }); } } // OBS-STACK-03: Caddyfile bearer-token guard const caddyPath = join(root, STACK_DIR, "caddy", "Caddyfile"); if (await context.io.exists(caddyPath)) { const caddy = await context.io.readFile(caddyPath); if (!caddy.includes("Bearer {$WARPGOGOL_OTLP_TOKEN}")) { diagnostics.push({ ruleId: "OBS-STACK-03", severity: "error", file: `${STACK_DIR}/caddy/Caddyfile`, message: "Caddyfile must enforce bearer-token auth on the ingest host.", fixHint: 'Add: @unauthorized not header Authorization "Bearer {$WARPGOGOL_OTLP_TOKEN}"', }); } if (caddy.includes("4317") || caddy.includes("4318 :")) { // Check if 4318 is exposed directly (not via reverse_proxy) const lines = caddy.split("\n"); for (const line of lines) { if (line.includes("4318") && !line.includes("reverse_proxy")) { diagnostics.push({ ruleId: "OBS-STACK-03", severity: "error", file: `${STACK_DIR}/caddy/Caddyfile`, message: "Caddyfile exposes otel-collector port directly.", fixHint: "Use reverse_proxy to otel-collector:4318, never expose the port directly.", }); } } } } // OBS-STACK-04: collector-patch.yaml const collectorPath = join(root, STACK_DIR, "collector", "collector-patch.yaml"); if (await context.io.exists(collectorPath)) { const collector = await context.io.readFile(collectorPath); if (!collector.includes("transform/warpgogol-enrich")) { diagnostics.push({ ruleId: "OBS-STACK-04", severity: "error", file: `${STACK_DIR}/collector/collector-patch.yaml`, message: "collector-patch.yaml must define transform/warpgogol-enrich processor.", fixHint: "Add the transform/warpgogol-enrich processor per RFC-0337.", }); } if (!collector.includes("transform/warpgogol-redact")) { diagnostics.push({ ruleId: "OBS-STACK-04", severity: "error", file: `${STACK_DIR}/collector/collector-patch.yaml`, message: "collector-patch.yaml must define transform/warpgogol-redact processor.", fixHint: "Add the transform/warpgogol-redact processor per RFC-0337.", }); } } // OBS-STACK-05: .env.example const envExamplePath = join(root, STACK_DIR, ".env.example"); if (await context.io.exists(envExamplePath)) { const envExample = await context.io.readFile(envExamplePath); for (const varName of REQUIRED_ENV_VARS) { if (!envExample.includes(varName)) { diagnostics.push({ ruleId: "OBS-STACK-05", severity: "error", file: `${STACK_DIR}/.env.example`, message: `.env.example is missing variable: ${varName}`, fixHint: `Add ${varName}= to .env.example.`, }); } } // Check for non-empty values (secrets committed) const envLines = envExample.split("\n"); for (const line of envLines) { const match = line.match(/^([A-Z_]+)=(.*)$/); if (match && match[2] && match[2].trim() !== "") { // Allow numeric defaults like 587 if (!/^\d+$/.test(match[2].trim())) { diagnostics.push({ ruleId: "OBS-STACK-05", severity: "error", file: `${STACK_DIR}/.env.example`, message: `.env.example contains a non-empty value for ${match[1]} — possible secret committed.`, fixHint: "Set all values to empty in .env.example; real secrets go in .env only.", }); } } } } // OBS-STACK-06: README.md runbook sections const readmePath = join(root, STACK_DIR, "README.md"); if (await context.io.exists(readmePath)) { const readme = await context.io.readFile(readmePath); for (const section of REQUIRED_RUNBOOK_SECTIONS) { if (!readme.includes(section)) { diagnostics.push({ ruleId: "OBS-STACK-06", severity: "warning", file: `${STACK_DIR}/README.md`, message: `README.md is missing runbook section: ${section}`, fixHint: `Add a "${section}" section to the README.md runbook.`, }); } } } return diagnosticsResult("observability.stack.validate", diagnostics); }