mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 03:36:59 +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,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' });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user