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
+152
View File
@@ -176,6 +176,158 @@ describe('BlueprintReconciler.computeDecision', () => {
expect(decision.withdraw).toEqual([]);
});
it('skips new placements onto cordoned nodes (cordon filter)', () => {
const nodeId = seedNode();
DatabaseService.getInstance().setNodeCordoned(nodeId, true, 'maintenance');
const bp = seedBlueprint({ classification: 'stateless', nodeIds: [nodeId] });
const reconciler = BlueprintReconciler.getInstance() as unknown as ReconcilerWithCompute;
const allNodes = DatabaseService.getInstance().getNodes();
const decision = reconciler.computeDecision(bp, allNodes);
expect(decision.deploy).toEqual([]);
expect(decision.stateReview).toEqual([]);
});
it('skips state-review for stateful blueprints landing on cordoned nodes', () => {
const nodeId = seedNode();
DatabaseService.getInstance().setNodeCordoned(nodeId, true, null);
const bp = seedBlueprint({ classification: 'stateful', nodeIds: [nodeId] });
const reconciler = BlueprintReconciler.getInstance() as unknown as ReconcilerWithCompute;
const allNodes = DatabaseService.getInstance().getNodes();
const decision = reconciler.computeDecision(bp, allNodes);
expect(decision.stateReview).toEqual([]);
expect(decision.deploy).toEqual([]);
});
it('still redeploys for revision drift on a cordoned node (existing deployment, not a new placement)', () => {
const nodeId = seedNode();
DatabaseService.getInstance().setNodeCordoned(nodeId, true, null);
const bp = seedBlueprint({ classification: 'stateless', nodeIds: [nodeId] });
DatabaseService.getInstance().upsertDeployment({
blueprint_id: bp.id,
node_id: nodeId,
status: 'active',
applied_revision: bp.revision - 1,
});
const reconciler = BlueprintReconciler.getInstance() as unknown as ReconcilerWithCompute;
const allNodes = DatabaseService.getInstance().getNodes();
const decision = reconciler.computeDecision(bp, allNodes);
expect(decision.deploy.map((n: { id: number }) => n.id)).toContain(nodeId);
});
it('still drift-checks active deployments on a cordoned node', () => {
const nodeId = seedNode();
DatabaseService.getInstance().setNodeCordoned(nodeId, true, null);
const bp = seedBlueprint({ classification: 'stateless', nodeIds: [nodeId] });
DatabaseService.getInstance().upsertDeployment({
blueprint_id: bp.id,
node_id: nodeId,
status: 'active',
applied_revision: bp.revision,
});
const reconciler = BlueprintReconciler.getInstance() as unknown as ReconcilerWithCompute;
const allNodes = DatabaseService.getInstance().getNodes();
const decision = reconciler.computeDecision(bp, allNodes);
expect(decision.check.map((n: { id: number }) => n.id)).toContain(nodeId);
});
it('honors pin override: desired set is exactly the pinned node, regardless of selector', () => {
const nodeA = seedNode();
const nodeB = seedNode();
const bp = seedBlueprint({ classification: 'stateless', nodeIds: [nodeA] });
DatabaseService.getInstance().setBlueprintPinnedNode(bp.id, nodeB);
const refreshed = DatabaseService.getInstance().getBlueprint(bp.id)!;
const reconciler = BlueprintReconciler.getInstance() as unknown as ReconcilerWithCompute;
const allNodes = DatabaseService.getInstance().getNodes();
const decision = reconciler.computeDecision(refreshed, allNodes);
expect(decision.deploy.map((n: { id: number }) => n.id)).toContain(nodeB);
expect(decision.deploy.map((n: { id: number }) => n.id)).not.toContain(nodeA);
});
it('pin overrides cordon: pinned blueprint deploys onto a cordoned node', () => {
const nodeId = seedNode();
DatabaseService.getInstance().setNodeCordoned(nodeId, true, null);
const bp = seedBlueprint({ classification: 'stateless', nodeIds: [] });
DatabaseService.getInstance().setBlueprintPinnedNode(bp.id, nodeId);
const refreshed = DatabaseService.getInstance().getBlueprint(bp.id)!;
const reconciler = BlueprintReconciler.getInstance() as unknown as ReconcilerWithCompute;
const allNodes = DatabaseService.getInstance().getNodes();
const decision = reconciler.computeDecision(refreshed, allNodes);
expect(decision.deploy.map((n: { id: number }) => n.id)).toContain(nodeId);
});
it('pin to a non-existent node yields an empty desired set', () => {
const bp = seedBlueprint({ classification: 'stateless', nodeIds: [] });
DatabaseService.getInstance().setBlueprintPinnedNode(bp.id, 999_999);
const refreshed = DatabaseService.getInstance().getBlueprint(bp.id)!;
const reconciler = BlueprintReconciler.getInstance() as unknown as ReconcilerWithCompute;
const allNodes = DatabaseService.getInstance().getNodes();
const decision = reconciler.computeDecision(refreshed, allNodes);
expect(decision.deploy).toEqual([]);
expect(decision.stateReview).toEqual([]);
});
it('pin shrinks the desired set: stateless deployments on non-pinned nodes are queued for withdraw', () => {
const nodeA = seedNode();
const nodeB = seedNode();
const bp = seedBlueprint({ classification: 'stateless', nodeIds: [nodeA, nodeB] });
DatabaseService.getInstance().upsertDeployment({
blueprint_id: bp.id, node_id: nodeA, status: 'active', applied_revision: bp.revision,
});
DatabaseService.getInstance().upsertDeployment({
blueprint_id: bp.id, node_id: nodeB, status: 'active', applied_revision: bp.revision,
});
DatabaseService.getInstance().setBlueprintPinnedNode(bp.id, nodeA);
const refreshed = DatabaseService.getInstance().getBlueprint(bp.id)!;
const reconciler = BlueprintReconciler.getInstance() as unknown as ReconcilerWithCompute;
const allNodes = DatabaseService.getInstance().getNodes();
const decision = reconciler.computeDecision(refreshed, allNodes);
expect(decision.check.map((n: { id: number }) => n.id)).toContain(nodeA);
expect(decision.withdraw.map((n: { id: number }) => n.id)).toContain(nodeB);
expect(decision.evictBlocked).toEqual([]);
});
it('pin shrinks the desired set: stateful deployments on non-pinned nodes are queued for evict_blocked', () => {
const nodeA = seedNode();
const nodeB = seedNode();
const bp = seedBlueprint({ classification: 'stateful', nodeIds: [nodeA, nodeB] });
DatabaseService.getInstance().upsertDeployment({
blueprint_id: bp.id, node_id: nodeA, status: 'active', applied_revision: bp.revision,
});
DatabaseService.getInstance().upsertDeployment({
blueprint_id: bp.id, node_id: nodeB, status: 'active', applied_revision: bp.revision,
});
DatabaseService.getInstance().setBlueprintPinnedNode(bp.id, nodeA);
const refreshed = DatabaseService.getInstance().getBlueprint(bp.id)!;
const reconciler = BlueprintReconciler.getInstance() as unknown as ReconcilerWithCompute;
const allNodes = DatabaseService.getInstance().getNodes();
const decision = reconciler.computeDecision(refreshed, allNodes);
expect(decision.evictBlocked.map((n: { id: number }) => n.id)).toContain(nodeB);
expect(decision.withdraw).toEqual([]);
});
it('deleting the pinned node clears the pin from the blueprint', () => {
const nodeId = seedNode();
const bp = seedBlueprint({ classification: 'stateless', nodeIds: [] });
DatabaseService.getInstance().setBlueprintPinnedNode(bp.id, nodeId);
expect(DatabaseService.getInstance().getBlueprint(bp.id)!.pinned_node_id).toBe(nodeId);
DatabaseService.getInstance().deleteNode(nodeId);
expect(DatabaseService.getInstance().getBlueprint(bp.id)!.pinned_node_id).toBeNull();
});
it('clearing the pin restores selector behavior on the next tick', () => {
const nodeA = seedNode();
const nodeB = seedNode();
const bp = seedBlueprint({ classification: 'stateless', nodeIds: [nodeA] });
DatabaseService.getInstance().setBlueprintPinnedNode(bp.id, nodeB);
DatabaseService.getInstance().setBlueprintPinnedNode(bp.id, null);
const refreshed = DatabaseService.getInstance().getBlueprint(bp.id)!;
const reconciler = BlueprintReconciler.getInstance() as unknown as ReconcilerWithCompute;
const allNodes = DatabaseService.getInstance().getNodes();
const decision = reconciler.computeDecision(refreshed, allNodes);
expect(decision.deploy.map((n: { id: number }) => n.id)).toContain(nodeA);
expect(decision.deploy.map((n: { id: number }) => n.id)).not.toContain(nodeB);
});
it('matches via labels selector and respects label changes', () => {
const nodeA = seedNode();
const nodeB = seedNode();
+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);
+25 -2
View File
@@ -154,8 +154,25 @@ export class BlueprintReconciler {
}
private computeDecision(blueprint: Blueprint, allNodes: Node[]): ReconcileDecision {
const labelSvc = NodeLabelService.getInstance();
const desiredNodes = labelSvc.matchSelector(blueprint.selector, allNodes);
// Pin override: a pinned blueprint deploys only on its pinned node,
// regardless of the selector. The pinned node also wins over a
// cordon flag (pin is an explicit operator decision; cordon governs
// automatic placement only).
let desiredNodes: Node[];
if (blueprint.pinned_node_id !== null) {
const pinned = allNodes.find(n => n.id === blueprint.pinned_node_id);
if (!pinned) {
console.warn(
`[BlueprintReconciler] blueprint "${blueprint.name}" pinned to node ${blueprint.pinned_node_id} which no longer exists; treating desired set as empty`,
);
desiredNodes = [];
} else {
desiredNodes = [pinned];
}
} else {
const labelSvc = NodeLabelService.getInstance();
desiredNodes = labelSvc.matchSelector(blueprint.selector, allNodes);
}
const desiredIds = new Set(desiredNodes.map(n => n.id));
const existingDeployments = DatabaseService.getInstance().listDeployments(blueprint.id);
@@ -174,6 +191,12 @@ export class BlueprintReconciler {
for (const node of desiredNodes) {
const dep = deploymentByNode.get(node.id);
if (!dep) {
// Cordon filter: skip new placements onto cordoned nodes.
// Pin always wins, so the pinned node is exempt. Existing
// deployments below are untouched: cordon does not evict.
if (node.cordoned && blueprint.pinned_node_id !== node.id) {
continue;
}
if (blueprint.classification === 'stateful' || blueprint.classification === 'unknown') {
decision.stateReview.push(node);
} else {
+42 -2
View File
@@ -72,6 +72,9 @@ export interface Node {
pilot_last_seen?: number | null;
pilot_agent_version?: string | null;
last_successful_contact?: number | null;
cordoned: boolean;
cordoned_at: number | null;
cordoned_reason: string | null;
}
export interface StackRestartSummary {
@@ -275,6 +278,7 @@ export interface Blueprint {
created_at: number;
updated_at: number;
created_by: string | null;
pinned_node_id: number | null;
}
export interface BlueprintDeployment {
@@ -587,6 +591,8 @@ export class DatabaseService {
this.migrateNodeLabels();
this.migrateBlueprints();
this.migrateAddNodeLastContact();
this.migrateAddNodeCordonFields();
this.migrateAddBlueprintPinnedNode();
// Reset the cache once at end of constructor in case any migration
// populated it via getGlobalSettings() and a subsequent migration
@@ -1411,6 +1417,16 @@ export class DatabaseService {
this.tryAddColumn('nodes', 'last_successful_contact', 'INTEGER');
}
private migrateAddNodeCordonFields(): void {
this.tryAddColumn('nodes', 'cordoned', 'INTEGER NOT NULL DEFAULT 0');
this.tryAddColumn('nodes', 'cordoned_at', 'INTEGER');
this.tryAddColumn('nodes', 'cordoned_reason', 'TEXT');
}
private migrateAddBlueprintPinnedNode(): void {
this.tryAddColumn('blueprints', 'pinned_node_id', 'INTEGER');
}
// --- Sencho Mesh ---
public listMeshStacks(nodeId?: number): Array<{ id: number; node_id: number; stack_name: string; created_at: number; created_by: string | null }> {
@@ -1888,11 +1904,14 @@ export class DatabaseService {
pilot_last_seen: row.pilot_last_seen ?? null,
pilot_agent_version: row.pilot_agent_version ?? null,
last_successful_contact: row.last_successful_contact ?? null,
cordoned: row.cordoned === 1,
cordoned_at: row.cordoned_at ?? null,
cordoned_reason: row.cordoned_reason ?? null,
};
}
private static readonly NODE_COLUMNS =
'id, name, type, compose_dir, is_default, status, created_at, api_url, api_token, mode, pilot_last_seen, pilot_agent_version, last_successful_contact';
'id, name, type, compose_dir, is_default, status, created_at, api_url, api_token, mode, pilot_last_seen, pilot_agent_version, last_successful_contact, cordoned, cordoned_at, cordoned_reason';
public getNodes(): Node[] {
const stmt = this.db.prepare(`SELECT ${DatabaseService.NODE_COLUMNS} FROM nodes ORDER BY is_default DESC, name ASC`);
@@ -1913,7 +1932,7 @@ export class DatabaseService {
return this.decryptNodeRow(row);
}
public addNode(node: Omit<Node, 'id' | 'status' | 'created_at' | 'mode'> & { mode?: NodeMode }): number {
public addNode(node: Omit<Node, 'id' | 'status' | 'created_at' | 'mode' | 'cordoned' | 'cordoned_at' | 'cordoned_reason'> & { mode?: NodeMode }): number {
if (node.is_default) {
this.db.prepare('UPDATE nodes SET is_default = 0').run();
}
@@ -1977,6 +1996,7 @@ export class DatabaseService {
this.db.prepare('DELETE FROM stack_update_status WHERE node_id = ?').run(id);
this.db.prepare('DELETE FROM stack_label_assignments WHERE node_id = ?').run(id);
this.db.prepare('DELETE FROM stack_labels WHERE node_id = ?').run(id);
this.db.prepare('UPDATE blueprints SET pinned_node_id = NULL WHERE pinned_node_id = ?').run(id);
this.deleteRoleAssignmentsByResource('node', String(id));
this.db.prepare('DELETE FROM nodes WHERE id = ?').run(id);
})();
@@ -1986,6 +2006,25 @@ export class DatabaseService {
this.db.prepare('UPDATE nodes SET status = ? WHERE id = ?').run(status, id);
}
public setNodeCordoned(id: number, cordoned: boolean, reason: string | null): Node | undefined {
if (cordoned) {
this.db.prepare(
'UPDATE nodes SET cordoned = 1, cordoned_at = ?, cordoned_reason = ? WHERE id = ?'
).run(Date.now(), reason, id);
} else {
this.db.prepare(
'UPDATE nodes SET cordoned = 0, cordoned_at = NULL, cordoned_reason = NULL WHERE id = ?'
).run(id);
}
return this.getNode(id);
}
public setBlueprintPinnedNode(blueprintId: number, nodeId: number | null): Blueprint | undefined {
this.db.prepare('UPDATE blueprints SET pinned_node_id = ?, updated_at = ? WHERE id = ?')
.run(nodeId, Date.now(), blueprintId);
return this.getBlueprint(blueprintId);
}
public updateNodeLastContact(nodeId: number): void {
this.db.prepare('UPDATE nodes SET last_successful_contact = ? WHERE id = ?')
.run(Math.floor(Date.now() / 1000), nodeId);
@@ -3862,6 +3901,7 @@ export class DatabaseService {
created_at: row.created_at as number,
updated_at: row.updated_at as number,
created_by: (row.created_by as string | null) ?? null,
pinned_node_id: (row.pinned_node_id as number | null) ?? null,
};
}
+5
View File
@@ -46,6 +46,8 @@ export const AUDIT_ROUTE_SUMMARIES: Record<string, string> = {
'POST /nodes': 'Added node',
'PUT /nodes': 'Updated node',
'DELETE /nodes': 'Deleted node',
'POST /nodes/*/cordon': 'Cordoned node',
'POST /nodes/*/uncordon': 'Uncordoned node',
// User management
'POST /users': 'Created user',
@@ -126,6 +128,9 @@ export const AUDIT_ROUTE_SUMMARIES: Record<string, string> = {
// Auto-update
'POST /auto-update/execute': 'Executed auto-update',
// Blueprints (Federation pin)
'PUT /blueprints/*/pin': 'Updated blueprint pin',
};
// Pre-sorted at module load: most specific patterns (by segment count) first.