mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-31 04:38:11 +00:00
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.
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user