mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-28 19:27:41 +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();
|
||||
|
||||
Reference in New Issue
Block a user