mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-21 23:56:39 +00:00
feat(blueprints): backend foundation for fleet-wide compose templates (#860)
* feat(blueprints): add backend foundation for fleet-wide compose templates
Introduces the Blueprint Model: a docker-compose.yml plus a node selector
(labels or explicit IDs) that Sencho reconciles across the fleet. Backend
foundation only; the frontend tab and documentation follow.
Schema (DatabaseService):
- node_labels table for fleet-level orchestration tagging
- blueprints table with compose content, selector, drift_mode, classification
- blueprint_deployments table for per-node materialized state
- New idempotent migrate methods following the existing pattern
Services:
- BlueprintAnalyzer: pure compose-YAML classifier (stateless / stateful /
unknown) with 17 covered cases including named volumes, bind mounts,
external volumes, and tmpfs
- NodeLabelService: label CRUD plus selector matching helper (any/all/ids)
- BlueprintService: local + remote deploy/withdraw orchestration, marker
file management, name-conflict guard, per-(blueprint,node) lock
- BlueprintReconciler: 60-second loop with three-mode drift policy
(observe/suggest/enforce), state-aware guards, and Enforce-downgrade for
volume-destroying drift
Routes (gated requirePaid + requireAdmin on mutations):
- /api/blueprints (CRUD + apply + withdraw + accept + preview + analyze)
- /api/node-labels (CRUD + listAll + listDistinct)
Notifications: four new categories registered in NotificationService for
deploy/failure/drift events.
Bootstrap: reconciler start/stop wired in startup and shutdown.
Tests: 45 new Vitest cases covering selector matching, classifier rules,
state-aware guards, drift-mode branching, and marker parsing. Full backend
suite (1625 tests) passes; tsc clean.
* fix(lint): replace bare Function type in blueprint reconciler tests
Replace 8 occurrences of `as unknown as { computeDecision: Function }`
with a properly typed `ReconcilerWithCompute` alias that mirrors the
real method signature. Export `ReconcileDecision` from
BlueprintReconciler so the test can reference it.
Resolves @typescript-eslint/no-unsafe-function-type errors that were
failing the Backend (Lint) CI step.
This commit is contained in:
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user