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:
Anso
2026-05-01 18:57:44 -04:00
committed by GitHub
parent f62716f557
commit 685d5d729e
14 changed files with 2657 additions and 1 deletions
+214
View File
@@ -0,0 +1,214 @@
/**
* BlueprintReconciler decision-logic tests.
*
* The reconciler's `computeDecision` is the load-bearing pure logic — given
* a blueprint, an actual deployment table, and a desired node set, it must
* decide for each node whether to deploy, withdraw, drift-check, state-review,
* or evict-block. We test that decision in isolation by accessing the
* private method via a type-cast, mirroring the AutoHealService.shouldHeal
* pattern.
*
* Local deploy / remote HTTP / actual `docker compose` invocation are not
* exercised here — they're integration concerns covered by the manual
* lifecycle in the plan.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
import type { Blueprint, Node } from '../services/DatabaseService';
import type { ReconcileDecision } from '../services/BlueprintReconciler';
type ReconcilerWithCompute = { computeDecision: (blueprint: Blueprint, allNodes: Node[]) => ReconcileDecision };
let tmpDir: string;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let BlueprintReconciler: typeof import('../services/BlueprintReconciler').BlueprintReconciler;
let NodeLabelService: typeof import('../services/NodeLabelService').NodeLabelService;
let BlueprintService: typeof import('../services/BlueprintService').BlueprintService;
let counter = 0;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
({ BlueprintReconciler } = await import('../services/BlueprintReconciler'));
({ NodeLabelService } = await import('../services/NodeLabelService'));
({ BlueprintService } = await import('../services/BlueprintService'));
});
afterAll(() => cleanupTestDb(tmpDir));
beforeEach(() => {
const db = DatabaseService.getInstance().getDb();
db.prepare('DELETE FROM blueprint_deployments').run();
db.prepare('DELETE FROM blueprints').run();
db.prepare('DELETE FROM node_labels').run();
db.prepare("DELETE FROM nodes WHERE is_default = 0").run();
});
function seedNode(): number {
counter += 1;
const db = DatabaseService.getInstance().getDb();
const result = db.prepare(
`INSERT INTO nodes (name, type, mode, compose_dir, is_default, status, created_at)
VALUES (?, 'local', 'proxy', '/tmp/compose', 0, 'online', ?)`
).run(`bp-test-${counter}`, Date.now());
return result.lastInsertRowid as number;
}
function seedBlueprint(opts: {
name?: string;
classification?: 'stateless' | 'stateful' | 'unknown';
drift_mode?: 'observe' | 'suggest' | 'enforce';
nodeIds?: number[];
revision?: number;
}) {
counter += 1;
const name = opts.name ?? `bp-${counter}`;
return DatabaseService.getInstance().createBlueprint({
name,
description: null,
compose_content: 'services:\n app:\n image: nginx\n',
selector: { type: 'nodes', ids: opts.nodeIds ?? [] },
drift_mode: opts.drift_mode ?? 'suggest',
classification: opts.classification ?? 'stateless',
classification_reasons: [],
enabled: true,
created_by: null,
});
}
describe('BlueprintReconciler.computeDecision', () => {
it('queues deploy for a stateless blueprint targeting a fresh node', () => {
const nodeId = seedNode();
const bp = seedBlueprint({ classification: 'stateless', nodeIds: [nodeId] });
const reconciler = BlueprintReconciler.getInstance() as unknown as ReconcilerWithCompute;
const allNodes = DatabaseService.getInstance().getNodes();
const decision = reconciler.computeDecision(bp, allNodes);
expect(decision.deploy.map((n: { id: number }) => n.id)).toContain(nodeId);
expect(decision.stateReview).toEqual([]);
});
it('queues state-review (not deploy) for a stateful blueprint targeting a fresh node', () => {
const nodeId = seedNode();
const bp = seedBlueprint({ classification: 'stateful', nodeIds: [nodeId] });
const reconciler = BlueprintReconciler.getInstance() as unknown as ReconcilerWithCompute;
const allNodes = DatabaseService.getInstance().getNodes();
const decision = reconciler.computeDecision(bp, allNodes);
expect(decision.stateReview.map((n: { id: number }) => n.id)).toContain(nodeId);
expect(decision.deploy).toEqual([]);
});
it('queues drift-check for an active deployment whose revision matches', () => {
const nodeId = seedNode();
const bp = seedBlueprint({ classification: 'stateless', nodeIds: [nodeId] });
DatabaseService.getInstance().upsertDeployment({
blueprint_id: bp.id,
node_id: nodeId,
status: 'active',
applied_revision: bp.revision,
});
const reconciler = BlueprintReconciler.getInstance() as unknown as ReconcilerWithCompute;
const allNodes = DatabaseService.getInstance().getNodes();
const decision = reconciler.computeDecision(bp, allNodes);
expect(decision.check.map((n: { id: number }) => n.id)).toContain(nodeId);
expect(decision.deploy).toEqual([]);
});
it('queues redeploy when the blueprint revision moved past the deployed revision', () => {
const nodeId = seedNode();
const bp = seedBlueprint({ classification: 'stateless', nodeIds: [nodeId] });
DatabaseService.getInstance().upsertDeployment({
blueprint_id: bp.id,
node_id: nodeId,
status: 'active',
applied_revision: bp.revision - 1,
});
const reconciler = BlueprintReconciler.getInstance() as unknown as ReconcilerWithCompute;
const allNodes = DatabaseService.getInstance().getNodes();
const decision = reconciler.computeDecision(bp, allNodes);
expect(decision.deploy.map((n: { id: number }) => n.id)).toContain(nodeId);
});
it('queues stateless eviction when a node leaves the selector', () => {
const nodeId = seedNode();
const bp = seedBlueprint({ classification: 'stateless', nodeIds: [] });
DatabaseService.getInstance().upsertDeployment({
blueprint_id: bp.id,
node_id: nodeId,
status: 'active',
applied_revision: bp.revision,
});
const reconciler = BlueprintReconciler.getInstance() as unknown as ReconcilerWithCompute;
const allNodes = DatabaseService.getInstance().getNodes();
const decision = reconciler.computeDecision(bp, allNodes);
expect(decision.withdraw.map((n: { id: number }) => n.id)).toContain(nodeId);
expect(decision.evictBlocked).toEqual([]);
});
it('queues evict_blocked (not auto-withdraw) when a STATEFUL deployment leaves the selector', () => {
const nodeId = seedNode();
const bp = seedBlueprint({ classification: 'stateful', nodeIds: [] });
DatabaseService.getInstance().upsertDeployment({
blueprint_id: bp.id,
node_id: nodeId,
status: 'active',
applied_revision: bp.revision,
});
const reconciler = BlueprintReconciler.getInstance() as unknown as ReconcilerWithCompute;
const allNodes = DatabaseService.getInstance().getNodes();
const decision = reconciler.computeDecision(bp, allNodes);
expect(decision.evictBlocked.map((n: { id: number }) => n.id)).toContain(nodeId);
expect(decision.withdraw).toEqual([]);
});
it('skips deployments already in pending_state_review or evict_blocked or name_conflict', () => {
const nodeA = seedNode();
const nodeB = seedNode();
const nodeC = seedNode();
const bp = seedBlueprint({ classification: 'stateless', nodeIds: [nodeA, nodeB, nodeC] });
DatabaseService.getInstance().upsertDeployment({ blueprint_id: bp.id, node_id: nodeA, status: 'pending_state_review' });
DatabaseService.getInstance().upsertDeployment({ blueprint_id: bp.id, node_id: nodeB, status: 'evict_blocked' });
DatabaseService.getInstance().upsertDeployment({ blueprint_id: bp.id, node_id: nodeC, status: 'name_conflict' });
const reconciler = BlueprintReconciler.getInstance() as unknown as ReconcilerWithCompute;
const allNodes = DatabaseService.getInstance().getNodes();
const decision = reconciler.computeDecision(bp, allNodes);
expect(decision.deploy).toEqual([]);
expect(decision.check).toEqual([]);
expect(decision.withdraw).toEqual([]);
});
it('matches via labels selector and respects label changes', () => {
const nodeA = seedNode();
const nodeB = seedNode();
NodeLabelService.getInstance().addLabel(nodeA, 'prod');
NodeLabelService.getInstance().addLabel(nodeB, 'staging');
const bp = DatabaseService.getInstance().createBlueprint({
name: 'caddy-via-labels',
description: null,
compose_content: 'services:\n caddy:\n image: caddy\n',
selector: { type: 'labels', any: ['prod'], all: [] },
drift_mode: 'suggest',
classification: 'stateless',
classification_reasons: [],
enabled: true,
created_by: null,
});
const reconciler = BlueprintReconciler.getInstance() as unknown as ReconcilerWithCompute;
const allNodes = DatabaseService.getInstance().getNodes();
const decision = reconciler.computeDecision(bp, allNodes);
expect(decision.deploy.map((n: { id: number }) => n.id)).toContain(nodeA);
expect(decision.deploy.map((n: { id: number }) => n.id)).not.toContain(nodeB);
});
});
describe('BlueprintService marker parsing + name-conflict guard', () => {
it('parseMarker accepts a well-formed marker', () => {
const marker = BlueprintService.parseMarker(JSON.stringify({ blueprintId: 7, revision: 3, lastApplied: 12345 }));
expect(marker).toEqual({ blueprintId: 7, revision: 3, lastApplied: 12345 });
});
it('parseMarker rejects an invalid marker', () => {
expect(BlueprintService.parseMarker('not json')).toBeNull();
expect(BlueprintService.parseMarker(JSON.stringify({ revision: 1 }))).toBeNull();
expect(BlueprintService.parseMarker(JSON.stringify({ blueprintId: 'nope', revision: 1 }))).toBeNull();
});
});