mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-19 06:46:23 +00:00
155db30554
* fix(blueprints): gate confirmed apply on live intent fingerprint reconcileConfirmedPlan only checked approval_status, so a concurrent compose edit during Apply could deploy unapproved content under a stale fingerprint. Match the tick-path fingerprint gate, refuse Apply when live intent drifts, and surface reconciler refusal as PREVIEW_STALE instead of a false success. * test(blueprints): cover matching-fingerprint reconcileConfirmedPlan path Prove the production allow branch still deploys authorized actions when the approval fingerprint matches, and does not execute unauthorized blast nodes. * fix(blueprints): report live approval after confirmed snapshot apply A concurrent edit can clear approval while multi-node snapshot deploy is still running. Keep the in-flight snapshot contract, but re-read live effectiveApproval before responding and warn in the rollout dialog when approval is no longer current.
663 lines
32 KiB
TypeScript
663 lines
32 KiB
TypeScript
import { Router, type Request, type Response } from 'express';
|
|
import { authMiddleware } from '../middleware/auth';
|
|
import { 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,
|
|
messageForConfirmedOutcomes,
|
|
messageForSnapshotFinishedWithStaleApproval,
|
|
summarizeConfirmedOutcomes,
|
|
} from '../services/BlueprintReconciler';
|
|
import { BlueprintAnalyzer } from '../services/BlueprintAnalyzer';
|
|
import { buildBlueprintPreview, evaluateLightweightEffectiveApproval } from '../services/blueprintPreviewProjection';
|
|
import {
|
|
confirmableActionsEqual,
|
|
deriveBlastFromConfirmableActions,
|
|
evaluateEffectiveApproval,
|
|
intentFingerprint,
|
|
parseConfirmableActionsBody,
|
|
serializeApprovedBlast,
|
|
} from '../services/blueprintApproval';
|
|
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;
|
|
}
|
|
const auth = evaluateLightweightEffectiveApproval(blueprintId);
|
|
return {
|
|
blueprint,
|
|
deployments,
|
|
statusCounts: counts,
|
|
effectiveApproval: auth?.effectiveApproval ?? 'pending',
|
|
unauthorizedActions: auth?.unauthorizedActions ?? [],
|
|
};
|
|
}
|
|
|
|
blueprintsRouter.get('/', (req: Request, res: Response): void => {
|
|
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;
|
|
const auth = evaluateLightweightEffectiveApproval(b.id);
|
|
return {
|
|
...b,
|
|
deploymentCounts: counts,
|
|
deploymentTotal: deployments.length,
|
|
effectiveApproval: auth?.effectiveApproval ?? 'pending',
|
|
unauthorizedActions: auth?.unauthorizedActions ?? [],
|
|
};
|
|
});
|
|
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 (!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 => {
|
|
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 (!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; }
|
|
const nextName = (body.name as string).trim();
|
|
const existing = DatabaseService.getInstance().getBlueprint(id);
|
|
if (existing && existing.name !== nextName
|
|
&& DatabaseService.getInstance().hasNonWithdrawnBlueprintDeployments(id)) {
|
|
res.status(409).json({
|
|
error: 'Rename is blocked while non-withdrawn deployments or guards exist. Withdraw or resolve them first.',
|
|
code: 'RENAME_BLOCKED',
|
|
});
|
|
return;
|
|
}
|
|
updates.name = nextName;
|
|
}
|
|
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 (!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 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> => {
|
|
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 (!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;
|
|
}
|
|
|
|
const body = (req.body ?? {}) as { planFingerprint?: unknown; actions?: unknown };
|
|
if (typeof body.planFingerprint !== 'string' || body.planFingerprint.length === 0) {
|
|
res.status(400).json({ error: 'planFingerprint is required', code: 'CONFIRM_REQUIRED' });
|
|
return;
|
|
}
|
|
const parsedActions = parseConfirmableActionsBody(body.actions);
|
|
if (!parsedActions.ok) {
|
|
res.status(400).json({ error: `Invalid actions: ${parsedActions.reason}`, code: 'CONFIRM_REQUIRED' });
|
|
return;
|
|
}
|
|
|
|
const preview = await buildBlueprintPreview(id);
|
|
if (!preview) {
|
|
res.status(404).json({ error: 'Blueprint not found' });
|
|
return;
|
|
}
|
|
if (preview.summary.blocker > 0) {
|
|
res.status(409).json({ error: 'Plan has blockers', code: 'PLAN_BLOCKED', preview });
|
|
return;
|
|
}
|
|
if (
|
|
body.planFingerprint !== preview.planFingerprint
|
|
|| !confirmableActionsEqual(parsedActions.actions, preview.confirmableActions)
|
|
) {
|
|
res.status(409).json({ error: 'Preview is stale; refresh and confirm again', code: 'PREVIEW_STALE', preview });
|
|
return;
|
|
}
|
|
|
|
const blast = deriveBlastFromConfirmableActions(preview.confirmableActions);
|
|
const approved = DatabaseService.getInstance().setBlueprintApproval(id, {
|
|
intentFingerprint: preview.planFingerprint,
|
|
blastJson: serializeApprovedBlast(blast),
|
|
approvedBy: req.user?.username ?? null,
|
|
});
|
|
// planFingerprint is from an earlier preview. Concurrent compose/selector
|
|
// edits can invalidate it before approval persists, or the reconciler can
|
|
// refuse if the live gate no longer matches. Both paths clear and 409.
|
|
const approvalMatches = !!approved && intentFingerprint(approved) === preview.planFingerprint;
|
|
const plan = approvalMatches
|
|
? await BlueprintReconciler.getInstance().reconcileConfirmedPlan(id, preview.executorActions)
|
|
: null;
|
|
if (!plan || plan.refused) {
|
|
DatabaseService.getInstance().clearBlueprintApproval(id);
|
|
const fresh = await buildBlueprintPreview(id);
|
|
res.status(409).json({
|
|
error: 'Preview is stale; refresh and confirm again',
|
|
code: 'PREVIEW_STALE',
|
|
preview: fresh,
|
|
});
|
|
return;
|
|
}
|
|
// Snapshot deploy may finish after a concurrent edit cleared approval.
|
|
// Report live effectiveApproval; do not hardcode "approved".
|
|
const live = DatabaseService.getInstance().getBlueprint(id);
|
|
const { effectiveApproval } = live
|
|
? evaluateEffectiveApproval(live, preview.executorActions)
|
|
: { effectiveApproval: 'pending' as const };
|
|
const outcomeSummary = summarizeConfirmedOutcomes(plan.outcomes);
|
|
const message = effectiveApproval === 'approved'
|
|
? messageForConfirmedOutcomes(outcomeSummary)
|
|
: messageForSnapshotFinishedWithStaleApproval(outcomeSummary);
|
|
res.json({
|
|
message,
|
|
blueprintId: id,
|
|
effectiveApproval,
|
|
outcomes: plan.outcomes,
|
|
outcomeSummary,
|
|
});
|
|
} 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 (!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;
|
|
}
|
|
// Destructive eviction (and reconciler-queued evict_blocked rows) require a
|
|
// current approved remove outcome. Plain stateless "standard" withdraw
|
|
// remains an immediate operator stop (no remove blast required).
|
|
const existingDep = DatabaseService.getInstance().getDeployment(id, nodeId);
|
|
const destructiveConfirm = confirm === 'snapshot_then_evict' || confirm === 'evict_and_destroy';
|
|
if (destructiveConfirm || existingDep?.status === 'evict_blocked') {
|
|
const guard = BlueprintReconciler.getInstance().validateWithdrawConfirmation(id, nodeId);
|
|
if (!guard.ok) {
|
|
res.status(409).json({ error: guard.error, code: guard.code });
|
|
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 (!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 guard = BlueprintReconciler.getInstance().validateGuardConfirmation(id, nodeId, 'accept');
|
|
if (!guard.ok) {
|
|
res.status(409).json({ error: guard.error, code: guard.code });
|
|
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', async (req: Request, res: Response): Promise<void> => {
|
|
const id = parseIntParam(req, res, 'id');
|
|
if (id === null) return;
|
|
try {
|
|
const preview = await buildBlueprintPreview(id);
|
|
if (!preview) { res.status(404).json({ error: 'Blueprint not found' }); return; }
|
|
res.json(preview);
|
|
} 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 (!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));
|
|
// Pin clears approval, so reconcileOne cannot mutate until Confirm Apply.
|
|
// Still call it so the fail-closed pending state is evaluated immediately
|
|
// instead of waiting for the next tick.
|
|
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 (!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);
|
|
});
|