Files
sencho/backend/src/services/BlueprintAnalyzer.ts
T
Anso 685d5d729e feat(blueprints): backend foundation for fleet-wide compose templates (#860)
* feat(blueprints): add backend foundation for fleet-wide compose templates

Introduces the Blueprint Model: a docker-compose.yml plus a node selector
(labels or explicit IDs) that Sencho reconciles across the fleet. Backend
foundation only; the frontend tab and documentation follow.

Schema (DatabaseService):
- node_labels table for fleet-level orchestration tagging
- blueprints table with compose content, selector, drift_mode, classification
- blueprint_deployments table for per-node materialized state
- New idempotent migrate methods following the existing pattern

Services:
- BlueprintAnalyzer: pure compose-YAML classifier (stateless / stateful /
  unknown) with 17 covered cases including named volumes, bind mounts,
  external volumes, and tmpfs
- NodeLabelService: label CRUD plus selector matching helper (any/all/ids)
- BlueprintService: local + remote deploy/withdraw orchestration, marker
  file management, name-conflict guard, per-(blueprint,node) lock
- BlueprintReconciler: 60-second loop with three-mode drift policy
  (observe/suggest/enforce), state-aware guards, and Enforce-downgrade for
  volume-destroying drift

Routes (gated requirePaid + requireAdmin on mutations):
- /api/blueprints (CRUD + apply + withdraw + accept + preview + analyze)
- /api/node-labels (CRUD + listAll + listDistinct)

Notifications: four new categories registered in NotificationService for
deploy/failure/drift events.

Bootstrap: reconciler start/stop wired in startup and shutdown.

Tests: 45 new Vitest cases covering selector matching, classifier rules,
state-aware guards, drift-mode branching, and marker parsing. Full backend
suite (1625 tests) passes; tsc clean.

* fix(lint): replace bare Function type in blueprint reconciler tests

Replace 8 occurrences of `as unknown as { computeDecision: Function }`
with a properly typed `ReconcilerWithCompute` alias that mirrors the
real method signature. Export `ReconcileDecision` from
BlueprintReconciler so the test can reference it.

Resolves @typescript-eslint/no-unsafe-function-type errors that were
failing the Backend (Lint) CI step.
2026-05-01 18:57:44 -04:00

225 lines
8.8 KiB
TypeScript

import { parse as parseYaml } from 'yaml';
import type { BlueprintClassification } from './DatabaseService';
export interface AnalyzerResult {
classification: BlueprintClassification;
reasons: string[];
hasNamedVolumes: boolean;
hasBindMounts: boolean;
hasExternalVolumes: boolean;
hasTmpfsOnly: boolean;
parseError?: string;
}
interface ComposeShape {
services?: Record<string, ComposeService> | null;
volumes?: Record<string, ComposeVolume | null> | null;
}
interface ComposeService {
volumes?: Array<string | ComposeServiceVolume> | null;
tmpfs?: string | string[] | null;
}
interface ComposeServiceVolume {
type?: string;
source?: string;
target?: string;
bind?: { propagation?: string };
volume?: { nocopy?: boolean };
}
interface ComposeVolume {
external?: boolean | { name?: string };
driver?: string;
name?: string;
}
const STATEFUL_BIND_TARGETS = [
'/var/lib',
'/var/log',
'/data',
'/db',
'/etc',
];
export class BlueprintAnalyzer {
static analyze(composeContent: string): AnalyzerResult {
const result: AnalyzerResult = {
classification: 'unknown',
reasons: [],
hasNamedVolumes: false,
hasBindMounts: false,
hasExternalVolumes: false,
hasTmpfsOnly: true,
};
let parsed: unknown;
try {
parsed = parseYaml(composeContent);
} catch (err) {
result.parseError = err instanceof Error ? err.message : String(err);
result.classification = 'unknown';
result.reasons.push('compose YAML did not parse');
result.hasTmpfsOnly = false;
return result;
}
if (parsed == null || typeof parsed !== 'object') {
result.parseError = 'compose document is empty or not an object';
result.classification = 'unknown';
result.reasons.push('compose document is empty');
result.hasTmpfsOnly = false;
return result;
}
const doc = parsed as ComposeShape;
if (!doc.services || Object.keys(doc.services).length === 0) {
result.classification = 'unknown';
result.reasons.push('compose document has no services');
result.hasTmpfsOnly = false;
return result;
}
const topVolumes = doc.volumes ?? {};
const services = doc.services ?? {};
const externalVolumeNames = new Set<string>();
const declaredNamedVolumes = new Set<string>();
for (const [volName, volDef] of Object.entries(topVolumes)) {
if (volDef && typeof volDef === 'object' && 'external' in volDef && volDef.external) {
externalVolumeNames.add(volName);
result.hasExternalVolumes = true;
result.reasons.push(`external volume "${volName}": Sencho cannot reason about portability`);
result.hasTmpfsOnly = false;
} else {
declaredNamedVolumes.add(volName);
}
}
let sawAnyMount = false;
for (const [serviceName, serviceDef] of Object.entries(services)) {
if (!serviceDef || typeof serviceDef !== 'object') continue;
const volumes = serviceDef.volumes ?? [];
for (const v of volumes) {
sawAnyMount = true;
if (typeof v === 'string') {
const parts = v.split(':');
const source = parts[0];
if (!source) continue;
if (source.startsWith('/') || source.startsWith('.') || source.startsWith('~')) {
result.hasBindMounts = true;
result.hasTmpfsOnly = false;
result.reasons.push(`bind mount "${source}" in service "${serviceName}"`);
} else if (externalVolumeNames.has(source)) {
// already accounted for in topVolumes loop
} else {
result.hasNamedVolumes = true;
result.hasTmpfsOnly = false;
result.reasons.push(`named volume "${source}" in service "${serviceName}"`);
}
} else if (v && typeof v === 'object') {
const t = (v.type ?? '').toLowerCase();
const source = v.source ?? '';
if (t === 'bind') {
result.hasBindMounts = true;
result.hasTmpfsOnly = false;
result.reasons.push(`bind mount "${source}" in service "${serviceName}"`);
} else if (t === 'volume') {
if (externalVolumeNames.has(source)) {
// counted above
} else {
result.hasNamedVolumes = true;
result.hasTmpfsOnly = false;
result.reasons.push(`named volume "${source}" in service "${serviceName}"`);
}
} else if (t === 'tmpfs') {
// tmpfs is ephemeral; do not flip hasTmpfsOnly
} else if (source) {
// type omitted; fall back to source heuristic
if (source.startsWith('/') || source.startsWith('.') || source.startsWith('~')) {
result.hasBindMounts = true;
result.hasTmpfsOnly = false;
result.reasons.push(`bind mount "${source}" in service "${serviceName}"`);
} else {
result.hasNamedVolumes = true;
result.hasTmpfsOnly = false;
result.reasons.push(`named volume "${source}" in service "${serviceName}"`);
}
}
}
}
const tmpfs = serviceDef.tmpfs;
if (tmpfs && (typeof tmpfs === 'string' || Array.isArray(tmpfs))) {
sawAnyMount = true;
// tmpfs alone keeps hasTmpfsOnly true unless other mounts already flipped it
}
}
if (!sawAnyMount) {
result.hasTmpfsOnly = false;
}
// Classification rules (apply in order):
// 1. Named volumes or bind mounts → stateful
// 2. External volumes (no other persistence) → unknown (Sencho cannot prove portability)
// 3. Only tmpfs or no volumes → stateless
if (result.hasNamedVolumes || result.hasBindMounts) {
result.classification = 'stateful';
} else if (result.hasExternalVolumes) {
result.classification = 'unknown';
} else {
result.classification = 'stateless';
if (result.reasons.length === 0) {
result.reasons.push('no persistent volumes detected');
}
}
// Helpful annotation: if any bind mount targets a known stateful path, surface it
for (const [serviceName, serviceDef] of Object.entries(services)) {
if (!serviceDef || typeof serviceDef !== 'object') continue;
for (const v of serviceDef.volumes ?? []) {
if (typeof v !== 'string') continue;
const parts = v.split(':');
const target = parts[1];
if (!target) continue;
if (STATEFUL_BIND_TARGETS.some(prefix => target.startsWith(prefix))) {
result.reasons.push(`mount target "${target}" in service "${serviceName}" looks data-bearing`);
}
}
}
return result;
}
/**
* Returns true when applying a new compose to an existing deployment would
* destroy named volumes (rename or removal of a top-level named volume).
* Used by the reconciler to downgrade Enforce → Suggest for volume-destroying
* drift events on stateful blueprints.
*/
static wouldDestroyVolumes(currentCompose: string, nextCompose: string): boolean {
const current = BlueprintAnalyzer.extractNamedVolumes(currentCompose);
const next = BlueprintAnalyzer.extractNamedVolumes(nextCompose);
for (const name of current) {
if (!next.has(name)) return true;
}
return false;
}
private static extractNamedVolumes(composeContent: string): Set<string> {
try {
const doc = (parseYaml(composeContent) ?? {}) as ComposeShape;
const out = new Set<string>();
for (const [name, def] of Object.entries(doc.volumes ?? {})) {
if (def && typeof def === 'object' && 'external' in def && def.external) continue;
out.add(name);
}
return out;
} catch {
return new Set();
}
}
}