mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-03 22:25:30 +00:00
feat(fleet): add Federation tab with cordon and pin policy (Admiral) (#964)
Ships the v1 MVP for the Federation tab as placement control, not
placement automation:
- Cordon a node: marks the node unschedulable so the BlueprintReconciler
skips it for new placements only. Existing deployments continue to
drift-check and redeploy on revision changes; cordon never triggers
withdraw or eviction. Toggle on the NodeCard kebab (Admiral, admin
role); Cordoned pill renders for all tiers.
- Pin a blueprint to a node: stores blueprints.pinned_node_id, replacing
the desired set with the pinned node regardless of selector. Pin
overrides cordon by design. Action lives only in the Federation tab;
BlueprintDetail and the deployment table show read-only Pinned
indicators.
Backend: idempotent migrations add nodes.cordoned/cordoned_at/cordoned_reason
and blueprints.pinned_node_id. New routes POST /api/nodes/:id/cordon,
POST /api/nodes/:id/uncordon, PUT /api/blueprints/:id/pin, all gated by
requireAdmiral plus requireAdmin. Audit summaries added so the existing
auditLog middleware records every operator action. deleteNode clears
dangling pins.
Reconciler: pin override evaluated before selector match; cordon filter
applied only to the new-placement branch (deploy/stateReview without an
existing deployment). 11 new Vitest cases cover cordon filter, pin
override, pin-overrides-cordon, missing pin target, pin shrinks
desired set (stateless withdraw + stateful evict_blocked), and pin
clearing on node delete.
Frontend: new FederationTab.tsx with cordoned-nodes summary and
pin-policy table. Federation moved out of the experimental flag into
{isAdmiral && (...)} + AdmiralGate, mirroring the Routing tab pattern.
Secrets stays under experimental.
Tests pass: backend tsc, full Vitest suite (1704 passed), frontend
tsc -b, ESLint (0 errors). Manual verification via the local dev
instance confirmed the tab is hidden at Community, the kebab and pill
render at Admiral, and cordon and pin endpoints round-trip end to end.
Refs cut-line-1.0.md Federation v1 MVP.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { authMiddleware } from '../middleware/auth';
|
||||
import { requirePaid, requireAdmin, requireBody } from '../middleware/tierGates';
|
||||
import { requirePaid, requireAdmiral, requireAdmin, requireBody } from '../middleware/tierGates';
|
||||
import {
|
||||
DatabaseService,
|
||||
type BlueprintSelector,
|
||||
@@ -441,6 +441,46 @@ blueprintsRouter.get('/:id/preview', (req: Request, res: Response): void => {
|
||||
}
|
||||
});
|
||||
|
||||
blueprintsRouter.put('/:id/pin', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmiral(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; }
|
||||
// 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;
|
||||
|
||||
@@ -94,6 +94,9 @@ interface FleetNodeOverview {
|
||||
latency_ms?: number;
|
||||
last_successful_contact?: number | null;
|
||||
pilot_last_seen?: number | null;
|
||||
cordoned: boolean;
|
||||
cordoned_at: number | null;
|
||||
cordoned_reason: string | null;
|
||||
}
|
||||
|
||||
/** Resolve the version to compare nodes against (latest from GitHub, or gateway fallback). */
|
||||
@@ -162,6 +165,9 @@ async function fetchLocalNodeOverview(node: Node): Promise<FleetNodeOverview> {
|
||||
},
|
||||
stacks,
|
||||
last_successful_contact: node.last_successful_contact ?? null,
|
||||
cordoned: node.cordoned,
|
||||
cordoned_at: node.cordoned_at,
|
||||
cordoned_reason: node.cordoned_reason,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[Fleet] Local node ${node.name} error:`, error);
|
||||
@@ -169,6 +175,9 @@ async function fetchLocalNodeOverview(node: Node): Promise<FleetNodeOverview> {
|
||||
id: node.id, name: node.name, type: node.type, status: 'offline',
|
||||
stats: null, systemStats: null, stacks: null,
|
||||
last_successful_contact: node.last_successful_contact ?? null,
|
||||
cordoned: node.cordoned,
|
||||
cordoned_at: node.cordoned_at,
|
||||
cordoned_reason: node.cordoned_reason,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -187,6 +196,9 @@ async function fetchRemoteNodeOverview(node: Node, db: DatabaseService): Promise
|
||||
stacks: null,
|
||||
last_successful_contact: node.pilot_last_seen ? Math.floor(node.pilot_last_seen / 1000) : null,
|
||||
pilot_last_seen: node.pilot_last_seen ? Math.floor(node.pilot_last_seen / 1000) : null,
|
||||
cordoned: node.cordoned,
|
||||
cordoned_at: node.cordoned_at,
|
||||
cordoned_reason: node.cordoned_reason,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -195,6 +207,9 @@ async function fetchRemoteNodeOverview(node: Node, db: DatabaseService): Promise
|
||||
id: node.id, name: node.name, type: node.type, status: 'offline',
|
||||
stats: null, systemStats: null, stacks: null,
|
||||
last_successful_contact: node.last_successful_contact ?? null,
|
||||
cordoned: node.cordoned,
|
||||
cordoned_at: node.cordoned_at,
|
||||
cordoned_reason: node.cordoned_reason,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -254,6 +269,9 @@ async function fetchRemoteNodeOverview(node: Node, db: DatabaseService): Promise
|
||||
last_successful_contact: isOnline
|
||||
? Math.floor(completedAt / 1000)
|
||||
: node.last_successful_contact ?? null,
|
||||
cordoned: node.cordoned,
|
||||
cordoned_at: node.cordoned_at,
|
||||
cordoned_reason: node.cordoned_reason,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[Fleet] Remote node ${node.name} error:`, error);
|
||||
@@ -261,6 +279,9 @@ async function fetchRemoteNodeOverview(node: Node, db: DatabaseService): Promise
|
||||
id: node.id, name: node.name, type: node.type, mode: node.mode, status: 'offline',
|
||||
stats: null, systemStats: null, stacks: null,
|
||||
last_successful_contact: node.last_successful_contact ?? null,
|
||||
cordoned: node.cordoned,
|
||||
cordoned_at: node.cordoned_at,
|
||||
cordoned_reason: node.cordoned_reason,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -345,6 +366,9 @@ fleetRouter.get('/overview', authMiddleware, async (_req: Request, res: Response
|
||||
stats: null,
|
||||
systemStats: null,
|
||||
stacks: null,
|
||||
cordoned: nodes[i].cordoned,
|
||||
cordoned_at: nodes[i].cordoned_at,
|
||||
cordoned_reason: nodes[i].cordoned_reason,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import crypto from 'crypto';
|
||||
import { authMiddleware } from '../middleware/auth';
|
||||
import { requirePermission } from '../middleware/permissions';
|
||||
import { rejectApiTokenScope } from '../middleware/apiTokenScope';
|
||||
import { requireAdmiral } from '../middleware/tierGates';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { NodeRegistry } from '../services/NodeRegistry';
|
||||
import { CacheService } from '../services/CacheService';
|
||||
@@ -255,6 +256,68 @@ nodesRouter.delete('/:id', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
nodesRouter.post('/:id/cordon', (req: Request, res: Response) => {
|
||||
if (rejectApiTokenScope(req, res, NODE_SCOPE_MESSAGE)) return;
|
||||
const nodeIdParam = req.params.id as string;
|
||||
if (!requirePermission(req, res, 'node:manage', 'node', nodeIdParam)) return;
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
const id = parseInt(nodeIdParam, 10);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
res.status(400).json({ error: 'Invalid node id' });
|
||||
return;
|
||||
}
|
||||
const rawReason = (req.body && typeof req.body === 'object') ? (req.body as { reason?: unknown }).reason : undefined;
|
||||
let reason: string | null = null;
|
||||
if (rawReason !== undefined && rawReason !== null) {
|
||||
if (typeof rawReason !== 'string') {
|
||||
res.status(400).json({ error: 'reason must be a string' });
|
||||
return;
|
||||
}
|
||||
const trimmed = rawReason.trim();
|
||||
if (trimmed.length > 256) {
|
||||
res.status(400).json({ error: 'reason must be 256 characters or fewer' });
|
||||
return;
|
||||
}
|
||||
reason = trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
try {
|
||||
const existing = DatabaseService.getInstance().getNode(id);
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: 'Node not found' });
|
||||
return;
|
||||
}
|
||||
const updated = DatabaseService.getInstance().setNodeCordoned(id, true, reason);
|
||||
res.set('cache-control', 'no-store').json(updated);
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to cordon node:', error);
|
||||
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to cordon node' });
|
||||
}
|
||||
});
|
||||
|
||||
nodesRouter.post('/:id/uncordon', (req: Request, res: Response) => {
|
||||
if (rejectApiTokenScope(req, res, NODE_SCOPE_MESSAGE)) return;
|
||||
const nodeIdParam = req.params.id as string;
|
||||
if (!requirePermission(req, res, 'node:manage', 'node', nodeIdParam)) return;
|
||||
if (!requireAdmiral(req, res)) return;
|
||||
const id = parseInt(nodeIdParam, 10);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
res.status(400).json({ error: 'Invalid node id' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const existing = DatabaseService.getInstance().getNode(id);
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: 'Node not found' });
|
||||
return;
|
||||
}
|
||||
const updated = DatabaseService.getInstance().setNodeCordoned(id, false, null);
|
||||
res.set('cache-control', 'no-store').json(updated);
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to uncordon node:', error);
|
||||
res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to uncordon node' });
|
||||
}
|
||||
});
|
||||
|
||||
nodesRouter.post('/:id/test', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id as string);
|
||||
|
||||
Reference in New Issue
Block a user