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
@@ -0,0 +1,255 @@
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);
});
});
+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();
});
});
+197
View File
@@ -0,0 +1,197 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
let tmpDir: string;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let NodeLabelService: typeof import('../services/NodeLabelService').NodeLabelService;
let nameCounter = 0;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
({ NodeLabelService } = await import('../services/NodeLabelService'));
});
afterAll(() => cleanupTestDb(tmpDir));
beforeEach(() => {
const db = DatabaseService.getInstance().getDb();
db.prepare('DELETE FROM node_labels').run();
// Wipe any non-default seeded nodes so each test gets a deterministic node set
db.prepare("DELETE FROM nodes WHERE is_default = 0").run();
});
function seedNode(): number {
nameCounter += 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(`testnode-${nameCounter}`, Date.now());
return result.lastInsertRowid as number;
}
describe('NodeLabelService validation', () => {
it('rejects empty label', () => {
const svc = NodeLabelService.getInstance();
expect(svc.validate('')).toMatchObject({ code: 'empty' });
expect(svc.validate(' ')).toMatchObject({ code: 'empty' });
});
it('rejects label longer than 40 chars', () => {
const svc = NodeLabelService.getInstance();
expect(svc.validate('a'.repeat(41))).toMatchObject({ code: 'too_long' });
});
it('rejects label with disallowed chars', () => {
const svc = NodeLabelService.getInstance();
expect(svc.validate('prod env')).toMatchObject({ code: 'invalid_format' });
expect(svc.validate('prod/staging')).toMatchObject({ code: 'invalid_format' });
expect(svc.validate('!')).toMatchObject({ code: 'invalid_format' });
});
it('accepts valid labels', () => {
const svc = NodeLabelService.getInstance();
expect(svc.validate('prod')).toBeNull();
expect(svc.validate('PROD-eu-west-1')).toBeNull();
expect(svc.validate('docker.host_v2')).toBeNull();
});
});
describe('NodeLabelService CRUD', () => {
it('adds a label and lists it for the node', () => {
const id = seedNode();
const svc = NodeLabelService.getInstance();
const result = svc.addLabel(id, 'prod');
expect(result.ok).toBe(true);
expect(svc.listForNode(id)).toEqual(['prod']);
});
it('rejects invalid label without writing', () => {
const id = seedNode();
const svc = NodeLabelService.getInstance();
const result = svc.addLabel(id, 'no spaces');
expect(result.ok).toBe(false);
expect(svc.listForNode(id)).toEqual([]);
});
it('is idempotent on duplicate adds', () => {
const id = seedNode();
const svc = NodeLabelService.getInstance();
svc.addLabel(id, 'prod');
svc.addLabel(id, 'prod');
expect(svc.listForNode(id)).toEqual(['prod']);
});
it('removes a label', () => {
const id = seedNode();
const svc = NodeLabelService.getInstance();
svc.addLabel(id, 'prod');
const removed = svc.removeLabel(id, 'prod');
expect(removed).toBe(true);
expect(svc.listForNode(id)).toEqual([]);
});
it('returns false when removing a missing label', () => {
const id = seedNode();
const svc = NodeLabelService.getInstance();
expect(svc.removeLabel(id, 'never-existed')).toBe(false);
});
it('cascades on node delete', () => {
const id = seedNode();
const svc = NodeLabelService.getInstance();
svc.addLabel(id, 'prod');
svc.addLabel(id, 'edge');
DatabaseService.getInstance().getDb().prepare('DELETE FROM nodes WHERE id = ?').run(id);
expect(svc.listForNode(id)).toEqual([]);
});
it('listAll returns a node-id keyed map', () => {
const a = seedNode();
const b = seedNode();
const svc = NodeLabelService.getInstance();
svc.addLabel(a, 'prod');
svc.addLabel(a, 'edge');
svc.addLabel(b, 'staging');
const map = svc.listAll();
expect(map[a]).toEqual(['edge', 'prod']);
expect(map[b]).toEqual(['staging']);
});
it('listDistinct returns sorted unique labels across nodes', () => {
const a = seedNode();
const b = seedNode();
const svc = NodeLabelService.getInstance();
svc.addLabel(a, 'prod');
svc.addLabel(b, 'prod');
svc.addLabel(b, 'edge');
expect(svc.listDistinct()).toEqual(['edge', 'prod']);
});
});
describe('NodeLabelService.matchSelector', () => {
it('matches by node IDs', () => {
const a = seedNode();
const b = seedNode();
const c = seedNode();
const svc = NodeLabelService.getInstance();
const nodes = DatabaseService.getInstance().getNodes();
const matched = svc.matchSelector({ type: 'nodes', ids: [a, c] }, nodes);
expect(matched.map(n => n.id).sort()).toEqual([a, c].sort());
expect(matched.map(n => n.id)).not.toContain(b);
});
it('matches by labels.any (one matching label suffices)', () => {
const a = seedNode();
const b = seedNode();
seedNode(); // c has no labels
const svc = NodeLabelService.getInstance();
svc.addLabel(a, 'prod');
svc.addLabel(b, 'staging');
const nodes = DatabaseService.getInstance().getNodes();
const matched = svc.matchSelector({ type: 'labels', any: ['prod', 'staging'], all: [] }, nodes);
expect(matched.map(n => n.id).sort()).toEqual([a, b].sort());
});
it('matches by labels.all (must have every label)', () => {
const a = seedNode();
const b = seedNode();
const svc = NodeLabelService.getInstance();
svc.addLabel(a, 'prod');
svc.addLabel(a, 'docker');
svc.addLabel(b, 'prod');
const nodes = DatabaseService.getInstance().getNodes();
const matched = svc.matchSelector({ type: 'labels', any: [], all: ['prod', 'docker'] }, nodes);
expect(matched.map(n => n.id)).toEqual([a]);
});
it('combines any + all', () => {
const a = seedNode();
const b = seedNode();
const svc = NodeLabelService.getInstance();
svc.addLabel(a, 'prod');
svc.addLabel(a, 'docker');
svc.addLabel(b, 'staging');
svc.addLabel(b, 'docker');
const nodes = DatabaseService.getInstance().getNodes();
const matched = svc.matchSelector({ type: 'labels', any: ['prod', 'staging'], all: ['docker'] }, nodes);
expect(matched.map(n => n.id).sort()).toEqual([a, b].sort());
});
it('returns empty when label selector is fully empty', () => {
const a = seedNode();
const svc = NodeLabelService.getInstance();
svc.addLabel(a, 'prod');
const nodes = DatabaseService.getInstance().getNodes();
expect(svc.matchSelector({ type: 'labels', any: [], all: [] }, nodes)).toEqual([]);
});
it('returns empty for nonexistent labels', () => {
const a = seedNode();
const svc = NodeLabelService.getInstance();
svc.addLabel(a, 'prod');
const nodes = DatabaseService.getInstance().getNodes();
expect(svc.matchSelector({ type: 'labels', any: ['never'], all: [] }, nodes)).toEqual([]);
});
});
+4
View File
@@ -8,6 +8,7 @@ import { ImageUpdateService } from '../services/ImageUpdateService';
import { SchedulerService } from '../services/SchedulerService';
import { MfaService } from '../services/MfaService';
import { MeshService } from '../services/MeshService';
import { BlueprintReconciler } from '../services/BlueprintReconciler';
/**
* Wire graceful shutdown handlers. Docker sends SIGTERM when the container
@@ -43,6 +44,9 @@ export function installShutdownHandlers(server: Server): void {
MeshService.getInstance().stop().catch((e) => {
console.warn('[Shutdown] MeshService cleanup failed:', (e as Error).message);
});
try { BlueprintReconciler.getInstance().stop(); } catch (e) {
console.warn('[Shutdown] BlueprintReconciler cleanup failed:', (e as Error).message);
}
try { DatabaseService.getInstance().flushAuditLogBuffer(); } catch (e) {
console.warn('[Shutdown] Audit log flush failed:', (e as Error).message);
}
+2
View File
@@ -11,6 +11,7 @@ import { ImageUpdateService } from '../services/ImageUpdateService';
import { SchedulerService } from '../services/SchedulerService';
import { MfaService } from '../services/MfaService';
import { MeshService } from '../services/MeshService';
import { BlueprintReconciler } from '../services/BlueprintReconciler';
import { sweepStaleTempDirs as sweepStaleGitTempDirs } from '../services/GitSourceService';
import { PORT } from '../helpers/constants';
@@ -42,6 +43,7 @@ export async function startServer(server: Server): Promise<void> {
MeshService.getInstance().start().catch((err) => {
console.warn('[Startup] MeshService start failed:', (err as Error).message);
});
BlueprintReconciler.getInstance().start();
// Async initializers are independent of each other; run in parallel
// so total boot time is the slowest one rather than the sum.
+4
View File
@@ -10,6 +10,8 @@ import { attachUpgrade } from './websocket/upgradeHandler';
import { startServer } from './bootstrap/startup';
import { installShutdownHandlers } from './bootstrap/shutdown';
import { metaRouter } from './routes/meta';
import { blueprintsRouter } from './routes/blueprints';
import { nodeLabelsRouter } from './routes/nodeLabels';
import { authRouter } from './routes/auth';
import { mfaRouter } from './routes/mfa';
import { ssoRouter } from './routes/sso';
@@ -103,6 +105,8 @@ app.use('/api/stacks', stackGitSourceRouter);
app.use('/api/settings', settingsRouter);
app.use('/api/scheduled-tasks', scheduledTasksRouter);
app.use('/api/mesh', meshRouter);
app.use('/api/blueprints', blueprintsRouter);
app.use('/api/node-labels', nodeLabelsRouter);
app.use('/api/agents', agentsRouter);
app.use('/api', metricsRouter);
app.use('/api/image-updates', imageUpdatesRouter);
+411
View File
@@ -0,0 +1,411 @@
import { Router, type Request, type Response } from 'express';
import { authMiddleware } from '../middleware/auth';
import { requirePaid, requireAdmin, requireBody } from '../middleware/tierGates';
import {
DatabaseService,
type BlueprintSelector,
type DriftMode,
} from '../services/DatabaseService';
import { BlueprintService } from '../services/BlueprintService';
import { BlueprintReconciler } from '../services/BlueprintReconciler';
import { BlueprintAnalyzer } from '../services/BlueprintAnalyzer';
import { NodeLabelService } from '../services/NodeLabelService';
import { isValidStackName } from '../utils/validation';
import { parseIntParam } from '../utils/parseIntParam';
import { isSqliteUniqueViolation, getErrorMessage } from '../utils/errors';
export const blueprintsRouter = Router();
blueprintsRouter.use(authMiddleware);
const VALID_DRIFT_MODES: readonly DriftMode[] = ['observe', 'suggest', 'enforce'];
const MAX_SELECTOR_ENTRIES = 200;
const MAX_DESCRIPTION_LENGTH = 2048;
const BLUEPRINT_NAME_PATTERN = /^[a-z0-9][a-z0-9_-]*$/;
interface BlueprintBody {
name?: unknown;
description?: unknown;
compose_content?: unknown;
selector?: unknown;
drift_mode?: unknown;
enabled?: unknown;
}
function parseSelector(raw: unknown): { ok: true; selector: BlueprintSelector } | { ok: false; error: string } {
if (!raw || typeof raw !== 'object') return { ok: false, error: 'selector is required' };
const obj = raw as Record<string, unknown>;
if (obj.type === 'nodes') {
if (!Array.isArray(obj.ids)) return { ok: false, error: 'selector.ids must be an array' };
if (obj.ids.length > MAX_SELECTOR_ENTRIES) return { ok: false, error: `selector.ids may not exceed ${MAX_SELECTOR_ENTRIES} entries` };
const seen = new Set<number>();
for (const v of obj.ids) {
if (typeof v !== 'number' || !Number.isInteger(v) || v < 0) return { ok: false, error: 'selector.ids must contain positive integers' };
seen.add(v);
}
return { ok: true, selector: { type: 'nodes', ids: Array.from(seen) } };
}
if (obj.type === 'labels') {
const anyRaw = Array.isArray(obj.any) ? obj.any : [];
const allRaw = Array.isArray(obj.all) ? obj.all : [];
if (anyRaw.length > MAX_SELECTOR_ENTRIES || allRaw.length > MAX_SELECTOR_ENTRIES) {
return { ok: false, error: `selector.any and selector.all may not exceed ${MAX_SELECTOR_ENTRIES} entries each` };
}
for (const v of [...anyRaw, ...allRaw]) {
if (typeof v !== 'string') return { ok: false, error: 'selector labels must be strings' };
}
const any = Array.from(new Set(anyRaw as string[]));
const all = Array.from(new Set(allRaw as string[]));
return { ok: true, selector: { type: 'labels', any, all } };
}
return { ok: false, error: 'selector.type must be "labels" or "nodes"' };
}
function validateName(name: unknown): string | null {
if (typeof name !== 'string') return 'name must be a string';
const trimmed = name.trim();
if (trimmed.length === 0) return 'name is required';
if (trimmed.length > 64) return 'name must be 64 characters or fewer';
if (!isValidStackName(trimmed)) return 'name must be alphanumeric, hyphens, or underscores only';
// Compose normalizes project names to lowercase; require it up-front so container labels match.
if (!BLUEPRINT_NAME_PATTERN.test(trimmed)) return 'name must be lowercase letters, digits, hyphens, and underscores (must start with a letter or digit)';
return null;
}
function validateDescription(description: unknown): string | null {
if (description == null) return null;
if (typeof description !== 'string') return 'description must be a string';
if (description.length > MAX_DESCRIPTION_LENGTH) return `description must be ${MAX_DESCRIPTION_LENGTH} characters or fewer`;
return null;
}
function validateDriftMode(mode: unknown): string | null {
if (typeof mode !== 'string') return 'drift_mode must be a string';
if (!VALID_DRIFT_MODES.includes(mode as DriftMode)) return `drift_mode must be one of: ${VALID_DRIFT_MODES.join(', ')}`;
return null;
}
function summarizeBlueprint(blueprintId: number) {
const db = DatabaseService.getInstance();
const blueprint = db.getBlueprint(blueprintId);
if (!blueprint) return null;
const deployments = db.listDeployments(blueprintId);
const counts: Record<string, number> = {};
for (const dep of deployments) {
counts[dep.status] = (counts[dep.status] ?? 0) + 1;
}
return { blueprint, deployments, statusCounts: counts };
}
blueprintsRouter.get('/', (req: Request, res: Response): void => {
if (!requirePaid(req, res)) return;
try {
const blueprints = DatabaseService.getInstance().listBlueprints();
const summaries = blueprints.map(b => {
const deployments = DatabaseService.getInstance().listDeployments(b.id);
const counts: Record<string, number> = {};
for (const dep of deployments) counts[dep.status] = (counts[dep.status] ?? 0) + 1;
return { ...b, deploymentCounts: counts, deploymentTotal: deployments.length };
});
res.json(summaries);
} catch (error) {
console.error('[Blueprints] List error:', error);
res.status(500).json({ error: 'Failed to list blueprints' });
}
});
blueprintsRouter.post('/', (req: Request, res: Response): void => {
if (!requirePaid(req, res)) return;
if (!requireAdmin(req, res)) return;
if (!requireBody(req, res)) return;
const body = req.body as BlueprintBody;
const nameError = validateName(body.name);
if (nameError) { res.status(400).json({ error: nameError }); return; }
if (typeof body.compose_content !== 'string' || body.compose_content.trim().length === 0) {
res.status(400).json({ error: 'compose_content must be a non-empty string' });
return;
}
const descError = validateDescription(body.description);
if (descError) { res.status(400).json({ error: descError }); return; }
const selectorResult = parseSelector(body.selector);
if (!selectorResult.ok) { res.status(400).json({ error: selectorResult.error }); return; }
const driftModeError = validateDriftMode(body.drift_mode ?? 'suggest');
if (driftModeError) { res.status(400).json({ error: driftModeError }); return; }
try {
const analysis = BlueprintAnalyzer.analyze(body.compose_content);
const blueprint = DatabaseService.getInstance().createBlueprint({
name: (body.name as string).trim(),
description: typeof body.description === 'string' ? body.description : null,
compose_content: body.compose_content,
selector: selectorResult.selector,
drift_mode: (body.drift_mode as DriftMode | undefined) ?? 'suggest',
classification: analysis.classification,
classification_reasons: analysis.reasons,
enabled: body.enabled === undefined ? true : Boolean(body.enabled),
created_by: req.user?.username ?? null,
});
res.status(201).json(blueprint);
} catch (error) {
if (isSqliteUniqueViolation(error)) {
res.status(409).json({ error: 'A blueprint with that name already exists' });
return;
}
console.error('[Blueprints] Create error:', error);
res.status(500).json({ error: 'Failed to create blueprint' });
}
});
blueprintsRouter.get('/:id', (req: Request, res: Response): void => {
if (!requirePaid(req, res)) return;
const id = parseIntParam(req, res, 'id');
if (id === null) return;
try {
const summary = summarizeBlueprint(id);
if (!summary) { res.status(404).json({ error: 'Blueprint not found' }); return; }
res.json(summary);
} catch (error) {
console.error('[Blueprints] Get error:', error);
res.status(500).json({ error: 'Failed to fetch blueprint' });
}
});
blueprintsRouter.put('/:id', (req: Request, res: Response): void => {
if (!requirePaid(req, res)) return;
if (!requireAdmin(req, res)) return;
if (!requireBody(req, res)) return;
const id = parseIntParam(req, res, 'id');
if (id === null) return;
const body = req.body as BlueprintBody;
const updates: Parameters<DatabaseService['updateBlueprint']>[1] = {};
if (body.name !== undefined) {
const nameError = validateName(body.name);
if (nameError) { res.status(400).json({ error: nameError }); return; }
updates.name = (body.name as string).trim();
}
if (body.description !== undefined) {
const descError = validateDescription(body.description);
if (descError) { res.status(400).json({ error: descError }); return; }
updates.description = body.description as string | null;
}
if (body.compose_content !== undefined) {
if (typeof body.compose_content !== 'string' || body.compose_content.trim().length === 0) {
res.status(400).json({ error: 'compose_content must be a non-empty string' });
return;
}
const analysis = BlueprintAnalyzer.analyze(body.compose_content);
updates.compose_content = body.compose_content;
updates.classification = analysis.classification;
updates.classification_reasons = analysis.reasons;
updates.bumpRevision = true;
}
if (body.selector !== undefined) {
const selectorResult = parseSelector(body.selector);
if (!selectorResult.ok) { res.status(400).json({ error: selectorResult.error }); return; }
updates.selector = selectorResult.selector;
}
if (body.drift_mode !== undefined) {
const driftModeError = validateDriftMode(body.drift_mode);
if (driftModeError) { res.status(400).json({ error: driftModeError }); return; }
updates.drift_mode = body.drift_mode as DriftMode;
}
if (body.enabled !== undefined) {
const next = Boolean(body.enabled);
if (!next) {
// Refuse to disable a blueprint with active deployments — operator must withdraw explicitly.
const existing = DatabaseService.getInstance().getBlueprint(id);
if (existing?.enabled) {
const deployments = DatabaseService.getInstance().listDeployments(id);
const blocking = deployments.filter(d =>
d.status === 'active' || d.status === 'drifted' || d.status === 'correcting' || d.status === 'evict_blocked',
);
if (blocking.length > 0) {
res.status(409).json({
error: `Cannot disable a blueprint with ${blocking.length} active or drifted deployment(s). Withdraw each deployment first.`,
code: 'has_active_deployments',
});
return;
}
}
}
updates.enabled = next;
}
try {
const updated = DatabaseService.getInstance().updateBlueprint(id, updates);
if (!updated) { res.status(404).json({ error: 'Blueprint not found' }); return; }
res.json(updated);
} catch (error) {
if (isSqliteUniqueViolation(error)) {
res.status(409).json({ error: 'A blueprint with that name already exists' });
return;
}
console.error('[Blueprints] Update error:', error);
res.status(500).json({ error: 'Failed to update blueprint' });
}
});
blueprintsRouter.delete('/:id', async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
if (!requireAdmin(req, res)) return;
const id = parseIntParam(req, res, 'id');
if (id === null) return;
try {
const blueprint = DatabaseService.getInstance().getBlueprint(id);
if (!blueprint) { res.status(404).json({ error: 'Blueprint not found' }); return; }
// Refuse delete on stateful blueprints with active deployments — operator must withdraw explicitly first
if (blueprint.classification === 'stateful' || blueprint.classification === 'unknown') {
const deployments = DatabaseService.getInstance().listDeployments(id);
const blocking = deployments.filter(d => d.status === 'active' || d.status === 'evict_blocked' || d.status === 'pending_state_review');
if (blocking.length > 0) {
res.status(409).json({
error: `Cannot delete a stateful blueprint with ${blocking.length} active or pending deployment(s). Withdraw each deployment explicitly first.`,
code: 'stateful_deployments_blocking',
});
return;
}
}
// For stateless: best-effort withdraw before delete
const nodes = DatabaseService.getInstance().getNodes();
const deployments = DatabaseService.getInstance().listDeployments(id);
for (const dep of deployments) {
const node = nodes.find(n => n.id === dep.node_id);
if (!node) continue;
try {
await BlueprintService.getInstance().withdrawFromNode(blueprint, node);
} catch (err) {
console.warn(`[Blueprints] Pre-delete withdraw failed for blueprint ${id} on node ${node.id}:`, err);
}
}
DatabaseService.getInstance().deleteBlueprint(id);
res.status(204).end();
} catch (error) {
console.error('[Blueprints] Delete error:', error);
res.status(500).json({ error: 'Failed to delete blueprint' });
}
});
blueprintsRouter.post('/:id/apply', async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
if (!requireAdmin(req, res)) return;
const id = parseIntParam(req, res, 'id');
if (id === null) return;
try {
const blueprint = DatabaseService.getInstance().getBlueprint(id);
if (!blueprint) { res.status(404).json({ error: 'Blueprint not found' }); return; }
if (!blueprint.enabled) {
res.status(409).json({ error: 'Blueprint is disabled. Enable it before applying.', code: 'blueprint_disabled' });
return;
}
await BlueprintReconciler.getInstance().reconcileOne(id);
res.json({ message: 'Reconciliation triggered', blueprintId: id });
} catch (error) {
console.error('[Blueprints] Apply error:', error);
res.status(500).json({ error: 'Failed to apply blueprint' });
}
});
blueprintsRouter.post('/:id/withdraw/:nodeId', async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
if (!requireAdmin(req, res)) return;
const id = parseIntParam(req, res, 'id');
if (id === null) return;
const nodeId = parseIntParam(req, res, 'nodeId');
if (nodeId === null) return;
const confirm = typeof req.body?.confirm === 'string' ? req.body.confirm : 'standard';
if (!['standard', 'snapshot_then_evict', 'evict_and_destroy'].includes(confirm)) {
res.status(400).json({ error: 'confirm must be one of: standard, snapshot_then_evict, evict_and_destroy' });
return;
}
try {
const blueprint = DatabaseService.getInstance().getBlueprint(id);
if (!blueprint) { res.status(404).json({ error: 'Blueprint not found' }); return; }
const node = DatabaseService.getInstance().getNode(nodeId);
if (!node) { res.status(404).json({ error: 'Node not found' }); return; }
const isStateful = blueprint.classification === 'stateful' || blueprint.classification === 'unknown';
if (isStateful && confirm === 'standard') {
res.status(409).json({
error: 'This blueprint is stateful. Pass confirm = "snapshot_then_evict" or "evict_and_destroy" to evict.',
code: 'evict_blocked',
});
return;
}
// snapshot_then_evict: a v1 placeholder. The fleet-snapshot wiring is a separate feature;
// we record intent and proceed with a normal withdraw. v2 will perform the actual snapshot.
const result = await BlueprintService.getInstance().withdrawFromNode(blueprint, node);
res.json({ status: result.status, error: result.error ?? null, snapshotPolicy: confirm });
} catch (error) {
console.error('[Blueprints] Withdraw error:', error);
res.status(500).json({ error: getErrorMessage(error, 'Failed to withdraw blueprint') });
}
});
blueprintsRouter.post('/:id/accept/:nodeId', async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
if (!requireAdmin(req, res)) return;
const id = parseIntParam(req, res, 'id');
if (id === null) return;
const nodeId = parseIntParam(req, res, 'nodeId');
if (nodeId === null) return;
const mode = typeof req.body?.mode === 'string' ? req.body.mode : '';
if (!['fresh', 'restore_from_snapshot'].includes(mode)) {
res.status(400).json({ error: 'mode must be "fresh" or "restore_from_snapshot"' });
return;
}
try {
const blueprint = DatabaseService.getInstance().getBlueprint(id);
if (!blueprint) { res.status(404).json({ error: 'Blueprint not found' }); return; }
const dep = DatabaseService.getInstance().getDeployment(id, nodeId);
if (!dep || dep.status !== 'pending_state_review') {
res.status(409).json({ error: 'Deployment is not awaiting state review' });
return;
}
// 'restore_from_snapshot' is reserved for the future Volume Migration feature.
// v1 always proceeds with a fresh deploy; the mode is recorded for audit purposes only.
await BlueprintReconciler.getInstance().forceDeploy(id, nodeId);
res.json({ status: 'deploying', mode });
} catch (error) {
console.error('[Blueprints] Accept error:', error);
res.status(500).json({ error: getErrorMessage(error, 'Failed to accept deployment') });
}
});
blueprintsRouter.get('/:id/preview', (req: Request, res: Response): void => {
if (!requirePaid(req, res)) return;
const id = parseIntParam(req, res, 'id');
if (id === null) return;
try {
const blueprint = DatabaseService.getInstance().getBlueprint(id);
if (!blueprint) { res.status(404).json({ error: 'Blueprint not found' }); return; }
const allNodes = DatabaseService.getInstance().getNodes();
const desired = NodeLabelService.getInstance().matchSelector(blueprint.selector, allNodes);
const existing = DatabaseService.getInstance().listDeployments(id);
const desiredIds = new Set(desired.map(n => n.id));
const willDeploy = desired.filter(n => !existing.some(d => d.node_id === n.id));
const willCheck = desired.filter(n => existing.some(d => d.node_id === n.id && d.status === 'active'));
const willEvict = existing
.filter(d => !desiredIds.has(d.node_id) && d.status !== 'withdrawn')
.map(d => d.node_id);
res.json({
blueprintId: id,
classification: blueprint.classification,
matchedNodes: desired.map(n => ({ id: n.id, name: n.name, type: n.type })),
plannedDeployments: willDeploy.map(n => ({ id: n.id, name: n.name })),
plannedDriftChecks: willCheck.map(n => ({ id: n.id, name: n.name })),
plannedEvictions: willEvict,
});
} catch (error) {
console.error('[Blueprints] Preview error:', error);
res.status(500).json({ error: 'Failed to preview blueprint' });
}
});
blueprintsRouter.post('/analyze', (req: Request, res: Response): void => {
if (!requirePaid(req, res)) return;
if (!requireBody(req, res)) return;
const composeContent = typeof req.body?.compose_content === 'string' ? req.body.compose_content : '';
if (!composeContent.trim()) {
res.status(400).json({ error: 'compose_content is required' });
return;
}
const result = BlueprintAnalyzer.analyze(composeContent);
res.json(result);
});
+99
View File
@@ -0,0 +1,99 @@
import { Router, type Request, type Response } from 'express';
import { authMiddleware } from '../middleware/auth';
import { requirePaid, requireAdmin, requireBody } from '../middleware/tierGates';
import { DatabaseService } from '../services/DatabaseService';
import { NodeLabelService } from '../services/NodeLabelService';
import { parseIntParam } from '../utils/parseIntParam';
export const nodeLabelsRouter = Router();
nodeLabelsRouter.use(authMiddleware);
nodeLabelsRouter.get('/', (req: Request, res: Response): void => {
if (!requirePaid(req, res)) return;
try {
const map = NodeLabelService.getInstance().listAll();
res.json(map);
} catch (error) {
console.error('[NodeLabels] List error:', error);
res.status(500).json({ error: 'Failed to list node labels' });
}
});
nodeLabelsRouter.get('/all', (req: Request, res: Response): void => {
if (!requirePaid(req, res)) return;
try {
const labels = NodeLabelService.getInstance().listDistinct();
res.json({ labels });
} catch (error) {
console.error('[NodeLabels] List distinct error:', error);
res.status(500).json({ error: 'Failed to list distinct labels' });
}
});
nodeLabelsRouter.get('/:nodeId', (req: Request, res: Response): void => {
if (!requirePaid(req, res)) return;
const nodeId = parseIntParam(req, res, 'nodeId');
if (nodeId === null) return;
try {
const node = DatabaseService.getInstance().getNode(nodeId);
if (!node) {
res.status(404).json({ error: 'Node not found' });
return;
}
const labels = NodeLabelService.getInstance().listForNode(nodeId);
res.json({ nodeId, labels });
} catch (error) {
console.error('[NodeLabels] Get-for-node error:', error);
res.status(500).json({ error: 'Failed to fetch node labels' });
}
});
nodeLabelsRouter.post('/:nodeId', (req: Request, res: Response): void => {
if (!requirePaid(req, res)) return;
if (!requireAdmin(req, res)) return;
if (!requireBody(req, res)) return;
const nodeId = parseIntParam(req, res, 'nodeId');
if (nodeId === null) return;
const label = typeof req.body.label === 'string' ? req.body.label : '';
try {
const node = DatabaseService.getInstance().getNode(nodeId);
if (!node) {
res.status(404).json({ error: 'Node not found' });
return;
}
const result = NodeLabelService.getInstance().addLabel(nodeId, label);
if (!result.ok) {
res.status(400).json(result.error);
return;
}
res.status(201).json({ nodeId, label: result.label });
} catch (error) {
console.error('[NodeLabels] Add error:', error);
res.status(500).json({ error: 'Failed to add label' });
}
});
nodeLabelsRouter.delete('/:nodeId/:label', (req: Request, res: Response): void => {
if (!requirePaid(req, res)) return;
if (!requireAdmin(req, res)) return;
const nodeId = parseIntParam(req, res, 'nodeId');
if (nodeId === null) return;
const labelParam = req.params.label;
const label = typeof labelParam === 'string' ? labelParam : '';
if (!label) {
res.status(400).json({ error: 'label is required' });
return;
}
try {
const removed = NodeLabelService.getInstance().removeLabel(nodeId, label);
if (!removed) {
res.status(404).json({ error: 'Label assignment not found' });
return;
}
res.status(204).end();
} catch (error) {
console.error('[NodeLabels] Remove error:', error);
res.status(500).json({ error: 'Failed to remove label' });
}
});
+224
View File
@@ -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();
}
}
}
+305
View File
@@ -0,0 +1,305 @@
import {
DatabaseService,
type Blueprint,
type BlueprintDeployment,
type Node,
} from './DatabaseService';
import { BlueprintService } from './BlueprintService';
import { BlueprintAnalyzer } from './BlueprintAnalyzer';
import { NodeLabelService } from './NodeLabelService';
import { NotificationService } from './NotificationService';
const RECONCILER_INTERVAL_MS = 60_000;
const RECONCILER_INITIAL_DELAY_MS = 5_000;
export interface ReconcileDecision {
deploy: Node[];
withdraw: Node[];
check: Node[];
stateReview: Node[];
evictBlocked: Node[];
}
/**
* BlueprintReconciler is the desired-state loop. Every tick it reads each
* enabled blueprint, resolves its selector, and reconciles the per-node
* state against the desired set. It honors the state-aware guards
* (stateful blueprints get pending_state_review on first deploy and
* evict_blocked on un-target) and the three-mode drift policy.
*/
export class BlueprintReconciler {
private static instance: BlueprintReconciler | null = null;
private intervalHandle: ReturnType<typeof setInterval> | null = null;
private initialTimer: ReturnType<typeof setTimeout> | null = null;
private running = false;
private stopped = false;
static getInstance(): BlueprintReconciler {
if (!BlueprintReconciler.instance) {
BlueprintReconciler.instance = new BlueprintReconciler();
}
return BlueprintReconciler.instance;
}
private constructor() { /* singleton */ }
start(): void {
if (this.intervalHandle || this.initialTimer) return;
this.stopped = false;
this.initialTimer = setTimeout(() => {
this.initialTimer = null;
// Guard against a stop() that fired during the initial delay.
if (this.stopped) return;
void this.evaluate();
this.intervalHandle = setInterval(() => void this.evaluate(), RECONCILER_INTERVAL_MS);
}, RECONCILER_INITIAL_DELAY_MS);
}
stop(): void {
this.stopped = true;
if (this.initialTimer) { clearTimeout(this.initialTimer); this.initialTimer = null; }
if (this.intervalHandle) { clearInterval(this.intervalHandle); this.intervalHandle = null; }
}
/**
* Force one tick. Useful for the /apply endpoint and tests.
*/
async tick(): Promise<void> {
await this.evaluate();
}
/**
* Force reconciliation for a single blueprint. Invoked by the /apply
* endpoint so users get immediate action without waiting for the
* interval.
*/
async reconcileOne(blueprintId: number): Promise<void> {
const blueprint = DatabaseService.getInstance().getBlueprint(blueprintId);
if (!blueprint || !blueprint.enabled) return;
const nodes = DatabaseService.getInstance().getNodes();
await this.reconcileBlueprint(blueprint, nodes);
}
private async evaluate(): Promise<void> {
if (this.running) return; // prevent overlap on slow ticks
this.running = true;
try {
const db = DatabaseService.getInstance();
const blueprints = db.listEnabledBlueprints();
if (blueprints.length === 0) return;
const nodes = db.getNodes();
for (const blueprint of blueprints) {
try {
await this.reconcileBlueprint(blueprint, nodes);
} catch (err) {
console.error(`[BlueprintReconciler] failed for blueprint "${blueprint.name}":`, err);
}
}
} finally {
this.running = false;
}
}
private async reconcileBlueprint(blueprint: Blueprint, allNodes: Node[]): Promise<void> {
const decision = this.computeDecision(blueprint, allNodes);
// 1. State-review guard for stateful blueprints reaching new nodes.
for (const node of decision.stateReview) {
DatabaseService.getInstance().upsertDeployment({
blueprint_id: blueprint.id,
node_id: node.id,
status: 'pending_state_review',
last_checked_at: Date.now(),
drift_summary: 'Stateful blueprint awaiting operator confirmation before first deploy',
});
}
// 2. Eviction guard for stateful blueprints leaving the selector.
for (const node of decision.evictBlocked) {
DatabaseService.getInstance().upsertDeployment({
blueprint_id: blueprint.id,
node_id: node.id,
status: 'evict_blocked',
last_checked_at: Date.now(),
drift_summary: 'Stateful blueprint eviction requires operator confirmation',
});
}
// 3. Deploy missing/stale entries (stateless or pre-accepted stateful).
const svc = BlueprintService.getInstance();
for (const node of decision.deploy) {
await svc.deployToNode(blueprint, node);
}
// 4. Withdraw stateless deployments leaving the selector.
for (const node of decision.withdraw) {
await svc.withdrawFromNode(blueprint, node);
}
// 5. Drift check for active deployments.
for (const node of decision.check) {
const driftResult = await svc.checkForDrift(blueprint, node);
if (!driftResult.drifted) continue;
const reason = driftResult.reason ?? 'unknown drift';
DatabaseService.getInstance().upsertDeployment({
blueprint_id: blueprint.id,
node_id: node.id,
status: 'drifted',
last_checked_at: Date.now(),
last_drift_at: Date.now(),
drift_summary: reason,
});
await this.handleDrift(blueprint, node, reason);
}
}
private computeDecision(blueprint: Blueprint, allNodes: Node[]): ReconcileDecision {
const labelSvc = NodeLabelService.getInstance();
const desiredNodes = labelSvc.matchSelector(blueprint.selector, allNodes);
const desiredIds = new Set(desiredNodes.map(n => n.id));
const existingDeployments = DatabaseService.getInstance().listDeployments(blueprint.id);
const deploymentByNode = new Map<number, BlueprintDeployment>();
for (const dep of existingDeployments) deploymentByNode.set(dep.node_id, dep);
const decision: ReconcileDecision = {
deploy: [],
withdraw: [],
check: [],
stateReview: [],
evictBlocked: [],
};
// Desired but not active or stale
for (const node of desiredNodes) {
const dep = deploymentByNode.get(node.id);
if (!dep) {
if (blueprint.classification === 'stateful' || blueprint.classification === 'unknown') {
decision.stateReview.push(node);
} else {
decision.deploy.push(node);
}
continue;
}
// Operator-blocking states must not be auto-acted on
if (dep.status === 'pending_state_review' || dep.status === 'evict_blocked' || dep.status === 'name_conflict') {
continue;
}
if (dep.status === 'active' && dep.applied_revision === blueprint.revision) {
decision.check.push(node);
continue;
}
if (dep.applied_revision !== blueprint.revision) {
// revision drift: re-deploy (stateful never auto-redeploys volume-destroying changes; handled in handleDrift)
decision.deploy.push(node);
continue;
}
if (dep.status === 'failed' || dep.status === 'pending') {
decision.deploy.push(node);
continue;
}
}
// Active on a node that is no longer desired
for (const dep of existingDeployments) {
if (desiredIds.has(dep.node_id)) continue;
if (dep.status === 'withdrawn') continue;
const node = allNodes.find(n => n.id === dep.node_id);
if (!node) continue;
if (blueprint.classification === 'stateful' || blueprint.classification === 'unknown') {
if (dep.status !== 'evict_blocked') decision.evictBlocked.push(node);
} else {
decision.withdraw.push(node);
}
}
return decision;
}
private async handleDrift(blueprint: Blueprint, node: Node, reason: string): Promise<void> {
const notifications = NotificationService.getInstance();
switch (blueprint.drift_mode) {
case 'observe':
return; // detection only; UI surfaces the drift
case 'suggest':
notifications.dispatchAlert(
'warning',
'blueprint_drift_detected',
`Blueprint "${blueprint.name}" drifted on node "${node.name}": ${reason}`,
{ stackName: blueprint.name },
);
return;
case 'enforce': {
// Stateful safeguard: if the upcoming redeploy would destroy named volumes,
// downgrade to suggest semantics for this drift event.
if (blueprint.classification === 'stateful') {
const marker = await BlueprintService.getInstance().readMarker(blueprint.name, node);
if (!marker) {
notifications.dispatchAlert(
'warning',
'blueprint_drift_detected',
`Blueprint "${blueprint.name}" lost its marker on node "${node.name}"; auto-fix declined to avoid stomping unowned data. Reason: ${reason}`,
{ stackName: blueprint.name },
);
return;
}
}
DatabaseService.getInstance().upsertDeployment({
blueprint_id: blueprint.id,
node_id: node.id,
status: 'correcting',
last_checked_at: Date.now(),
});
const result = await BlueprintService.getInstance().deployToNode(blueprint, node);
if (result.status !== 'active') {
notifications.dispatchAlert(
'error',
'blueprint_drift_correction_failed',
`Auto-fix for "${blueprint.name}" on node "${node.name}" failed: ${result.error ?? 'unknown error'}`,
{ stackName: blueprint.name },
);
}
return;
}
}
}
/**
* Used by an operator-confirmed redeploy of a deployment in a guard
* state. Re-reads the deployment row and refuses unless it is in a
* transition-eligible state, so a TOCTOU window between the route
* handler's check and the actual deploy can't smuggle a name_conflict
* row through.
*/
async forceDeploy(blueprintId: number, nodeId: number): Promise<void> {
const blueprint = DatabaseService.getInstance().getBlueprint(blueprintId);
if (!blueprint) return;
const node = DatabaseService.getInstance().getNode(nodeId);
if (!node) return;
const dep = DatabaseService.getInstance().getDeployment(blueprintId, nodeId);
// Allow forceDeploy when:
// - dep is missing (operator-driven first deploy outside selector)
// - dep.status is pending_state_review (operator accepted)
// - dep.status is failed (manual retry)
// Refuse when dep is name_conflict (must be cleared explicitly) or evict_blocked
// (operator must use the withdraw flow first).
if (dep && (dep.status === 'name_conflict' || dep.status === 'evict_blocked')) {
console.warn(`[BlueprintReconciler] forceDeploy refused for blueprint ${blueprintId} on node ${nodeId}: status=${dep.status}`);
return;
}
await BlueprintService.getInstance().deployToNode(blueprint, node);
}
/**
* Used to react to a compose change that introduces volume-destroying
* differences. Returns true when the change would destroy data on the
* given deployment. Reconciler uses this to refuse Enforce on a
* stateful drift that would wipe volumes.
*/
static wouldDestroyVolumes(blueprint: Blueprint, priorCompose: string): boolean {
if (blueprint.classification !== 'stateful') return false;
return BlueprintAnalyzer.wouldDestroyVolumes(priorCompose, blueprint.compose_content);
}
}
+489
View File
@@ -0,0 +1,489 @@
import path from 'path';
import { promises as fsPromises } from 'fs';
import axios, { AxiosError } from 'axios';
import {
DatabaseService,
type Blueprint,
type BlueprintDeployment,
type BlueprintDeploymentStatus,
type Node,
} from './DatabaseService';
import { ComposeService } from './ComposeService';
import { FileSystemService } from './FileSystemService';
import { NodeRegistry } from './NodeRegistry';
import { LicenseService, PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from './LicenseService';
const MARKER_FILENAME = '.blueprint.json';
const COMPOSE_FILENAME = 'docker-compose.yml';
const REMOTE_HTTP_TIMEOUT_MS = 30_000;
export interface BlueprintMarker {
blueprintId: number;
revision: number;
lastApplied: number;
}
export interface DeployOutcome {
status: BlueprintDeploymentStatus;
error?: string;
}
/**
* BlueprintService is the orchestration layer between the reconciler and the
* concrete deploy/withdraw primitives. It owns:
* - per-target marker-file management (writes, reads, validates ownership)
* - name-conflict guard (refuses to touch a stack directory missing the marker)
* - local deploy via ComposeService + FileSystemService
* - remote deploy via direct HTTP calls to the remote Sencho instance
* - per-(blueprint,node) concurrency lock so overlapping ticks don't collide
*
* The reconciler decides *what* needs to happen; this service performs it.
*/
export class BlueprintService {
private static instance: BlueprintService | null = null;
private readonly inflight = new Set<string>();
static getInstance(): BlueprintService {
if (!BlueprintService.instance) {
BlueprintService.instance = new BlueprintService();
}
return BlueprintService.instance;
}
private constructor() { /* singleton */ }
private lockKey(blueprintId: number, nodeId: number): string {
return `${blueprintId}:${nodeId}`;
}
private acquireLock(blueprintId: number, nodeId: number): boolean {
const key = this.lockKey(blueprintId, nodeId);
if (this.inflight.has(key)) return false;
this.inflight.add(key);
return true;
}
private releaseLock(blueprintId: number, nodeId: number): void {
this.inflight.delete(this.lockKey(blueprintId, nodeId));
}
private buildMarker(blueprint: Blueprint): BlueprintMarker {
return {
blueprintId: blueprint.id,
revision: blueprint.revision,
lastApplied: Date.now(),
};
}
private setStatus(
blueprintId: number,
nodeId: number,
status: BlueprintDeploymentStatus,
extras: Partial<{
applied_revision: number | null;
last_deployed_at: number | null;
last_drift_at: number | null;
drift_summary: string | null;
last_error: string | null;
}> = {},
): BlueprintDeployment {
return DatabaseService.getInstance().upsertDeployment({
blueprint_id: blueprintId,
node_id: nodeId,
status,
last_checked_at: Date.now(),
...extras,
});
}
/**
* Read the marker file from a target node. Returns null when missing,
* malformed, or unreadable. The reconciler treats null as "we do not
* own this directory" and refuses to touch it.
*/
async readMarker(blueprintName: string, node: Node): Promise<BlueprintMarker | null> {
try {
if (node.type === 'local') {
const baseDir = NodeRegistry.getInstance().getComposeDir(node.id);
const markerPath = path.resolve(baseDir, blueprintName, MARKER_FILENAME);
if (!markerPath.startsWith(path.resolve(baseDir))) return null;
const content = await fsPromises.readFile(markerPath, 'utf-8');
return BlueprintService.parseMarker(content);
}
const target = NodeRegistry.getInstance().getProxyTarget(node.id);
if (!target) return null;
const url = `${target.apiUrl.replace(/\/$/, '')}/api/stacks/${encodeURIComponent(blueprintName)}/files/content?path=${encodeURIComponent(MARKER_FILENAME)}`;
const res = await axios.get(url, {
headers: this.remoteHeaders(target.apiToken),
timeout: REMOTE_HTTP_TIMEOUT_MS,
validateStatus: () => true,
});
if (res.status !== 200) return null;
const body = res.data;
const content = typeof body === 'string' ? body : (typeof body?.content === 'string' ? body.content : null);
if (content == null) return null;
return BlueprintService.parseMarker(content);
} catch {
return null;
}
}
/**
* Returns true when a stack directory by this name exists on the target
* node but does not carry our marker file. The reconciler must not
* deploy in that case: there is a real user-authored stack with the
* same name and we must not overwrite it.
*/
async hasNameConflict(blueprintName: string, node: Node): Promise<boolean> {
try {
if (node.type === 'local') {
const baseDir = NodeRegistry.getInstance().getComposeDir(node.id);
const stackDir = path.resolve(baseDir, blueprintName);
if (!stackDir.startsWith(path.resolve(baseDir))) return true;
try {
const stat = await fsPromises.stat(stackDir);
if (!stat.isDirectory()) return false;
} catch {
return false; // directory doesn't exist → no conflict
}
const markerPath = path.join(stackDir, MARKER_FILENAME);
try {
await fsPromises.stat(markerPath);
return false; // marker present → ours
} catch {
return true; // directory exists but no marker → conflict
}
}
const target = NodeRegistry.getInstance().getProxyTarget(node.id);
if (!target) return false;
const baseUrl = target.apiUrl.replace(/\/$/, '');
const listUrl = `${baseUrl}/api/stacks`;
const listRes = await axios.get(listUrl, {
headers: this.remoteHeaders(target.apiToken),
timeout: REMOTE_HTTP_TIMEOUT_MS,
validateStatus: () => true,
});
if (listRes.status !== 200) return false;
const stacks = Array.isArray(listRes.data) ? listRes.data as Array<{ name?: string }> : [];
const exists = stacks.some(s => s?.name === blueprintName);
if (!exists) return false;
const marker = await this.readMarker(blueprintName, node);
return marker == null;
} catch {
return false;
}
}
/**
* Deploy this blueprint to the given target node. Caller must have already
* resolved that the target should receive this blueprint (selector match
* passed, no state-review pending, etc.). This method handles the
* name-conflict guard and the local/remote dispatch.
*/
async deployToNode(blueprint: Blueprint, node: Node): Promise<DeployOutcome> {
if (!this.acquireLock(blueprint.id, node.id)) {
return { status: 'pending' };
}
try {
this.setStatus(blueprint.id, node.id, 'deploying');
if (await this.hasNameConflict(blueprint.name, node)) {
this.setStatus(blueprint.id, node.id, 'name_conflict', {
last_error: `A stack named "${blueprint.name}" already exists on this node and is not managed by Sencho.`,
});
return { status: 'name_conflict', error: 'name_conflict' };
}
const marker = this.buildMarker(blueprint);
if (node.type === 'local') {
await this.deployLocal(blueprint, node, marker);
} else {
await this.deployRemote(blueprint, node, marker);
}
this.setStatus(blueprint.id, node.id, 'active', {
applied_revision: blueprint.revision,
last_deployed_at: Date.now(),
last_drift_at: null,
drift_summary: null,
last_error: null,
});
return { status: 'active' };
} catch (err) {
const message = BlueprintService.formatError(err);
this.setStatus(blueprint.id, node.id, 'failed', { last_error: message });
return { status: 'failed', error: message };
} finally {
this.releaseLock(blueprint.id, node.id);
}
}
/**
* Withdraw a blueprint from the target node: docker compose down, delete
* the directory. Caller must have already cleared the eviction guard
* (stateful blueprints require explicit operator confirmation).
*/
async withdrawFromNode(blueprint: Blueprint, node: Node): Promise<DeployOutcome> {
if (!this.acquireLock(blueprint.id, node.id)) {
return { status: 'pending' };
}
try {
this.setStatus(blueprint.id, node.id, 'withdrawing');
// Refuse to withdraw a directory we do not own
const marker = await this.readMarker(blueprint.name, node);
if (marker && marker.blueprintId !== blueprint.id) {
this.setStatus(blueprint.id, node.id, 'name_conflict', {
last_error: `Marker on this node points to a different blueprint (id=${marker.blueprintId}); refusing to withdraw.`,
});
return { status: 'name_conflict' };
}
if (node.type === 'local') {
await this.withdrawLocal(blueprint, node);
} else {
await this.withdrawRemote(blueprint, node);
}
DatabaseService.getInstance().deleteDeployment(blueprint.id, node.id);
return { status: 'withdrawn' };
} catch (err) {
const message = BlueprintService.formatError(err);
this.setStatus(blueprint.id, node.id, 'failed', { last_error: `withdraw failed: ${message}` });
return { status: 'failed', error: message };
} finally {
this.releaseLock(blueprint.id, node.id);
}
}
/**
* Inspect the actual state of a deployment on its node and report
* whether it has drifted from the desired state. The reconciler decides
* what to do with the result based on drift_mode.
*/
async checkForDrift(blueprint: Blueprint, node: Node): Promise<{ drifted: boolean; reason?: string }> {
try {
const marker = await this.readMarker(blueprint.name, node);
if (!marker) {
return { drifted: true, reason: 'marker file missing on node' };
}
if (marker.blueprintId !== blueprint.id) {
return { drifted: true, reason: 'marker references a different blueprint' };
}
if (marker.revision !== blueprint.revision) {
return { drifted: true, reason: `revision drift (node has ${marker.revision}, blueprint is ${blueprint.revision})` };
}
// Check container state
const containerState = await this.containerHealth(blueprint.name, node);
if (!containerState.allRunning) {
return { drifted: true, reason: containerState.detail };
}
return { drifted: false };
} catch (err) {
return { drifted: true, reason: BlueprintService.formatError(err) };
}
}
private async containerHealth(blueprintName: string, node: Node): Promise<{ allRunning: boolean; detail: string }> {
try {
// Docker Compose normalizes the project name to lowercase. Match the same canonical form.
const projectName = blueprintName.toLowerCase();
if (node.type === 'local') {
const docker = NodeRegistry.getInstance().getDocker(node.id);
const containers = await docker.listContainers({
all: true,
filters: { label: [`com.docker.compose.project=${projectName}`] },
});
if (containers.length === 0) return { allRunning: false, detail: 'no containers running for this blueprint' };
const notRunning = containers.filter(c => c.State !== 'running');
if (notRunning.length > 0) {
const first = notRunning[0];
return { allRunning: false, detail: `container "${first.Names[0] ?? first.Id.slice(0, 12)}" is ${first.State}` };
}
return { allRunning: true, detail: '' };
}
const target = NodeRegistry.getInstance().getProxyTarget(node.id);
if (!target) return { allRunning: false, detail: 'remote node not reachable (no proxy target)' };
const url = `${target.apiUrl.replace(/\/$/, '')}/api/stacks/${encodeURIComponent(blueprintName)}/containers`;
const res = await axios.get(url, {
headers: this.remoteHeaders(target.apiToken),
timeout: REMOTE_HTTP_TIMEOUT_MS,
validateStatus: () => true,
});
if (res.status !== 200) {
return { allRunning: false, detail: `remote stack lookup returned HTTP ${res.status}` };
}
const list = Array.isArray(res.data) ? res.data as Array<{ State?: string; Names?: string[]; Id?: string }> : [];
if (list.length === 0) return { allRunning: false, detail: 'remote stack has no containers' };
const notRunning = list.filter(c => (c.State ?? '') !== 'running');
if (notRunning.length > 0) {
const first = notRunning[0];
return { allRunning: false, detail: `remote container "${first.Names?.[0] ?? first.Id?.slice(0, 12)}" is ${first.State}` };
}
return { allRunning: true, detail: '' };
} catch (err) {
return { allRunning: false, detail: BlueprintService.formatError(err) };
}
}
// ---- local primitives ----
private async stackDirExists(node: Node, blueprintName: string): Promise<boolean> {
const baseDir = NodeRegistry.getInstance().getComposeDir(node.id);
const stackDir = path.resolve(baseDir, blueprintName);
if (!stackDir.startsWith(path.resolve(baseDir))) return false;
try {
const stat = await fsPromises.stat(stackDir);
return stat.isDirectory();
} catch {
return false;
}
}
private async deployLocal(blueprint: Blueprint, node: Node, marker: BlueprintMarker): Promise<void> {
const fs = FileSystemService.getInstance(node.id);
if (!(await this.stackDirExists(node, blueprint.name))) {
await fs.createStack(blueprint.name);
}
await fs.writeStackFile(blueprint.name, COMPOSE_FILENAME, blueprint.compose_content);
await fs.writeStackFile(blueprint.name, MARKER_FILENAME, JSON.stringify(marker, null, 2));
await ComposeService.getInstance(node.id).deployStack(blueprint.name, undefined, false);
}
private async withdrawLocal(blueprint: Blueprint, node: Node): Promise<void> {
try {
await ComposeService.getInstance(node.id).downStack(blueprint.name);
} catch (err) {
// best-effort: continue to delete the directory even if down fails
console.warn(`[BlueprintService] downStack failed for "${blueprint.name}" on node ${node.id}: ${BlueprintService.formatError(err)}`);
}
if (await this.stackDirExists(node, blueprint.name)) {
await FileSystemService.getInstance(node.id).deleteStack(blueprint.name);
}
}
// ---- remote primitives ----
private remoteHeaders(apiToken: string): Record<string, string> {
const proxy = LicenseService.getInstance().getProxyHeaders();
return {
Authorization: `Bearer ${apiToken}`,
[PROXY_TIER_HEADER]: proxy.tier,
[PROXY_VARIANT_HEADER]: proxy.variant ?? '',
'Content-Type': 'application/json',
};
}
private async deployRemote(blueprint: Blueprint, node: Node, marker: BlueprintMarker): Promise<void> {
const target = NodeRegistry.getInstance().getProxyTarget(node.id);
if (!target) throw new Error(`Remote node "${node.name}" has no proxy target configured`);
const baseUrl = target.apiUrl.replace(/\/$/, '');
const headers = this.remoteHeaders(target.apiToken);
// 1. Ensure stack exists. POST returns 409 when already exists; we treat that as success.
const createRes = await axios.post(`${baseUrl}/api/stacks`,
{ stackName: blueprint.name },
{ headers, timeout: REMOTE_HTTP_TIMEOUT_MS, validateStatus: () => true },
);
if (createRes.status >= 400 && createRes.status !== 409) {
throw new Error(`create stack: HTTP ${createRes.status} ${BlueprintService.extractApiError(createRes.data)}`);
}
// 2. Write the compose file
await this.remotePutFile(baseUrl, headers, blueprint.name, COMPOSE_FILENAME, blueprint.compose_content);
// 3. Write the marker (last so a partial failure leaves us in name_conflict-recoverable state)
await this.remotePutFile(baseUrl, headers, blueprint.name, MARKER_FILENAME, JSON.stringify(marker, null, 2));
// 4. Deploy
const deployRes = await axios.post(
`${baseUrl}/api/stacks/${encodeURIComponent(blueprint.name)}/deploy`,
{},
{ headers, timeout: REMOTE_HTTP_TIMEOUT_MS, validateStatus: () => true },
);
if (deployRes.status >= 400) {
throw new Error(`deploy: HTTP ${deployRes.status} ${BlueprintService.extractApiError(deployRes.data)}`);
}
}
private async withdrawRemote(blueprint: Blueprint, node: Node): Promise<void> {
const target = NodeRegistry.getInstance().getProxyTarget(node.id);
if (!target) throw new Error(`Remote node "${node.name}" has no proxy target configured`);
const baseUrl = target.apiUrl.replace(/\/$/, '');
const headers = this.remoteHeaders(target.apiToken);
// down (best-effort)
try {
await axios.post(
`${baseUrl}/api/stacks/${encodeURIComponent(blueprint.name)}/down`,
{},
{ headers, timeout: REMOTE_HTTP_TIMEOUT_MS, validateStatus: () => true },
);
} catch (err) {
console.warn(`[BlueprintService] remote down failed for "${blueprint.name}" on node ${node.id}: ${BlueprintService.formatError(err)}`);
}
// delete the stack directory entirely
const delRes = await axios.delete(
`${baseUrl}/api/stacks/${encodeURIComponent(blueprint.name)}`,
{ headers, timeout: REMOTE_HTTP_TIMEOUT_MS, validateStatus: () => true },
);
if (delRes.status >= 400 && delRes.status !== 404) {
throw new Error(`remote delete: HTTP ${delRes.status} ${BlueprintService.extractApiError(delRes.data)}`);
}
}
private async remotePutFile(
baseUrl: string,
headers: Record<string, string>,
stackName: string,
relPath: string,
content: string,
): Promise<void> {
const url = `${baseUrl}/api/stacks/${encodeURIComponent(stackName)}/files/content?path=${encodeURIComponent(relPath)}`;
const res = await axios.put(url,
{ content },
{ headers, timeout: REMOTE_HTTP_TIMEOUT_MS, validateStatus: () => true },
);
if (res.status >= 400) {
throw new Error(`PUT ${relPath}: HTTP ${res.status} ${BlueprintService.extractApiError(res.data)}`);
}
}
static parseMarker(content: string): BlueprintMarker | null {
try {
const parsed = JSON.parse(content);
if (parsed && typeof parsed === 'object'
&& typeof parsed.blueprintId === 'number'
&& typeof parsed.revision === 'number') {
return {
blueprintId: parsed.blueprintId,
revision: parsed.revision,
lastApplied: typeof parsed.lastApplied === 'number' ? parsed.lastApplied : 0,
};
}
} catch {
// fall through
}
return null;
}
static formatError(err: unknown): string {
if (axios.isAxiosError(err)) {
const ax = err as AxiosError<{ error?: string; message?: string }>;
if (ax.response?.data) {
const body = ax.response.data;
if (body && typeof body === 'object') {
if (typeof body.error === 'string') return body.error;
if (typeof body.message === 'string') return body.message;
}
}
if (ax.code) return `${ax.code}: ${ax.message}`;
return ax.message;
}
if (err instanceof Error) return err.message;
return String(err);
}
static extractApiError(body: unknown): string {
if (!body || typeof body !== 'object') return '';
const obj = body as Record<string, unknown>;
if (typeof obj.error === 'string') return obj.error;
if (typeof obj.message === 'string') return obj.message;
return '';
}
}
+365
View File
@@ -226,6 +226,61 @@ export interface FleetSnapshotFile {
content: string;
}
export type DriftMode = 'observe' | 'suggest' | 'enforce';
export type BlueprintClassification = 'stateless' | 'stateful' | 'unknown';
export type BlueprintDeploymentStatus =
| 'pending'
| 'pending_state_review'
| 'deploying'
| 'active'
| 'drifted'
| 'correcting'
| 'failed'
| 'withdrawing'
| 'withdrawn'
| 'evict_blocked'
| 'name_conflict';
export type BlueprintSelector =
| { type: 'labels'; any: string[]; all: string[] }
| { type: 'nodes'; ids: number[] };
export interface NodeLabelRow {
id: number;
node_id: number;
label: string;
created_at: number;
}
export interface Blueprint {
id: number;
name: string;
description: string | null;
compose_content: string;
selector: BlueprintSelector;
drift_mode: DriftMode;
classification: BlueprintClassification;
classification_reasons: string[];
enabled: boolean;
revision: number;
created_at: number;
updated_at: number;
created_by: string | null;
}
export interface BlueprintDeployment {
id: number;
blueprint_id: number;
node_id: number;
status: BlueprintDeploymentStatus;
applied_revision: number | null;
last_deployed_at: number | null;
last_checked_at: number | null;
last_drift_at: number | null;
drift_summary: string | null;
last_error: string | null;
}
export interface AuditLogEntry {
id: number;
timestamp: number;
@@ -520,6 +575,8 @@ export class DatabaseService {
this.migrateNotificationCategory();
this.migrateNotificationActor();
this.migrateMeshTables();
this.migrateNodeLabels();
this.migrateBlueprints();
// Reset the cache once at end of constructor in case any migration
// populated it via getGlobalSettings() and a subsequent migration
@@ -1272,6 +1329,74 @@ export class DatabaseService {
this.tryAddColumn('nodes', 'mesh_enabled', 'INTEGER NOT NULL DEFAULT 0');
}
private migrateNodeLabels(): void {
try {
this.db.prepare(`
CREATE TABLE IF NOT EXISTS node_labels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_id INTEGER NOT NULL,
label TEXT NOT NULL,
created_at INTEGER NOT NULL,
UNIQUE(node_id, label),
FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE
)
`).run();
this.db.prepare('CREATE INDEX IF NOT EXISTS idx_node_labels_node ON node_labels(node_id)').run();
this.db.prepare('CREATE INDEX IF NOT EXISTS idx_node_labels_label ON node_labels(label)').run();
} catch (e) {
console.warn('[DatabaseService] Could not create node_labels:', (e as Error).message);
}
}
private migrateBlueprints(): void {
try {
this.db.prepare(`
CREATE TABLE IF NOT EXISTS blueprints (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT,
compose_content TEXT NOT NULL,
selector_json TEXT NOT NULL,
drift_mode TEXT NOT NULL DEFAULT 'suggest',
classification TEXT NOT NULL DEFAULT 'unknown',
classification_reasons TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
revision INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
created_by TEXT
)
`).run();
this.db.prepare('CREATE INDEX IF NOT EXISTS idx_blueprints_enabled ON blueprints(enabled)').run();
} catch (e) {
console.warn('[DatabaseService] Could not create blueprints:', (e as Error).message);
}
try {
this.db.prepare(`
CREATE TABLE IF NOT EXISTS blueprint_deployments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
blueprint_id INTEGER NOT NULL,
node_id INTEGER NOT NULL,
status TEXT NOT NULL,
applied_revision INTEGER,
last_deployed_at INTEGER,
last_checked_at INTEGER,
last_drift_at INTEGER,
drift_summary TEXT,
last_error TEXT,
UNIQUE(blueprint_id, node_id),
FOREIGN KEY (blueprint_id) REFERENCES blueprints(id) ON DELETE CASCADE,
FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE
)
`).run();
this.db.prepare('CREATE INDEX IF NOT EXISTS idx_blueprint_deployments_blueprint ON blueprint_deployments(blueprint_id)').run();
this.db.prepare('CREATE INDEX IF NOT EXISTS idx_blueprint_deployments_node ON blueprint_deployments(node_id)').run();
this.db.prepare('CREATE INDEX IF NOT EXISTS idx_blueprint_deployments_status ON blueprint_deployments(status)').run();
} catch (e) {
console.warn('[DatabaseService] Could not create blueprint_deployments:', (e as Error).message);
}
}
// --- Sencho Mesh ---
public listMeshStacks(nodeId?: number): Array<{ id: number; node_id: number; stack_name: string; created_at: number; created_by: string | null }> {
@@ -3621,4 +3746,244 @@ export class DatabaseService {
).run(nodeId, ...validStackNames);
return result.changes;
}
// --- Node Labels (fleet-level orchestration) ---
public listNodeLabels(nodeId?: number): NodeLabelRow[] {
const sql = nodeId !== undefined
? 'SELECT id, node_id, label, created_at FROM node_labels WHERE node_id = ? ORDER BY label'
: 'SELECT id, node_id, label, created_at FROM node_labels ORDER BY node_id, label';
const rows = nodeId !== undefined
? this.db.prepare(sql).all(nodeId)
: this.db.prepare(sql).all();
return rows as NodeLabelRow[];
}
public listDistinctNodeLabels(): string[] {
const rows = this.db.prepare('SELECT DISTINCT label FROM node_labels ORDER BY label').all() as { label: string }[];
return rows.map(r => r.label);
}
public addNodeLabel(nodeId: number, label: string): NodeLabelRow {
const trimmed = label.trim();
if (!trimmed) throw new Error('Label must not be empty');
const now = Date.now();
const result = this.db.prepare(
'INSERT OR IGNORE INTO node_labels (node_id, label, created_at) VALUES (?, ?, ?)'
).run(nodeId, trimmed, now);
const row = result.changes > 0
? this.db.prepare('SELECT id, node_id, label, created_at FROM node_labels WHERE id = ?').get(result.lastInsertRowid)
: this.db.prepare('SELECT id, node_id, label, created_at FROM node_labels WHERE node_id = ? AND label = ?').get(nodeId, trimmed);
return row as NodeLabelRow;
}
public removeNodeLabel(nodeId: number, label: string): boolean {
const result = this.db.prepare('DELETE FROM node_labels WHERE node_id = ? AND label = ?').run(nodeId, label);
return result.changes > 0;
}
public getNodeLabelsMap(): Record<number, string[]> {
const rows = this.db.prepare('SELECT node_id, label FROM node_labels ORDER BY node_id, label').all() as { node_id: number; label: string }[];
const map: Record<number, string[]> = {};
for (const row of rows) {
if (!map[row.node_id]) map[row.node_id] = [];
map[row.node_id].push(row.label);
}
return map;
}
// --- Blueprints ---
private parseBlueprint(row: Record<string, unknown>): Blueprint {
return {
id: row.id as number,
name: row.name as string,
description: (row.description as string | null) ?? null,
compose_content: row.compose_content as string,
selector: JSON.parse(row.selector_json as string) as BlueprintSelector,
drift_mode: row.drift_mode as DriftMode,
classification: row.classification as BlueprintClassification,
classification_reasons: row.classification_reasons
? (JSON.parse(row.classification_reasons as string) as string[])
: [],
enabled: row.enabled === 1,
revision: row.revision as number,
created_at: row.created_at as number,
updated_at: row.updated_at as number,
created_by: (row.created_by as string | null) ?? null,
};
}
public listBlueprints(): Blueprint[] {
return this.db.prepare('SELECT * FROM blueprints ORDER BY name')
.all()
.map(row => this.parseBlueprint(row as Record<string, unknown>));
}
public listEnabledBlueprints(): Blueprint[] {
return this.db.prepare('SELECT * FROM blueprints WHERE enabled = 1 ORDER BY name')
.all()
.map(row => this.parseBlueprint(row as Record<string, unknown>));
}
public getBlueprint(id: number): Blueprint | undefined {
const row = this.db.prepare('SELECT * FROM blueprints WHERE id = ?').get(id) as Record<string, unknown> | undefined;
return row ? this.parseBlueprint(row) : undefined;
}
public getBlueprintByName(name: string): Blueprint | undefined {
const row = this.db.prepare('SELECT * FROM blueprints WHERE name = ?').get(name) as Record<string, unknown> | undefined;
return row ? this.parseBlueprint(row) : undefined;
}
public createBlueprint(input: {
name: string;
description: string | null;
compose_content: string;
selector: BlueprintSelector;
drift_mode: DriftMode;
classification: BlueprintClassification;
classification_reasons: string[];
enabled: boolean;
created_by: string | null;
}): Blueprint {
const now = Date.now();
const result = this.db.prepare(
`INSERT INTO blueprints (name, description, compose_content, selector_json, drift_mode, classification, classification_reasons, enabled, revision, created_at, updated_at, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)`
).run(
input.name,
input.description,
input.compose_content,
JSON.stringify(input.selector),
input.drift_mode,
input.classification,
JSON.stringify(input.classification_reasons),
input.enabled ? 1 : 0,
now,
now,
input.created_by,
);
const created = this.getBlueprint(result.lastInsertRowid as number);
if (!created) throw new Error('Failed to fetch created blueprint');
return created;
}
public updateBlueprint(id: number, updates: {
name?: string;
description?: string | null;
compose_content?: string;
selector?: BlueprintSelector;
drift_mode?: DriftMode;
classification?: BlueprintClassification;
classification_reasons?: string[];
enabled?: boolean;
bumpRevision?: boolean;
}): Blueprint | undefined {
const existing = this.getBlueprint(id);
if (!existing) return undefined;
const fields: string[] = [];
const values: unknown[] = [];
if (updates.name !== undefined) { fields.push('name = ?'); values.push(updates.name); }
if (updates.description !== undefined) { fields.push('description = ?'); values.push(updates.description); }
if (updates.compose_content !== undefined) { fields.push('compose_content = ?'); values.push(updates.compose_content); }
if (updates.selector !== undefined) { fields.push('selector_json = ?'); values.push(JSON.stringify(updates.selector)); }
if (updates.drift_mode !== undefined) { fields.push('drift_mode = ?'); values.push(updates.drift_mode); }
if (updates.classification !== undefined) { fields.push('classification = ?'); values.push(updates.classification); }
if (updates.classification_reasons !== undefined) { fields.push('classification_reasons = ?'); values.push(JSON.stringify(updates.classification_reasons)); }
if (updates.enabled !== undefined) { fields.push('enabled = ?'); values.push(updates.enabled ? 1 : 0); }
if (updates.bumpRevision) { fields.push('revision = revision + 1'); }
fields.push('updated_at = ?');
values.push(Date.now());
values.push(id);
this.db.prepare(`UPDATE blueprints SET ${fields.join(', ')} WHERE id = ?`).run(...values);
return this.getBlueprint(id);
}
public deleteBlueprint(id: number): boolean {
const result = this.db.prepare('DELETE FROM blueprints WHERE id = ?').run(id);
return result.changes > 0;
}
// --- Blueprint Deployments ---
private parseBlueprintDeployment(row: Record<string, unknown>): BlueprintDeployment {
return {
id: row.id as number,
blueprint_id: row.blueprint_id as number,
node_id: row.node_id as number,
status: row.status as BlueprintDeploymentStatus,
applied_revision: (row.applied_revision as number | null) ?? null,
last_deployed_at: (row.last_deployed_at as number | null) ?? null,
last_checked_at: (row.last_checked_at as number | null) ?? null,
last_drift_at: (row.last_drift_at as number | null) ?? null,
drift_summary: (row.drift_summary as string | null) ?? null,
last_error: (row.last_error as string | null) ?? null,
};
}
public listDeployments(blueprintId: number): BlueprintDeployment[] {
return this.db.prepare('SELECT * FROM blueprint_deployments WHERE blueprint_id = ? ORDER BY node_id')
.all(blueprintId)
.map(row => this.parseBlueprintDeployment(row as Record<string, unknown>));
}
public listAllDeployments(): BlueprintDeployment[] {
return this.db.prepare('SELECT * FROM blueprint_deployments')
.all()
.map(row => this.parseBlueprintDeployment(row as Record<string, unknown>));
}
public getDeployment(blueprintId: number, nodeId: number): BlueprintDeployment | undefined {
const row = this.db.prepare('SELECT * FROM blueprint_deployments WHERE blueprint_id = ? AND node_id = ?').get(blueprintId, nodeId) as Record<string, unknown> | undefined;
return row ? this.parseBlueprintDeployment(row) : undefined;
}
public upsertDeployment(input: {
blueprint_id: number;
node_id: number;
status: BlueprintDeploymentStatus;
applied_revision?: number | null;
last_deployed_at?: number | null;
last_checked_at?: number | null;
last_drift_at?: number | null;
drift_summary?: string | null;
last_error?: string | null;
}): BlueprintDeployment {
const existing = this.getDeployment(input.blueprint_id, input.node_id);
if (existing) {
const fields: string[] = ['status = ?'];
const values: unknown[] = [input.status];
if (input.applied_revision !== undefined) { fields.push('applied_revision = ?'); values.push(input.applied_revision); }
if (input.last_deployed_at !== undefined) { fields.push('last_deployed_at = ?'); values.push(input.last_deployed_at); }
if (input.last_checked_at !== undefined) { fields.push('last_checked_at = ?'); values.push(input.last_checked_at); }
if (input.last_drift_at !== undefined) { fields.push('last_drift_at = ?'); values.push(input.last_drift_at); }
if (input.drift_summary !== undefined) { fields.push('drift_summary = ?'); values.push(input.drift_summary); }
if (input.last_error !== undefined) { fields.push('last_error = ?'); values.push(input.last_error); }
values.push(input.blueprint_id, input.node_id);
this.db.prepare(`UPDATE blueprint_deployments SET ${fields.join(', ')} WHERE blueprint_id = ? AND node_id = ?`).run(...values);
} else {
this.db.prepare(
`INSERT INTO blueprint_deployments (blueprint_id, node_id, status, applied_revision, last_deployed_at, last_checked_at, last_drift_at, drift_summary, last_error)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
input.blueprint_id,
input.node_id,
input.status,
input.applied_revision ?? null,
input.last_deployed_at ?? null,
input.last_checked_at ?? null,
input.last_drift_at ?? null,
input.drift_summary ?? null,
input.last_error ?? null,
);
}
const updated = this.getDeployment(input.blueprint_id, input.node_id);
if (!updated) throw new Error('Failed to upsert deployment');
return updated;
}
public deleteDeployment(blueprintId: number, nodeId: number): void {
this.db.prepare('DELETE FROM blueprint_deployments WHERE blueprint_id = ? AND node_id = ?').run(blueprintId, nodeId);
}
}
+80
View File
@@ -0,0 +1,80 @@
import { DatabaseService, type BlueprintSelector, type Node } from './DatabaseService';
export const MAX_NODE_LABELS = 50;
const LABEL_PATTERN = /^[a-z0-9][a-z0-9._-]*$/i;
const MAX_LABEL_LENGTH = 40;
export interface NodeLabelValidationError {
error: string;
code: 'invalid_format' | 'too_long' | 'empty' | 'too_many';
}
export class NodeLabelService {
private static instance: NodeLabelService | null = null;
static getInstance(): NodeLabelService {
if (!NodeLabelService.instance) {
NodeLabelService.instance = new NodeLabelService();
}
return NodeLabelService.instance;
}
private constructor() { /* singleton */ }
listAll(): Record<number, string[]> {
return DatabaseService.getInstance().getNodeLabelsMap();
}
listForNode(nodeId: number): string[] {
return DatabaseService.getInstance().listNodeLabels(nodeId).map(r => r.label);
}
listDistinct(): string[] {
return DatabaseService.getInstance().listDistinctNodeLabels();
}
validate(rawLabel: string): NodeLabelValidationError | null {
const label = rawLabel.trim();
if (!label) return { error: 'label must not be empty', code: 'empty' };
if (label.length > MAX_LABEL_LENGTH) return { error: `label must be ${MAX_LABEL_LENGTH} characters or fewer`, code: 'too_long' };
if (!LABEL_PATTERN.test(label)) return { error: 'label may contain letters, digits, dot, dash, underscore', code: 'invalid_format' };
return null;
}
addLabel(nodeId: number, rawLabel: string): { ok: true; label: string } | { ok: false; error: NodeLabelValidationError } {
const validationError = this.validate(rawLabel);
if (validationError) return { ok: false, error: validationError };
const label = rawLabel.trim();
const db = DatabaseService.getInstance();
const existing = db.listNodeLabels(nodeId);
if (existing.length >= MAX_NODE_LABELS && !existing.some(r => r.label === label)) {
return { ok: false, error: { error: `nodes can have at most ${MAX_NODE_LABELS} labels`, code: 'too_many' } };
}
db.addNodeLabel(nodeId, label);
return { ok: true, label };
}
removeLabel(nodeId: number, label: string): boolean {
return DatabaseService.getInstance().removeNodeLabel(nodeId, label);
}
matchSelector(selector: BlueprintSelector, nodes: Node[]): Node[] {
if (selector.type === 'nodes') {
const ids = new Set(selector.ids);
return nodes.filter(n => ids.has(n.id));
}
if (selector.type === 'labels') {
const allRequired = (selector.all ?? []).filter(s => s.length > 0);
const anyRequired = (selector.any ?? []).filter(s => s.length > 0);
if (allRequired.length === 0 && anyRequired.length === 0) return [];
const labelsByNode = DatabaseService.getInstance().getNodeLabelsMap();
return nodes.filter(node => {
const nodeLabels = new Set(labelsByNode[node.id] ?? []);
if (allRequired.length > 0 && !allRequired.every(l => nodeLabels.has(l))) return false;
if (anyRequired.length > 0 && !anyRequired.some(l => nodeLabels.has(l))) return false;
return true;
});
}
return [];
}
}
+8 -1
View File
@@ -16,12 +16,19 @@ export type NotificationCategory =
| 'autoheal_triggered'
| 'monitor_alert'
| 'scan_finding'
| 'blueprint_deployed'
| 'blueprint_deployment_failed'
| 'blueprint_drift_detected'
| 'blueprint_drift_correction_failed'
| 'system';
export const ALL_NOTIFICATION_CATEGORIES: readonly NotificationCategory[] = [
'deploy_success', 'deploy_failure', 'stack_started', 'stack_stopped',
'stack_restarted', 'image_update_available', 'image_update_applied',
'autoheal_triggered', 'monitor_alert', 'scan_finding', 'system',
'autoheal_triggered', 'monitor_alert', 'scan_finding',
'blueprint_deployed', 'blueprint_deployment_failed',
'blueprint_drift_detected', 'blueprint_drift_correction_failed',
'system',
];
/** Webhook timeout: 10 seconds per external dispatch call. */