mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-24 17:36:42 +00:00
fix: bind fleet stop-by-label to the exact confirmed nodes and stacks (#1506)
A fleet stop re-matched stacks by label name at execution, so a stack that gained the label between the operator's preview and confirmation could be stopped even though it never appeared in the confirmation. A confirmed node that was deleted after the preview also vanished from the results, letting the remaining successes read as a clean stop. The confirm flow now sends the exact node and stack list resolved in the preview. Each node's stop is bound to that set: only stacks that are still label-matched and confirmed are stopped, and a confirmed node missing from the registry is reported as an explicit failure rather than dropped.
This commit is contained in:
@@ -757,11 +757,12 @@ describe('POST /api/fleet/labels/fleet-stop remote leg', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/fleet/labels/fleet-stop nodeIds allowlist', () => {
|
||||
// Guards the wrong-node drift fix: the real stop carries the node ids the
|
||||
// operator confirmed in the preview, so a node that was unreachable then and
|
||||
// reconnects before execution cannot silently enter the fan-out.
|
||||
it('contacts a confirmed remote in the allowlist and excludes the unconfirmed local node', async () => {
|
||||
describe('POST /api/fleet/labels/fleet-stop confirmed-target allowlist', () => {
|
||||
// Guards the drift fix: the real stop carries the exact node + stack list the
|
||||
// operator confirmed in the preview. A node that was unreachable then and
|
||||
// reconnects cannot enter the fan-out, and a stack labelled after the preview
|
||||
// is not stopped (the per-node receiver intersects against the sent stacks).
|
||||
it('contacts a confirmed remote and forwards its confirmed stacks, excluding the unconfirmed local node', async () => {
|
||||
const remoteId = db.addNode({
|
||||
name: 'confirmed-remote', type: 'remote', api_url: 'http://confirmed.example:1852',
|
||||
api_token: 'tok', compose_dir: '/app/compose', is_default: false,
|
||||
@@ -773,14 +774,16 @@ describe('POST /api/fleet/labels/fleet-stop nodeIds allowlist', () => {
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/labels/fleet-stop')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ labelName: 'any-label', nodeIds: [remoteId] });
|
||||
.send({ labelName: 'any-label', targets: [{ nodeId: remoteId, stackNames: ['alpha'] }] });
|
||||
expect(res.status).toBe(200);
|
||||
// Only the confirmed remote is in the fan-out: its local-stop receiver is
|
||||
// contacted and the unconfirmed local node is absent from the results.
|
||||
expect(res.body.results).toHaveLength(1);
|
||||
expect(res.body.results[0].nodeId).toBe(remoteId);
|
||||
const urls = fetchSpy.mock.calls.map(c => String(c[0]));
|
||||
expect(urls.some(u => u.endsWith('/api/fleet-actions/labels/local-stop'))).toBe(true);
|
||||
const stopCall = fetchSpy.mock.calls.find(c => String(c[0]).endsWith('/api/fleet-actions/labels/local-stop'));
|
||||
expect(stopCall).toBeTruthy();
|
||||
// The confirmed stacks are forwarded so the remote binds its stop to them.
|
||||
expect(JSON.parse((stopCall![1] as RequestInit).body as string)).toMatchObject({ labelName: 'any-label', stackNames: ['alpha'] });
|
||||
} finally {
|
||||
db.deleteNode(remoteId);
|
||||
}
|
||||
@@ -798,7 +801,7 @@ describe('POST /api/fleet/labels/fleet-stop nodeIds allowlist', () => {
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/labels/fleet-stop')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ labelName: label.name, nodeIds: [localId] });
|
||||
.send({ labelName: label.name, targets: [{ nodeId: localId, stackNames: ['alpha'] }] });
|
||||
expect(res.status).toBe(200);
|
||||
// The excluded remote has a proxy target and would otherwise be contacted;
|
||||
// the allowlist keeps it out of execution entirely.
|
||||
@@ -811,36 +814,36 @@ describe('POST /api/fleet/labels/fleet-stop nodeIds allowlist', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('treats an empty allowlist as zero target nodes, stopping nothing', async () => {
|
||||
it('treats empty targets as zero target nodes, stopping nothing', async () => {
|
||||
const label = await createAssignedLabel('empty-allow', ['alpha']);
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/labels/fleet-stop')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ labelName: label.name, nodeIds: [] });
|
||||
.send({ labelName: label.name, targets: [] });
|
||||
expect(res.status).toBe(200);
|
||||
// An empty allowlist filters to no nodes: a fail-safe no-op rather than a
|
||||
// Empty targets filter to no nodes: a fail-safe no-op rather than a
|
||||
// full-fleet stop. The local node is not acted on and no remote is contacted.
|
||||
expect(res.body.results).toEqual([]);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a non-array nodeIds with 400', async () => {
|
||||
it('rejects a non-array targets with 400', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/labels/fleet-stop')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ labelName: 'whatever', nodeIds: 'oops' });
|
||||
.send({ labelName: 'whatever', targets: 'oops' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/nodeIds/);
|
||||
expect(res.body.error).toMatch(/targets/);
|
||||
});
|
||||
|
||||
it('rejects a non-integer nodeIds entry with 400', async () => {
|
||||
it('rejects a non-integer target.nodeId with 400', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/labels/fleet-stop')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ labelName: 'whatever', nodeIds: [1, 2.5] });
|
||||
.send({ labelName: 'whatever', targets: [{ nodeId: 2.5, stackNames: ['x'] }] });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/nodeIds/);
|
||||
expect(res.body.error).toMatch(/nodeId/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -111,6 +111,24 @@ describe('Fleet Actions input validation', () => {
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('POST /api/fleet/labels/fleet-stop rejects a non-object target entry', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/labels/fleet-stop')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ labelName: 'prod', targets: ['oops'] });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/each target must be an object/);
|
||||
});
|
||||
|
||||
it('POST /api/fleet/labels/fleet-stop rejects a target with non-array stackNames', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/labels/fleet-stop')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ labelName: 'prod', targets: [{ nodeId: 1, stackNames: 'oops' }] });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/target.stackNames must be an array/);
|
||||
});
|
||||
|
||||
it('POST /api/fleet/labels/bulk-assign rejects an invalid label color', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/labels/bulk-assign')
|
||||
@@ -326,6 +344,135 @@ describe('local-stop behavior', () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ matched: true, results: [] });
|
||||
});
|
||||
|
||||
it('rejects a non-array stackNames allowlist', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/fleet-actions/labels/local-stop')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ labelName: 'whatever', stackNames: 'oops' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/stackNames must be an array/);
|
||||
});
|
||||
|
||||
it('stops only the stacks in the stackNames allowlist, not every labelled stack', async () => {
|
||||
makeStack('allow-a');
|
||||
makeStack('allow-b');
|
||||
const label = db.createLabel(nodeId, 'allow-label', 'slate');
|
||||
db.setStackLabels('allow-a', nodeId, [label.id]);
|
||||
db.setStackLabels('allow-b', nodeId, [label.id]);
|
||||
const res = await request(app)
|
||||
.post('/api/fleet-actions/labels/local-stop')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ labelName: 'allow-label', dryRun: true, stackNames: ['allow-a'] });
|
||||
expect(res.status).toBe(200);
|
||||
// allow-b carries the label too but was not confirmed, so it is excluded.
|
||||
expect(res.body.results).toEqual([{ stackName: 'allow-a', success: true, dryRun: true }]);
|
||||
});
|
||||
|
||||
it('reports a confirmed stack that no longer carries the label as a failure', async () => {
|
||||
makeStack('drop-kept');
|
||||
makeStack('drop-gone');
|
||||
const label = db.createLabel(nodeId, 'drop-label', 'teal');
|
||||
db.setStackLabels('drop-kept', nodeId, [label.id]);
|
||||
// drop-gone was confirmed but does not carry the label at execution time.
|
||||
const res = await request(app)
|
||||
.post('/api/fleet-actions/labels/local-stop')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ labelName: 'drop-label', dryRun: true, stackNames: ['drop-kept', 'drop-gone'] });
|
||||
expect(res.status).toBe(200);
|
||||
const byName = Object.fromEntries(res.body.results.map((r: { stackName: string }) => [r.stackName, r]));
|
||||
expect(byName['drop-kept']).toEqual({ stackName: 'drop-kept', success: true, dryRun: true });
|
||||
// The confirmed stack that fell out of scope is reported, not silently dropped.
|
||||
expect(byName['drop-gone']).toEqual({ stackName: 'drop-gone', success: false, error: 'No longer carries this label' });
|
||||
});
|
||||
|
||||
it('reports a confirmed labelled stack that is missing on disk as a failure', async () => {
|
||||
const label = db.createLabel(nodeId, 'ghostdisk-label', 'rose');
|
||||
db.setStackLabels('ghostdisk-stack', nodeId, [label.id]); // labelled but never created on disk
|
||||
const res = await request(app)
|
||||
.post('/api/fleet-actions/labels/local-stop')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ labelName: 'ghostdisk-label', dryRun: true, stackNames: ['ghostdisk-stack'] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.results).toEqual([{ stackName: 'ghostdisk-stack', success: false, error: 'Stack not found on this node' }]);
|
||||
});
|
||||
});
|
||||
|
||||
// Orchestrator-level binding: the control sends the exact node + stack list the
|
||||
// operator confirmed, and the fan-out must neither widen the stack set nor drop
|
||||
// a confirmed node that has since disappeared from the registry.
|
||||
describe('fleet-stop confirmed-target binding', () => {
|
||||
let db: import('../services/DatabaseService').DatabaseService;
|
||||
let localNodeId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
const { DatabaseService } = await import('../services/DatabaseService');
|
||||
const { NodeRegistry } = await import('../services/NodeRegistry');
|
||||
db = DatabaseService.getInstance();
|
||||
localNodeId = NodeRegistry.getInstance().getDefaultNodeId();
|
||||
});
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('stops only the confirmed stacks even when others share the label', async () => {
|
||||
makeStack('bind-a');
|
||||
makeStack('bind-late');
|
||||
const label = db.createLabel(localNodeId, 'bind-label', 'teal');
|
||||
db.setStackLabels('bind-a', localNodeId, [label.id]);
|
||||
// bind-late carries the label but was not in the confirmed preview (it
|
||||
// stands in for a stack labelled between preview and execution).
|
||||
db.setStackLabels('bind-late', localNodeId, [label.id]);
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/labels/fleet-stop')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ labelName: 'bind-label', dryRun: true, targets: [{ nodeId: localNodeId, stackNames: ['bind-a'] }] });
|
||||
expect(res.status).toBe(200);
|
||||
const local = res.body.results.find((r: { nodeId: number }) => r.nodeId === localNodeId);
|
||||
expect(local.matched).toBe(true);
|
||||
expect(local.stackResults.map((s: { stackName: string }) => s.stackName)).toEqual(['bind-a']);
|
||||
});
|
||||
|
||||
it('surfaces a confirmed node that no longer exists as an unreachable failure', async () => {
|
||||
makeStack('mix-a');
|
||||
const label = db.createLabel(localNodeId, 'mix-label', 'rose');
|
||||
db.setStackLabels('mix-a', localNodeId, [label.id]);
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/labels/fleet-stop')
|
||||
.set('Authorization', authHeader)
|
||||
.send({
|
||||
labelName: 'mix-label',
|
||||
dryRun: true,
|
||||
targets: [
|
||||
{ nodeId: localNodeId, stackNames: ['mix-a'] },
|
||||
{ nodeId: 999999, stackNames: ['ghost'] },
|
||||
],
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const local = res.body.results.find((r: { nodeId: number }) => r.nodeId === localNodeId);
|
||||
const missing = res.body.results.find((r: { nodeId: number }) => r.nodeId === 999999);
|
||||
expect(local.stackResults.map((s: { stackName: string }) => s.stackName)).toEqual(['mix-a']);
|
||||
expect(missing).toBeTruthy();
|
||||
expect(missing.reachable).toBe(false);
|
||||
expect(missing.error).toMatch(/no longer exists/i);
|
||||
// The missing node's confirmed stacks are marked failed so the run reports
|
||||
// as partial rather than a clean stop of the surviving node alone.
|
||||
expect(missing.stackResults).toEqual([{ stackName: 'ghost', success: false, error: 'Node no longer exists' }]);
|
||||
});
|
||||
|
||||
it('without targets, scans the whole label (no per-stack binding)', async () => {
|
||||
makeStack('nobind-a');
|
||||
makeStack('nobind-b');
|
||||
const label = db.createLabel(localNodeId, 'nobind-label', 'amber');
|
||||
db.setStackLabels('nobind-a', localNodeId, [label.id]);
|
||||
db.setStackLabels('nobind-b', localNodeId, [label.id]);
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/labels/fleet-stop')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ labelName: 'nobind-label', dryRun: true });
|
||||
expect(res.status).toBe(200);
|
||||
const local = res.body.results.find((r: { nodeId: number }) => r.nodeId === localNodeId);
|
||||
expect(local.stackResults.map((s: { stackName: string }) => s.stackName).sort()).toEqual(['nobind-a', 'nobind-b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('local-assign receiver behavior', () => {
|
||||
|
||||
Reference in New Issue
Block a user