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:
Anso
2026-05-07 05:55:00 -04:00
committed by GitHub
parent 77d5ff58d3
commit 52b46753af
18 changed files with 897 additions and 35 deletions
+41 -1
View File
@@ -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;
+24
View File
@@ -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,
};
});
+63
View File
@@ -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);