Files
sencho/backend/src/routes/blueprints.ts
T
Anso 5f1baa7522 fix: harden deploy/update concurrency and node-targeting safety (#1390)
* fix: harden deploy/update concurrency and node-targeting safety

Release stabilization for deploy/update operational safety.

Per-stack operation locking is now global. Background lifecycle paths
(scheduler auto stop/down/start/backup/update, webhook execute, Git source
auto-deploy, image auto-update, label bulk actions, fleet snapshot redeploy,
and mesh redeploy) acquire the per-node, per-stack lock through a new
StackOpLockService.runExclusive helper and skip rather than race a manual
deploy/update/rollback/backup on the same stack and node. Skips surface
honestly (a failed scheduled run, a recorded webhook failure, a per-stack
batch result, or a thrown error) instead of a silent no-op.

Update readiness and policy-bypass now run against the node captured when the
dialog opened, not the live active node, so switching nodes while a dialog is
open cannot retarget the update or the bypass retry.

Rollback readiness no longer presents a moving-tag or unpinned image as a ready
image revert. Restoring files does not revert a moving tag, so those stacks
read as partial, and the rollback success message states that the compose and
env files were restored.

* fix: lock blueprint reconcile against manual ops and correct rollback wording

Follow-up to the deploy/update safety hardening, closing two more gaps from a
verification pass.

BlueprintService.deployLocal and withdrawLocal called ComposeService directly,
so blueprint reconciliation could race a manual deploy/update/rollback/backup on
an owned stack. Both now run their compose lifecycle call through
StackOpLockService.runExclusive and skip (recorded as a failed reconcile,
retried on the next cycle) on conflict. The withdraw holds the lock across both
the compose down and the directory delete so neither races a manual operation.

The runtime rollback messages overstated recovery: a rollback restores the
compose and env files and recreates containers, but does not revert an image
behind a moving tag. The auto-rollback deploy-progress output, the recovery
panel and chip, the failure toasts, and the manual rollback route message now
state that the compose and env files were restored, with the matching OpenAPI
example and atomic-deployments doc updated.

* fix: acquire stack lock before blueprint deploy mutates compose and marker files

Local blueprint deploy wrote the compose and marker files and ran the policy
assert before acquiring the per-stack lock; the lock only wrapped the deploy
itself. A reconcile could therefore rewrite an owned stack's files while a
manual deploy/update/rollback/backup was running. The lock now wraps the whole
critical section (create, write compose, write marker, policy assert, deploy),
so on conflict nothing is written and the reconcile records a failed outcome.

Adds a test asserting a deploy under a held lock records failed, writes no
marker file, and leaves the manual lock untouched.

* fix: make remote blueprint apply atomic under the receiving node's stack lock

Remote blueprint deploy wrote the compose and marker files to the target node
via separate HTTP calls and only locked on the final deploy, so the file writes
could race a manual operation on that node. A node's operation lock is
process-local and cannot be held by the hub across HTTP calls, so the locked
create/write/deploy now runs on the receiving node.

The locked critical section is extracted into BlueprintService.applyLocalUnderLock
and exposed via POST /api/blueprints/apply-local. The hub posts the blueprint to
that endpoint in one call; the receiving node runs create + write compose+marker
+ deploy under its own per-stack lock. Older nodes without the route answer 404
and fall back to the legacy multi-call flow. The endpoint is gated by paid tier
and the same per-stack stack:edit and stack:deploy permissions as the
PUT-compose + deploy it bundles, validates the stack name, compose size, and
marker structure, and returns 409 on a lock conflict without writing anything.

Adds tests for the atomic single-call path, the 404 legacy fallback, the 409
lock-conflict mapping, the route validation and permission paths, and the
write-compose-then-marker-then-deploy ordering of the shared locked apply.

* fix(deps): bump undici to 7.28.0 to clear high-severity advisory

The frontend CI npm audit gate (--audit-level=high) failed on a transitive
undici 7.25.0 (a dev-only dependency via jsdom): TLS certificate validation
bypass (GHSA-vmh5-mc38-953g) and cross-user cache information disclosure
(GHSA-pr7r-676h-xcf6). Bumping undici within jsdom's existing ^7.25.0 range to
7.28.0 clears the high-severity advisory and unblocks the frontend job. Lockfile
only; no direct dependency or source change.
2026-06-18 13:38:19 -04:00

575 lines
28 KiB
TypeScript

import { Router, type Request, type Response } from 'express';
import { authMiddleware } from '../middleware/auth';
import { requirePaid, requireAdmin, requireBody } from '../middleware/tierGates';
import { requirePermission } from '../middleware/permissions';
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 { isDebugEnabled } from '../utils/debug';
import { sanitizeForLog } from '../utils/safeLog';
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;
export const MAX_BLUEPRINT_COMPOSE_BYTES = 96 * 1024;
const BLUEPRINT_NAME_PATTERN = /^[a-z0-9][a-z0-9_-]*$/;
interface BlueprintBody {
name?: unknown;
description?: unknown;
compose_content?: unknown;
selector?: unknown;
drift_mode?: unknown;
enabled?: unknown;
}
function parseSelector(raw: unknown): { ok: true; selector: BlueprintSelector } | { ok: false; error: string } {
if (!raw || typeof raw !== 'object') return { ok: false, error: 'selector is required' };
const obj = raw as Record<string, unknown>;
if (obj.type === 'nodes') {
if (!Array.isArray(obj.ids)) return { ok: false, error: 'selector.ids must be an array' };
if (obj.ids.length > MAX_SELECTOR_ENTRIES) return { ok: false, error: `selector.ids may not exceed ${MAX_SELECTOR_ENTRIES} entries` };
const seen = new Set<number>();
for (const v of obj.ids) {
if (typeof v !== 'number' || !Number.isInteger(v) || v < 0) return { ok: false, error: 'selector.ids must contain positive integers' };
seen.add(v);
}
return { ok: true, selector: { type: 'nodes', ids: Array.from(seen) } };
}
if (obj.type === 'labels') {
const anyRaw = Array.isArray(obj.any) ? obj.any : [];
const allRaw = Array.isArray(obj.all) ? obj.all : [];
if (anyRaw.length > MAX_SELECTOR_ENTRIES || allRaw.length > MAX_SELECTOR_ENTRIES) {
return { ok: false, error: `selector.any and selector.all may not exceed ${MAX_SELECTOR_ENTRIES} entries each` };
}
for (const v of [...anyRaw, ...allRaw]) {
if (typeof v !== 'string') return { ok: false, error: 'selector labels must be strings' };
}
const any = Array.from(new Set(anyRaw as string[]));
const all = Array.from(new Set(allRaw as string[]));
return { ok: true, selector: { type: 'labels', any, all } };
}
return { ok: false, error: 'selector.type must be "labels" or "nodes"' };
}
function validateName(name: unknown): string | null {
if (typeof name !== 'string') return 'name must be a string';
const trimmed = name.trim();
if (trimmed.length === 0) return 'name is required';
if (trimmed.length > 64) return 'name must be 64 characters or fewer';
if (!isValidStackName(trimmed)) return 'name must be alphanumeric, hyphens, or underscores only';
// Compose normalizes project names to lowercase; require it up-front so container labels match.
if (!BLUEPRINT_NAME_PATTERN.test(trimmed)) return 'name must be lowercase letters, digits, hyphens, and underscores (must start with a letter or digit)';
return null;
}
function validateDescription(description: unknown): string | null {
if (description == null) return null;
if (typeof description !== 'string') return 'description must be a string';
if (description.length > MAX_DESCRIPTION_LENGTH) return `description must be ${MAX_DESCRIPTION_LENGTH} characters or fewer`;
return null;
}
function validateDriftMode(mode: unknown): string | null {
if (typeof mode !== 'string') return 'drift_mode must be a string';
if (!VALID_DRIFT_MODES.includes(mode as DriftMode)) return `drift_mode must be one of: ${VALID_DRIFT_MODES.join(', ')}`;
return null;
}
function validateComposeContent(composeContent: unknown): string | null {
if (typeof composeContent !== 'string' || composeContent.trim().length === 0) {
return 'compose_content must be a non-empty string';
}
if (Buffer.byteLength(composeContent, 'utf8') > MAX_BLUEPRINT_COMPOSE_BYTES) {
return `compose_content must be ${MAX_BLUEPRINT_COMPOSE_BYTES} bytes or fewer`;
}
const analysis = BlueprintAnalyzer.analyze(composeContent);
if (analysis.parseError) return `compose_content must be valid YAML: ${analysis.parseError}`;
return null;
}
function summarizeBlueprint(blueprintId: number) {
const db = DatabaseService.getInstance();
const blueprint = db.getBlueprint(blueprintId);
if (!blueprint) return null;
const deployments = db.listDeployments(blueprintId);
const counts: Record<string, number> = {};
for (const dep of deployments) {
counts[dep.status] = (counts[dep.status] ?? 0) + 1;
}
return { blueprint, deployments, statusCounts: counts };
}
blueprintsRouter.get('/', (req: Request, res: Response): void => {
if (!requirePaid(req, res)) return;
try {
const blueprints = DatabaseService.getInstance().listBlueprints();
const summaries = blueprints.map(b => {
const deployments = DatabaseService.getInstance().listDeployments(b.id);
const counts: Record<string, number> = {};
for (const dep of deployments) counts[dep.status] = (counts[dep.status] ?? 0) + 1;
return { ...b, deploymentCounts: counts, deploymentTotal: deployments.length };
});
res.json(summaries);
} catch (error) {
console.error('[Blueprints] List error:', error);
res.status(500).json({ error: 'Failed to list blueprints' });
}
});
blueprintsRouter.post('/', (req: Request, res: Response): void => {
if (!requirePaid(req, res)) return;
if (!requireAdmin(req, res)) return;
if (!requireBody(req, res)) return;
const body = req.body as BlueprintBody;
const nameError = validateName(body.name);
if (nameError) { res.status(400).json({ error: nameError }); return; }
const composeError = validateComposeContent(body.compose_content);
if (composeError) { res.status(400).json({ error: composeError }); 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 composeContent = body.compose_content as string;
const analysis = BlueprintAnalyzer.analyze(composeContent);
const blueprint = DatabaseService.getInstance().createBlueprint({
name: (body.name as string).trim(),
description: typeof body.description === 'string' ? body.description : null,
compose_content: composeContent,
selector: selectorResult.selector,
drift_mode: (body.drift_mode as DriftMode | undefined) ?? 'suggest',
classification: analysis.classification,
classification_reasons: analysis.reasons,
enabled: body.enabled === undefined ? true : Boolean(body.enabled),
created_by: req.user?.username ?? null,
});
res.status(201).json(blueprint);
} catch (error) {
if (isSqliteUniqueViolation(error)) {
res.status(409).json({ error: 'A blueprint with that name already exists' });
return;
}
console.error('[Blueprints] Create error:', error);
res.status(500).json({ error: 'Failed to create blueprint' });
}
});
blueprintsRouter.get('/:id', (req: Request, res: Response): void => {
if (!requirePaid(req, res)) return;
const id = parseIntParam(req, res, 'id');
if (id === null) return;
try {
const summary = summarizeBlueprint(id);
if (!summary) { res.status(404).json({ error: 'Blueprint not found' }); return; }
res.json(summary);
} catch (error) {
console.error('[Blueprints] Get error:', error);
res.status(500).json({ error: 'Failed to fetch blueprint' });
}
});
blueprintsRouter.put('/:id', (req: Request, res: Response): void => {
if (!requirePaid(req, res)) return;
if (!requireAdmin(req, res)) return;
if (!requireBody(req, res)) return;
const id = parseIntParam(req, res, 'id');
if (id === null) return;
const body = req.body as BlueprintBody;
const updates: Parameters<DatabaseService['updateBlueprint']>[1] = {};
if (body.name !== undefined) {
const nameError = validateName(body.name);
if (nameError) { res.status(400).json({ error: nameError }); return; }
updates.name = (body.name as string).trim();
}
if (body.description !== undefined) {
const descError = validateDescription(body.description);
if (descError) { res.status(400).json({ error: descError }); return; }
updates.description = body.description as string | null;
}
if (body.compose_content !== undefined) {
const composeError = validateComposeContent(body.compose_content);
if (composeError) { res.status(400).json({ error: composeError }); return; }
const composeContent = body.compose_content as string;
const analysis = BlueprintAnalyzer.analyze(composeContent);
updates.compose_content = composeContent;
updates.classification = analysis.classification;
updates.classification_reasons = analysis.reasons;
updates.bumpRevision = true;
}
if (body.selector !== undefined) {
const selectorResult = parseSelector(body.selector);
if (!selectorResult.ok) { res.status(400).json({ error: selectorResult.error }); return; }
updates.selector = selectorResult.selector;
}
if (body.drift_mode !== undefined) {
const driftModeError = validateDriftMode(body.drift_mode);
if (driftModeError) { res.status(400).json({ error: driftModeError }); return; }
updates.drift_mode = body.drift_mode as DriftMode;
}
if (body.enabled !== undefined) {
const next = Boolean(body.enabled);
if (!next) {
// Refuse to disable a blueprint with active deployments. Operator must withdraw explicitly.
const existing = DatabaseService.getInstance().getBlueprint(id);
if (existing?.enabled) {
const deployments = DatabaseService.getInstance().listDeployments(id);
const blocking = deployments.filter(d =>
d.status === 'active' || d.status === 'drifted' || d.status === 'correcting' || d.status === 'evict_blocked',
);
if (blocking.length > 0) {
res.status(409).json({
error: `Cannot disable a blueprint with ${blocking.length} active or drifted deployment(s). Withdraw each deployment first.`,
code: 'has_active_deployments',
});
return;
}
}
}
updates.enabled = next;
}
try {
const updated = DatabaseService.getInstance().updateBlueprint(id, updates);
if (!updated) { res.status(404).json({ error: 'Blueprint not found' }); return; }
res.json(updated);
} catch (error) {
if (isSqliteUniqueViolation(error)) {
res.status(409).json({ error: 'A blueprint with that name already exists' });
return;
}
console.error('[Blueprints] Update error:', error);
res.status(500).json({ error: 'Failed to update blueprint' });
}
});
blueprintsRouter.delete('/:id', async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
if (!requireAdmin(req, res)) return;
const id = parseIntParam(req, res, 'id');
if (id === null) return;
try {
const blueprint = DatabaseService.getInstance().getBlueprint(id);
if (!blueprint) { res.status(404).json({ error: 'Blueprint not found' }); return; }
// Refuse delete on stateful blueprints that still have a stack Sencho deployed and
// owns on a node; the operator must withdraw those explicitly so the snapshot-vs-destroy
// choice is made. "Deployed by us" is last_deployed_at != null, which holds regardless of
// the current status (a previously deployed stack that later failed still carries it). A
// never-deployed row (e.g. a reconciler-created pending_state_review awaiting first deploy)
// has nothing of ours on the node, so it must not block delete. name_conflict is a
// same-name stack we do not own, and withdrawn is already gone, so neither blocks.
if (blueprint.classification === 'stateful' || blueprint.classification === 'unknown') {
const deployments = DatabaseService.getInstance().listDeployments(id);
const blocking = deployments.filter(d =>
d.last_deployed_at != null && d.status !== 'name_conflict' && d.status !== 'withdrawn',
);
if (blocking.length > 0) {
res.status(409).json({
error: `Cannot delete a stateful blueprint with ${blocking.length} live deployment(s). Withdraw each from the deployment table first.`,
code: 'stateful_deployments_blocking',
});
return;
}
}
// Best-effort cleanup before delete: withdraw exactly the rows a stateful delete would
// block, i.e. stacks Sencho deployed and still owns (last_deployed_at set, and neither a
// name_conflict nor an already-withdrawn row). Never run the withdraw primitive for a
// never-deployed or unmanaged row: withdrawFromNode proceeds on a missing marker and would
// down/delete a same-name stack Sencho does not own. The blueprint-delete cascade removes
// the rows the loop skips.
const nodes = DatabaseService.getInstance().getNodes();
const deployments = DatabaseService.getInstance().listDeployments(id);
for (const dep of deployments) {
if (dep.last_deployed_at == null || dep.status === 'name_conflict' || dep.status === 'withdrawn') continue;
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' });
}
});
// Node-to-node atomic blueprint apply. A hub posts here on the node that owns
// the stack so the create / write compose+marker / deploy runs under that node's
// per-stack lock (a remote node's lock is process-local and cannot be held by
// the hub over separate HTTP calls). Gated by paid tier plus per-stack stack:edit
// and stack:deploy, the same permissions as the PUT-compose + deploy it bundles;
// the node token the hub presents satisfies them.
blueprintsRouter.post('/apply-local', async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
const body = (req.body ?? {}) as { stackName?: unknown; composeContent?: unknown; markerContent?: unknown };
if (typeof body.stackName !== 'string' || !isValidStackName(body.stackName)) {
res.status(400).json({ error: 'Invalid stack name' });
return;
}
// Same per-stack RBAC as writing the compose file and deploying it directly.
if (!requirePermission(req, res, 'stack:edit', 'stack', body.stackName)) return;
if (!requirePermission(req, res, 'stack:deploy', 'stack', body.stackName)) return;
if (typeof body.composeContent !== 'string' || typeof body.markerContent !== 'string') {
res.status(400).json({ error: 'composeContent and markerContent are required strings' });
return;
}
if (Buffer.byteLength(body.composeContent, 'utf8') > MAX_BLUEPRINT_COMPOSE_BYTES) {
res.status(413).json({ error: 'compose content too large' });
return;
}
if (BlueprintService.parseMarker(body.markerContent) === null) {
res.status(400).json({ error: 'Invalid blueprint marker' });
return;
}
try {
const outcome = await BlueprintService.getInstance().applyLocalUnderLock(
req.nodeId, body.stackName, body.composeContent, body.markerContent, '/api/blueprints/apply-local',
);
if (!outcome.ran) {
res.status(409).json({
error: `${body.stackName} is busy: another operation (${outcome.existingAction}) is already in progress`,
code: 'stack_op_in_progress',
inProgress: { action: outcome.existingAction },
});
return;
}
res.json({ deployed: true });
} catch (error) {
console.error('[Blueprints] apply-local error:', sanitizeForLog(getErrorMessage(error, 'apply failed')));
res.status(500).json({ error: getErrorMessage(error, 'Blueprint apply failed') });
}
});
blueprintsRouter.post('/:id/apply', async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
if (!requireAdmin(req, res)) return;
const id = parseIntParam(req, res, 'id');
if (id === null) return;
try {
const blueprint = DatabaseService.getInstance().getBlueprint(id);
if (!blueprint) { res.status(404).json({ error: 'Blueprint not found' }); return; }
if (!blueprint.enabled) {
res.status(409).json({ error: 'Blueprint is disabled. Enable it before applying.', code: 'blueprint_disabled' });
return;
}
await BlueprintReconciler.getInstance().reconcileOne(id);
res.json({ message: 'Reconciliation triggered', blueprintId: id });
} catch (error) {
console.error('[Blueprints] Apply error:', error);
res.status(500).json({ error: 'Failed to apply blueprint' });
}
});
blueprintsRouter.post('/:id/withdraw/:nodeId', async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
if (!requireAdmin(req, res)) return;
const id = parseIntParam(req, res, 'id');
if (id === null) return;
const nodeId = parseIntParam(req, res, 'nodeId');
if (nodeId === null) return;
const confirm = typeof req.body?.confirm === 'string' ? req.body.confirm : 'standard';
if (!['standard', 'snapshot_then_evict', 'evict_and_destroy'].includes(confirm)) {
res.status(400).json({ error: 'confirm must be one of: standard, snapshot_then_evict, evict_and_destroy' });
return;
}
try {
const blueprint = DatabaseService.getInstance().getBlueprint(id);
if (!blueprint) { res.status(404).json({ error: 'Blueprint not found' }); return; }
const node = DatabaseService.getInstance().getNode(nodeId);
if (!node) { res.status(404).json({ error: 'Node not found' }); return; }
const isStateful = blueprint.classification === 'stateful' || blueprint.classification === 'unknown';
if (isStateful && confirm === 'standard') {
res.status(409).json({
error: 'This blueprint is stateful. Pass confirm = "snapshot_then_evict" or "evict_and_destroy" to evict.',
code: 'evict_blocked',
});
return;
}
let snapshotId: number | null = null;
if (confirm === 'snapshot_then_evict') {
const compose = blueprint.compose_content;
if (!compose || compose.trim().length === 0) {
res.status(500).json({
error: 'Blueprint has no compose content to snapshot',
code: 'snapshot_failed',
});
return;
}
try {
const db = DatabaseService.getInstance();
const username = req.user?.username ?? 'admin';
snapshotId = db.createSnapshot(
`Pre-eviction: blueprint=${blueprint.name} node=${node.name}`,
username,
1,
1,
'[]',
);
db.insertSnapshotFiles(snapshotId, [{
nodeId: node.id,
nodeName: node.name,
stackName: blueprint.name,
filename: 'docker-compose.yml',
content: compose,
}]);
} catch (snapErr) {
console.error('[Blueprints] Pre-eviction snapshot failed:', snapErr);
if (snapshotId !== null) {
try { DatabaseService.getInstance().deleteSnapshot(snapshotId); }
catch (cleanupErr) { console.error('[Blueprints] Failed to clean up orphan snapshot row:', cleanupErr); }
}
res.status(500).json({
error: 'Failed to capture compose snapshot before eviction',
code: 'snapshot_failed',
});
return;
}
}
const result = await BlueprintService.getInstance().withdrawFromNode(blueprint, node);
res.json({
status: result.status,
error: result.error ?? null,
snapshotPolicy: confirm,
snapshotId,
});
} catch (error) {
console.error('[Blueprints] Withdraw error:', error);
res.status(500).json({ error: getErrorMessage(error, 'Failed to withdraw blueprint') });
}
});
blueprintsRouter.post('/:id/accept/:nodeId', async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
if (!requireAdmin(req, res)) return;
const id = parseIntParam(req, res, 'id');
if (id === null) return;
const nodeId = parseIntParam(req, res, 'nodeId');
if (nodeId === null) return;
const mode = typeof req.body?.mode === 'string' ? req.body.mode : '';
if (!['fresh', 'restore_from_snapshot'].includes(mode)) {
res.status(400).json({ error: 'mode must be "fresh" or "restore_from_snapshot"' });
return;
}
try {
const blueprint = DatabaseService.getInstance().getBlueprint(id);
if (!blueprint) { res.status(404).json({ error: 'Blueprint not found' }); return; }
const dep = DatabaseService.getInstance().getDeployment(id, nodeId);
if (!dep || dep.status !== 'pending_state_review') {
res.status(409).json({ error: 'Deployment is not awaiting state review' });
return;
}
// 'restore_from_snapshot' is reserved for the future Volume Migration feature.
// v1 always proceeds with a fresh deploy; the mode is recorded for audit purposes only.
await BlueprintReconciler.getInstance().forceDeploy(id, nodeId);
res.json({ status: 'deploying', mode });
} catch (error) {
console.error('[Blueprints] Accept error:', error);
res.status(500).json({ error: getErrorMessage(error, 'Failed to accept deployment') });
}
});
blueprintsRouter.get('/:id/preview', (req: Request, res: Response): void => {
if (!requirePaid(req, res)) return;
const id = parseIntParam(req, res, 'id');
if (id === null) return;
try {
const blueprint = DatabaseService.getInstance().getBlueprint(id);
if (!blueprint) { res.status(404).json({ error: 'Blueprint not found' }); return; }
const allNodes = DatabaseService.getInstance().getNodes();
const desired = NodeLabelService.getInstance().matchSelector(blueprint.selector, allNodes);
const existing = DatabaseService.getInstance().listDeployments(id);
const desiredIds = new Set(desired.map(n => n.id));
const willDeploy = desired.filter(n => !existing.some(d => d.node_id === n.id));
const willCheck = desired.filter(n => existing.some(d => d.node_id === n.id && d.status === 'active'));
const willEvict = existing
.filter(d => !desiredIds.has(d.node_id) && d.status !== 'withdrawn')
.map(d => d.node_id);
res.json({
blueprintId: id,
classification: blueprint.classification,
matchedNodes: desired.map(n => ({ id: n.id, name: n.name, type: n.type })),
plannedDeployments: willDeploy.map(n => ({ id: n.id, name: n.name })),
plannedDriftChecks: willCheck.map(n => ({ id: n.id, name: n.name })),
plannedEvictions: willEvict,
});
} catch (error) {
console.error('[Blueprints] Preview error:', error);
res.status(500).json({ error: 'Failed to preview blueprint' });
}
});
blueprintsRouter.put('/:id/pin', async (req: Request, res: Response): Promise<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 rawNodeId = (req.body as { nodeId?: unknown }).nodeId;
let nodeId: number | null;
if (rawNodeId === null) {
nodeId = null;
} else if (typeof rawNodeId === 'number' && Number.isInteger(rawNodeId) && rawNodeId > 0) {
nodeId = rawNodeId;
} else {
res.status(400).json({ error: 'nodeId must be a positive integer or null' });
return;
}
try {
const blueprint = DatabaseService.getInstance().getBlueprint(id);
if (!blueprint) { res.status(404).json({ error: 'Blueprint not found' }); return; }
if (nodeId !== null) {
const node = DatabaseService.getInstance().getNode(nodeId);
if (!node) { res.status(404).json({ error: 'Node not found' }); return; }
}
const updated = DatabaseService.getInstance().setBlueprintPinnedNode(id, nodeId);
if (!updated) { res.status(404).json({ error: 'Blueprint not found' }); return; }
if (isDebugEnabled()) console.log('[Federation:diag] pinned blueprint=%s node=%s', sanitizeForLog(id), sanitizeForLog(nodeId));
// Trigger immediate reconciliation so the pin takes effect without
// waiting for the next 60s tick. Errors here are logged but do not
// fail the request: the pin is already persisted.
if (updated.enabled) {
BlueprintReconciler.getInstance().reconcileOne(id).catch(err => {
console.warn('[Blueprints] post-pin reconcileOne failed:', err);
});
}
res.json(updated);
} catch (error) {
console.error('[Blueprints] Pin error:', error);
res.status(500).json({ error: 'Failed to update blueprint pin' });
}
});
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;
}
if (Buffer.byteLength(composeContent, 'utf8') > MAX_BLUEPRINT_COMPOSE_BYTES) {
res.status(400).json({ error: `compose_content must be ${MAX_BLUEPRINT_COMPOSE_BYTES} bytes or fewer` });
return;
}
const result = BlueprintAnalyzer.analyze(composeContent);
res.json(result);
});