From 685d5d729e71e6320aa015c88609b3cbda5a1f9b Mon Sep 17 00:00:00 2001 From: Anso Date: Fri, 1 May 2026 18:57:44 -0400 Subject: [PATCH] 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. --- .../src/__tests__/blueprint-analyzer.test.ts | 255 +++++++++ backend/src/__tests__/blueprints.test.ts | 214 ++++++++ backend/src/__tests__/node-labels.test.ts | 197 +++++++ backend/src/bootstrap/shutdown.ts | 4 + backend/src/bootstrap/startup.ts | 2 + backend/src/index.ts | 4 + backend/src/routes/blueprints.ts | 411 +++++++++++++++ backend/src/routes/nodeLabels.ts | 99 ++++ backend/src/services/BlueprintAnalyzer.ts | 224 ++++++++ backend/src/services/BlueprintReconciler.ts | 305 +++++++++++ backend/src/services/BlueprintService.ts | 489 ++++++++++++++++++ backend/src/services/DatabaseService.ts | 365 +++++++++++++ backend/src/services/NodeLabelService.ts | 80 +++ backend/src/services/NotificationService.ts | 9 +- 14 files changed, 2657 insertions(+), 1 deletion(-) create mode 100644 backend/src/__tests__/blueprint-analyzer.test.ts create mode 100644 backend/src/__tests__/blueprints.test.ts create mode 100644 backend/src/__tests__/node-labels.test.ts create mode 100644 backend/src/routes/blueprints.ts create mode 100644 backend/src/routes/nodeLabels.ts create mode 100644 backend/src/services/BlueprintAnalyzer.ts create mode 100644 backend/src/services/BlueprintReconciler.ts create mode 100644 backend/src/services/BlueprintService.ts create mode 100644 backend/src/services/NodeLabelService.ts diff --git a/backend/src/__tests__/blueprint-analyzer.test.ts b/backend/src/__tests__/blueprint-analyzer.test.ts new file mode 100644 index 00000000..e4b879f4 --- /dev/null +++ b/backend/src/__tests__/blueprint-analyzer.test.ts @@ -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); + }); +}); diff --git a/backend/src/__tests__/blueprints.test.ts b/backend/src/__tests__/blueprints.test.ts new file mode 100644 index 00000000..46c1613d --- /dev/null +++ b/backend/src/__tests__/blueprints.test.ts @@ -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(); + }); +}); diff --git a/backend/src/__tests__/node-labels.test.ts b/backend/src/__tests__/node-labels.test.ts new file mode 100644 index 00000000..5fc601da --- /dev/null +++ b/backend/src/__tests__/node-labels.test.ts @@ -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([]); + }); +}); diff --git a/backend/src/bootstrap/shutdown.ts b/backend/src/bootstrap/shutdown.ts index df6b31b6..74254b40 100644 --- a/backend/src/bootstrap/shutdown.ts +++ b/backend/src/bootstrap/shutdown.ts @@ -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); } diff --git a/backend/src/bootstrap/startup.ts b/backend/src/bootstrap/startup.ts index 0d1c3999..6dd217fd 100644 --- a/backend/src/bootstrap/startup.ts +++ b/backend/src/bootstrap/startup.ts @@ -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 { 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. diff --git a/backend/src/index.ts b/backend/src/index.ts index a61f8587..7a8224f8 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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); diff --git a/backend/src/routes/blueprints.ts b/backend/src/routes/blueprints.ts new file mode 100644 index 00000000..4d384497 --- /dev/null +++ b/backend/src/routes/blueprints.ts @@ -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; + 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(); + 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 = {}; + 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 = {}; + 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[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 => { + 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 => { + 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 => { + 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 => { + 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); +}); diff --git a/backend/src/routes/nodeLabels.ts b/backend/src/routes/nodeLabels.ts new file mode 100644 index 00000000..286947ea --- /dev/null +++ b/backend/src/routes/nodeLabels.ts @@ -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' }); + } +}); diff --git a/backend/src/services/BlueprintAnalyzer.ts b/backend/src/services/BlueprintAnalyzer.ts new file mode 100644 index 00000000..5b9bfa3e --- /dev/null +++ b/backend/src/services/BlueprintAnalyzer.ts @@ -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 | null; + volumes?: Record | null; +} + +interface ComposeService { + volumes?: Array | 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(); + const declaredNamedVolumes = new Set(); + 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 { + try { + const doc = (parseYaml(composeContent) ?? {}) as ComposeShape; + const out = new Set(); + 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(); + } + } +} diff --git a/backend/src/services/BlueprintReconciler.ts b/backend/src/services/BlueprintReconciler.ts new file mode 100644 index 00000000..13f4f759 --- /dev/null +++ b/backend/src/services/BlueprintReconciler.ts @@ -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 | null = null; + private initialTimer: ReturnType | 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 { + 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 { + 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 { + 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 { + 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(); + 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 { + 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 { + 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); + } +} diff --git a/backend/src/services/BlueprintService.ts b/backend/src/services/BlueprintService.ts new file mode 100644 index 00000000..6a986568 --- /dev/null +++ b/backend/src/services/BlueprintService.ts @@ -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(); + + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + stackName: string, + relPath: string, + content: string, + ): Promise { + 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; + if (typeof obj.error === 'string') return obj.error; + if (typeof obj.message === 'string') return obj.message; + return ''; + } +} diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index a05ae2e7..4ce1e73f 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -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 { + 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 = {}; + 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): 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)); + } + + public listEnabledBlueprints(): Blueprint[] { + return this.db.prepare('SELECT * FROM blueprints WHERE enabled = 1 ORDER BY name') + .all() + .map(row => this.parseBlueprint(row as Record)); + } + + public getBlueprint(id: number): Blueprint | undefined { + const row = this.db.prepare('SELECT * FROM blueprints WHERE id = ?').get(id) as Record | 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 | 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): 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)); + } + + public listAllDeployments(): BlueprintDeployment[] { + return this.db.prepare('SELECT * FROM blueprint_deployments') + .all() + .map(row => this.parseBlueprintDeployment(row as Record)); + } + + 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 | 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); + } } diff --git a/backend/src/services/NodeLabelService.ts b/backend/src/services/NodeLabelService.ts new file mode 100644 index 00000000..9a225cef --- /dev/null +++ b/backend/src/services/NodeLabelService.ts @@ -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 { + 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 []; + } +} diff --git a/backend/src/services/NotificationService.ts b/backend/src/services/NotificationService.ts index aa4ad790..ef25d90f 100644 --- a/backend/src/services/NotificationService.ts +++ b/backend/src/services/NotificationService.ts @@ -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. */