mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 12:17:34 +00:00
685d5d729e
* 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.
256 lines
6.1 KiB
TypeScript
256 lines
6.1 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { BlueprintAnalyzer } from '../services/BlueprintAnalyzer';
|
|
|
|
describe('BlueprintAnalyzer.analyze', () => {
|
|
it('classifies a stack with no volumes as stateless', () => {
|
|
const yaml = `
|
|
services:
|
|
caddy:
|
|
image: caddy:2-alpine
|
|
ports: ["80:80"]
|
|
`;
|
|
const r = BlueprintAnalyzer.analyze(yaml);
|
|
expect(r.classification).toBe('stateless');
|
|
expect(r.hasNamedVolumes).toBe(false);
|
|
expect(r.hasBindMounts).toBe(false);
|
|
expect(r.hasExternalVolumes).toBe(false);
|
|
});
|
|
|
|
it('classifies a stack with only tmpfs as stateless', () => {
|
|
const yaml = `
|
|
services:
|
|
redis:
|
|
image: redis:7
|
|
tmpfs:
|
|
- /var/cache
|
|
`;
|
|
const r = BlueprintAnalyzer.analyze(yaml);
|
|
expect(r.classification).toBe('stateless');
|
|
expect(r.hasBindMounts).toBe(false);
|
|
expect(r.hasNamedVolumes).toBe(false);
|
|
});
|
|
|
|
it('classifies a named volume as stateful with reason', () => {
|
|
const yaml = `
|
|
services:
|
|
postgres:
|
|
image: postgres:16
|
|
volumes:
|
|
- pg_data:/var/lib/postgresql/data
|
|
volumes:
|
|
pg_data:
|
|
`;
|
|
const r = BlueprintAnalyzer.analyze(yaml);
|
|
expect(r.classification).toBe('stateful');
|
|
expect(r.hasNamedVolumes).toBe(true);
|
|
expect(r.reasons.some(s => s.includes('named volume "pg_data"'))).toBe(true);
|
|
});
|
|
|
|
it('classifies a relative bind mount as stateful', () => {
|
|
const yaml = `
|
|
services:
|
|
postgres:
|
|
image: postgres:16
|
|
volumes:
|
|
- ./data:/var/lib/postgresql/data
|
|
`;
|
|
const r = BlueprintAnalyzer.analyze(yaml);
|
|
expect(r.classification).toBe('stateful');
|
|
expect(r.hasBindMounts).toBe(true);
|
|
expect(r.reasons.some(s => s.includes('bind mount "./data"'))).toBe(true);
|
|
});
|
|
|
|
it('classifies an absolute bind mount as stateful', () => {
|
|
const yaml = `
|
|
services:
|
|
app:
|
|
image: example
|
|
volumes:
|
|
- /opt/data:/data
|
|
`;
|
|
const r = BlueprintAnalyzer.analyze(yaml);
|
|
expect(r.classification).toBe('stateful');
|
|
expect(r.hasBindMounts).toBe(true);
|
|
});
|
|
|
|
it('classifies long-form bind mount as stateful', () => {
|
|
const yaml = `
|
|
services:
|
|
app:
|
|
image: example
|
|
volumes:
|
|
- type: bind
|
|
source: /srv/data
|
|
target: /data
|
|
`;
|
|
const r = BlueprintAnalyzer.analyze(yaml);
|
|
expect(r.classification).toBe('stateful');
|
|
expect(r.hasBindMounts).toBe(true);
|
|
});
|
|
|
|
it('classifies long-form named volume as stateful', () => {
|
|
const yaml = `
|
|
services:
|
|
app:
|
|
image: example
|
|
volumes:
|
|
- type: volume
|
|
source: app_data
|
|
target: /data
|
|
volumes:
|
|
app_data:
|
|
`;
|
|
const r = BlueprintAnalyzer.analyze(yaml);
|
|
expect(r.classification).toBe('stateful');
|
|
expect(r.hasNamedVolumes).toBe(true);
|
|
});
|
|
|
|
it('classifies a stack with only an external volume as unknown', () => {
|
|
const yaml = `
|
|
services:
|
|
app:
|
|
image: example
|
|
volumes:
|
|
- shared_storage:/data
|
|
volumes:
|
|
shared_storage:
|
|
external: true
|
|
`;
|
|
const r = BlueprintAnalyzer.analyze(yaml);
|
|
expect(r.classification).toBe('unknown');
|
|
expect(r.hasExternalVolumes).toBe(true);
|
|
expect(r.reasons.some(s => s.includes('external volume'))).toBe(true);
|
|
});
|
|
|
|
it('treats mixed named + external as stateful (named volumes dominate)', () => {
|
|
const yaml = `
|
|
services:
|
|
app:
|
|
image: example
|
|
volumes:
|
|
- app_data:/data
|
|
- shared:/cache
|
|
volumes:
|
|
app_data:
|
|
shared:
|
|
external: true
|
|
`;
|
|
const r = BlueprintAnalyzer.analyze(yaml);
|
|
expect(r.classification).toBe('stateful');
|
|
expect(r.hasNamedVolumes).toBe(true);
|
|
expect(r.hasExternalVolumes).toBe(true);
|
|
});
|
|
|
|
it('returns unknown classification on parse error', () => {
|
|
const yaml = 'services:\n bad: : nope:';
|
|
const r = BlueprintAnalyzer.analyze(yaml);
|
|
expect(r.classification).toBe('unknown');
|
|
expect(r.parseError).toBeTruthy();
|
|
});
|
|
|
|
it('returns unknown on empty document', () => {
|
|
const r = BlueprintAnalyzer.analyze('');
|
|
expect(r.classification).toBe('unknown');
|
|
});
|
|
|
|
it('annotates known data-bearing target paths', () => {
|
|
const yaml = `
|
|
services:
|
|
postgres:
|
|
image: postgres:16
|
|
volumes:
|
|
- pg_data:/var/lib/postgresql/data
|
|
volumes:
|
|
pg_data:
|
|
`;
|
|
const r = BlueprintAnalyzer.analyze(yaml);
|
|
expect(r.reasons.some(s => s.includes('looks data-bearing'))).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('BlueprintAnalyzer.wouldDestroyVolumes', () => {
|
|
it('returns false when volume names unchanged', () => {
|
|
const a = `
|
|
services:
|
|
db:
|
|
image: postgres
|
|
volumes: [pg:/data]
|
|
volumes:
|
|
pg:
|
|
`;
|
|
expect(BlueprintAnalyzer.wouldDestroyVolumes(a, a)).toBe(false);
|
|
});
|
|
|
|
it('returns true when a named volume is removed', () => {
|
|
const before = `
|
|
services:
|
|
db:
|
|
image: postgres
|
|
volumes: [pg:/data]
|
|
volumes:
|
|
pg:
|
|
`;
|
|
const after = `
|
|
services:
|
|
db:
|
|
image: postgres
|
|
`;
|
|
expect(BlueprintAnalyzer.wouldDestroyVolumes(before, after)).toBe(true);
|
|
});
|
|
|
|
it('returns true when a named volume is renamed', () => {
|
|
const before = `
|
|
services:
|
|
db:
|
|
image: postgres
|
|
volumes: [pg_data:/data]
|
|
volumes:
|
|
pg_data:
|
|
`;
|
|
const after = `
|
|
services:
|
|
db:
|
|
image: postgres
|
|
volumes: [pg_data_v2:/data]
|
|
volumes:
|
|
pg_data_v2:
|
|
`;
|
|
expect(BlueprintAnalyzer.wouldDestroyVolumes(before, after)).toBe(true);
|
|
});
|
|
|
|
it('returns false when a named volume is added', () => {
|
|
const before = `
|
|
services:
|
|
app:
|
|
image: example
|
|
`;
|
|
const after = `
|
|
services:
|
|
app:
|
|
image: example
|
|
volumes: [data:/d]
|
|
volumes:
|
|
data:
|
|
`;
|
|
expect(BlueprintAnalyzer.wouldDestroyVolumes(before, after)).toBe(false);
|
|
});
|
|
|
|
it('ignores external volumes', () => {
|
|
const before = `
|
|
services:
|
|
app:
|
|
image: example
|
|
volumes: [shared:/d]
|
|
volumes:
|
|
shared:
|
|
external: true
|
|
`;
|
|
const after = `
|
|
services:
|
|
app:
|
|
image: example
|
|
`;
|
|
expect(BlueprintAnalyzer.wouldDestroyVolumes(before, after)).toBe(false);
|
|
});
|
|
});
|