mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-28 11:17:07 +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:
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user