mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 11:47:11 +00:00
feat(fleet): cross-node bulk label assign with authoritative label discovery (#1389)
* feat(fleet): cross-node bulk label assign with authoritative label discovery Make Fleet Actions > Bulk label assign work across the fleet. Pick a stack label that exists anywhere in the fleet, select stacks on one or more nodes, and the control orchestrates: each target node resolves the label by name, creating it with the same name and color if missing, then adds it to the selected stacks while preserving their existing labels. The local node runs in process; each remote runs its own admin-only local-assign receiver over the node proxy. Per-node failures (unknown node, no proxy target, unreachable, mixed-version remote) degrade that node only and are reported per node in the result. Assignment writes use a transactional INSERT OR IGNORE so the add-preserve path is idempotent and race-free. Also make the shared fleet label discovery authoritative: suggestions, match-preview, and the fleet-stop remote leg now read each node's labels live over the proxy instead of the control database, which does not mirror remote labels. A propagated label therefore appears in, and is stoppable by, Stop-by-label across the fleet, and unreachable nodes are surfaced rather than silently dropped. Fleet Actions runs against the unfiltered node list, so overview filters no longer narrow its scope. The previous node-scoped, replace-by-id bulk-assign endpoint is removed. * fix(fleet): treat malformed remote label responses as per-node failures A 200 response from a remote node whose body is not the expected shape was treated as a benign empty result, so a malformed remote could read as a clean zero-stack assign or a "matched, nothing to stop" no-op and even surface a success toast. Validate the wire shape in the bulk-assign and fleet-stop remote legs and in the authoritative label discovery fan-out; on a malformed body, report the node as a per-node failure with the error attributed to its stacks instead of silently dropping it. * chore: drop accidentally committed temp file
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
/**
|
||||
* Tests for the Fleet Actions tab endpoints. Covers auth, tier gating, input
|
||||
* validation, and orchestration shape across the two routes.
|
||||
* validation, and cross-node orchestration shape across the fleet label routes:
|
||||
* the authoritative discovery reads (suggestions / match-preview / fleet-stop),
|
||||
* the bulk-assign orchestrator, and the per-node local-stop / local-assign
|
||||
* receivers.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
|
||||
import fs from 'fs';
|
||||
@@ -28,9 +31,20 @@ function mockTier(tier: 'paid' | 'community') {
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue(tier);
|
||||
}
|
||||
|
||||
function makeStack(name: string): void {
|
||||
const composeDir = process.env.COMPOSE_DIR as string;
|
||||
fs.mkdirSync(path.join(composeDir, name), { recursive: true });
|
||||
fs.writeFileSync(path.join(composeDir, name, 'docker-compose.yml'), 'services: {}\n');
|
||||
}
|
||||
|
||||
describe('Fleet Actions endpoints require authentication', () => {
|
||||
it('POST /api/fleet-actions/labels/bulk-assign returns 401 without auth', async () => {
|
||||
const res = await request(app).post('/api/fleet-actions/labels/bulk-assign').send({ assignments: [] });
|
||||
it('POST /api/fleet/labels/bulk-assign returns 401 without auth', async () => {
|
||||
const res = await request(app).post('/api/fleet/labels/bulk-assign').send({ label: { name: 'x', color: 'teal' }, targets: [] });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('POST /api/fleet-actions/labels/local-assign returns 401 without auth', async () => {
|
||||
const res = await request(app).post('/api/fleet-actions/labels/local-assign').send({ label: { name: 'x', color: 'teal' }, stackNames: [] });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
@@ -54,15 +68,26 @@ describe('Fleet Actions tier gating (Community + admin)', () => {
|
||||
expect(Array.isArray(res.body.results)).toBe(true);
|
||||
});
|
||||
|
||||
it('POST /api/fleet-actions/labels/bulk-assign is reachable on community tier for admins', async () => {
|
||||
it('POST /api/fleet/labels/bulk-assign is reachable on community tier for admins', async () => {
|
||||
mockTier('community');
|
||||
const res = await request(app)
|
||||
.post('/api/fleet-actions/labels/bulk-assign')
|
||||
.post('/api/fleet/labels/bulk-assign')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ assignments: [] });
|
||||
.send({ label: { name: 'community-ok', color: 'teal' }, targets: [{ nodeId: 999999, stackNames: ['nope'] }] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.code).not.toBe('PAID_REQUIRED');
|
||||
expect(res.body.results).toEqual([]);
|
||||
expect(Array.isArray(res.body.results)).toBe(true);
|
||||
});
|
||||
|
||||
it('POST /api/fleet-actions/labels/local-assign is reachable on community tier for admins', async () => {
|
||||
mockTier('community');
|
||||
const res = await request(app)
|
||||
.post('/api/fleet-actions/labels/local-assign')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ label: { name: 'community-recv', color: 'teal' }, stackNames: [] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.code).not.toBe('PAID_REQUIRED');
|
||||
expect(res.body).toEqual({ created: expect.any(Boolean), results: [] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -86,21 +111,76 @@ describe('Fleet Actions input validation', () => {
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('POST /api/fleet-actions/labels/bulk-assign rejects non-array assignments', async () => {
|
||||
it('POST /api/fleet/labels/bulk-assign rejects an invalid label color', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/fleet-actions/labels/bulk-assign')
|
||||
.post('/api/fleet/labels/bulk-assign')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ assignments: 'oops' });
|
||||
.send({ label: { name: 'media', color: 'not-a-color' }, targets: [{ nodeId: 0, stackNames: ['x'] }] });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/assignments must be an array/);
|
||||
expect(res.body.error).toMatch(/color/);
|
||||
});
|
||||
|
||||
it('POST /api/fleet-actions/labels/bulk-assign rejects oversized payload', async () => {
|
||||
const big = Array.from({ length: 1001 }, (_, i) => ({ stackName: `s${i}`, labelIds: [] }));
|
||||
it('POST /api/fleet/labels/bulk-assign rejects a missing label name', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/fleet-actions/labels/bulk-assign')
|
||||
.post('/api/fleet/labels/bulk-assign')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ assignments: big });
|
||||
.send({ label: { color: 'teal' }, targets: [{ nodeId: 0, stackNames: ['x'] }] });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/label\.name/);
|
||||
});
|
||||
|
||||
it('POST /api/fleet/labels/bulk-assign rejects empty targets', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/labels/bulk-assign')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ label: { name: 'media', color: 'teal' }, targets: [] });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/targets/);
|
||||
});
|
||||
|
||||
it('POST /api/fleet/labels/bulk-assign rejects a non-integer nodeId', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/labels/bulk-assign')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ label: { name: 'media', color: 'teal' }, targets: [{ nodeId: 'abc', stackNames: ['x'] }] });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/nodeId/);
|
||||
});
|
||||
|
||||
it('POST /api/fleet/labels/bulk-assign rejects when all target groups are empty', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/labels/bulk-assign')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ label: { name: 'media', color: 'teal' }, targets: [{ nodeId: 0, stackNames: [] }] });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/no target stacks/);
|
||||
});
|
||||
|
||||
it('POST /api/fleet/labels/bulk-assign rejects an oversized total', async () => {
|
||||
const big = Array.from({ length: 1001 }, (_, i) => `s${i}`);
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/labels/bulk-assign')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ label: { name: 'media', color: 'teal' }, targets: [{ nodeId: 0, stackNames: big }] });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/may not exceed/);
|
||||
});
|
||||
|
||||
it('POST /api/fleet-actions/labels/local-assign rejects non-array stackNames', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/fleet-actions/labels/local-assign')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ label: { name: 'media', color: 'teal' }, stackNames: 'oops' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/stackNames must be an array/);
|
||||
});
|
||||
|
||||
it('POST /api/fleet-actions/labels/local-assign rejects an oversized payload', async () => {
|
||||
const big = Array.from({ length: 1001 }, (_, i) => `s${i}`);
|
||||
const res = await request(app)
|
||||
.post('/api/fleet-actions/labels/local-assign')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ label: { name: 'media', color: 'teal' }, stackNames: big });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/may not exceed/);
|
||||
});
|
||||
@@ -122,22 +202,20 @@ describe('Fleet Actions orchestration shape', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('POST /api/fleet-actions/labels/bulk-assign accepts empty assignments and returns empty results', async () => {
|
||||
it('POST /api/fleet/labels/bulk-assign reports an unknown node per-node without failing the request', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/fleet-actions/labels/bulk-assign')
|
||||
.post('/api/fleet/labels/bulk-assign')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ assignments: [] });
|
||||
.send({ label: { name: 'media', color: 'teal' }, targets: [{ nodeId: 999999, stackNames: ['a', 'b'] }] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.results).toEqual([]);
|
||||
});
|
||||
|
||||
it('POST /api/fleet-actions/labels/bulk-assign rejects an entry with bad stack name in-line', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/fleet-actions/labels/bulk-assign')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ assignments: [{ stackName: 'has spaces!', labelIds: [1] }] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.results[0]).toMatchObject({ success: false, error: 'Invalid stack name' });
|
||||
expect(res.body.results).toHaveLength(1);
|
||||
const row = res.body.results[0];
|
||||
expect(row.reachable).toBe(false);
|
||||
expect(row.error).toBe('Unknown node');
|
||||
expect(row.stackResults).toEqual([
|
||||
{ stackName: 'a', success: false, error: 'Unknown node' },
|
||||
{ stackName: 'b', success: false, error: 'Unknown node' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -196,7 +274,7 @@ describe('local-stop behavior', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('matched:true with empty results when the label exists but has no stacks', async () => {
|
||||
db.createLabel(nodeId, 'no-stacks-label', '#ffffff');
|
||||
db.createLabel(nodeId, 'no-stacks-label', 'slate');
|
||||
const res = await request(app)
|
||||
.post('/api/fleet-actions/labels/local-stop')
|
||||
.set('Authorization', authHeader)
|
||||
@@ -206,7 +284,7 @@ describe('local-stop behavior', () => {
|
||||
});
|
||||
|
||||
it('reports per-stack lock contention when a bulk action is already running on the node', async () => {
|
||||
const label = db.createLabel(nodeId, 'busy-label', '#ffffff');
|
||||
const label = db.createLabel(nodeId, 'busy-label', 'slate');
|
||||
db.setStackLabels('busy-stack', nodeId, [label.id]);
|
||||
const { activeBulkActions } = await import('../routes/labels');
|
||||
activeBulkActions.add(`bulk:${nodeId}`);
|
||||
@@ -226,10 +304,8 @@ describe('local-stop behavior', () => {
|
||||
});
|
||||
|
||||
it('dry run returns dryRun:true per on-disk stack without touching Docker', async () => {
|
||||
const composeDir = process.env.COMPOSE_DIR as string;
|
||||
fs.mkdirSync(path.join(composeDir, 'dry-stack'), { recursive: true });
|
||||
fs.writeFileSync(path.join(composeDir, 'dry-stack', 'docker-compose.yml'), 'services: {}\n');
|
||||
const label = db.createLabel(nodeId, 'dry-label', '#ffffff');
|
||||
makeStack('dry-stack');
|
||||
const label = db.createLabel(nodeId, 'dry-label', 'slate');
|
||||
db.setStackLabels('dry-stack', nodeId, [label.id]);
|
||||
const res = await request(app)
|
||||
.post('/api/fleet-actions/labels/local-stop')
|
||||
@@ -241,7 +317,7 @@ describe('local-stop behavior', () => {
|
||||
});
|
||||
|
||||
it('filters out assigned stacks that are not present on disk', async () => {
|
||||
const label = db.createLabel(nodeId, 'ghost-label', '#ffffff');
|
||||
const label = db.createLabel(nodeId, 'ghost-label', 'slate');
|
||||
db.setStackLabels('ghost-stack', nodeId, [label.id]);
|
||||
const res = await request(app)
|
||||
.post('/api/fleet-actions/labels/local-stop')
|
||||
@@ -252,6 +328,320 @@ describe('local-stop behavior', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('local-assign receiver behavior', () => {
|
||||
let db: import('../services/DatabaseService').DatabaseService;
|
||||
let nodeId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
const { DatabaseService } = await import('../services/DatabaseService');
|
||||
const { NodeRegistry } = await import('../services/NodeRegistry');
|
||||
db = DatabaseService.getInstance();
|
||||
nodeId = NodeRegistry.getInstance().getDefaultNodeId();
|
||||
});
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('creates the label when missing and assigns it to the stack', async () => {
|
||||
makeStack('recv-create');
|
||||
const res = await request(app)
|
||||
.post('/api/fleet-actions/labels/local-assign')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ label: { name: 'recv-created', color: 'blue' }, stackNames: ['recv-create'] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.created).toBe(true);
|
||||
expect(res.body.results).toEqual([{ stackName: 'recv-create', success: true }]);
|
||||
const created = db.getLabels(nodeId).find(l => l.name === 'recv-created');
|
||||
expect(created).toBeTruthy();
|
||||
expect(db.getLabelsForStacks(nodeId)['recv-create'].map(l => l.name)).toContain('recv-created');
|
||||
});
|
||||
|
||||
it('reuses an existing label (created:false) and preserves existing assignments', async () => {
|
||||
makeStack('recv-reuse');
|
||||
const existing = db.createLabel(nodeId, 'recv-existing', 'green');
|
||||
db.setStackLabels('recv-reuse', nodeId, [existing.id]);
|
||||
db.createLabel(nodeId, 'recv-reused', 'purple');
|
||||
const res = await request(app)
|
||||
.post('/api/fleet-actions/labels/local-assign')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ label: { name: 'recv-reused', color: 'purple' }, stackNames: ['recv-reuse'] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.created).toBe(false);
|
||||
const names = db.getLabelsForStacks(nodeId)['recv-reuse'].map(l => l.name).sort();
|
||||
expect(names).toEqual(['recv-existing', 'recv-reused']);
|
||||
});
|
||||
|
||||
it('reports a per-stack error for a stack that is not on disk', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/fleet-actions/labels/local-assign')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ label: { name: 'recv-ghost', color: 'rose' }, stackNames: ['recv-not-on-disk'] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.results).toEqual([{ stackName: 'recv-not-on-disk', success: false, error: 'Stack not found' }]);
|
||||
});
|
||||
|
||||
it('fails all stacks with the cap message when the node is at the label limit', async () => {
|
||||
makeStack('recv-cap');
|
||||
vi.spyOn(db, 'getLabelCount').mockReturnValue(50);
|
||||
const res = await request(app)
|
||||
.post('/api/fleet-actions/labels/local-assign')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ label: { name: 'recv-over-cap', color: 'teal' }, stackNames: ['recv-cap'] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.created).toBe(false);
|
||||
expect(res.body.results[0]).toMatchObject({ stackName: 'recv-cap', success: false });
|
||||
expect(res.body.results[0].error).toMatch(/Maximum of 50/);
|
||||
});
|
||||
|
||||
it('reuses an existing label after a concurrent-create unique violation', async () => {
|
||||
makeStack('recv-race');
|
||||
// First read sees no label (so a create is attempted); create throws a unique
|
||||
// violation as if another request won the race; the re-fetch then finds it.
|
||||
const raced = { id: 9991, node_id: nodeId, name: 'recv-raced', color: 'teal' as const };
|
||||
vi.spyOn(db, 'getLabels').mockReturnValueOnce([]).mockReturnValue([raced]);
|
||||
const uniqueErr = Object.assign(new Error('UNIQUE constraint failed'), { code: 'SQLITE_CONSTRAINT_UNIQUE' });
|
||||
vi.spyOn(db, 'createLabel').mockImplementation(() => { throw uniqueErr; });
|
||||
const addSpy = vi.spyOn(db, 'addStackLabels').mockImplementation(() => {});
|
||||
const res = await request(app)
|
||||
.post('/api/fleet-actions/labels/local-assign')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ label: { name: 'recv-raced', color: 'teal' }, stackNames: ['recv-race'] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.created).toBe(false);
|
||||
expect(res.body.results).toEqual([{ stackName: 'recv-race', success: true }]);
|
||||
expect(addSpy).toHaveBeenCalledWith('recv-race', nodeId, [9991]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DatabaseService.addStackLabels', () => {
|
||||
let db: import('../services/DatabaseService').DatabaseService;
|
||||
let nodeId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
db = (await import('../services/DatabaseService')).DatabaseService.getInstance();
|
||||
nodeId = (await import('../services/NodeRegistry')).NodeRegistry.getInstance().getDefaultNodeId();
|
||||
});
|
||||
|
||||
it('adds labels while preserving existing assignments', () => {
|
||||
const a = db.createLabel(nodeId, 'add-pre-a', 'teal');
|
||||
const b = db.createLabel(nodeId, 'add-pre-b', 'blue');
|
||||
db.setStackLabels('add-pre-stack', nodeId, [a.id]);
|
||||
db.addStackLabels('add-pre-stack', nodeId, [b.id]);
|
||||
const names = db.getLabelsForStacks(nodeId)['add-pre-stack'].map(l => l.name).sort();
|
||||
expect(names).toEqual(['add-pre-a', 'add-pre-b']);
|
||||
});
|
||||
|
||||
it('is idempotent on re-add (no duplicate row, no throw)', () => {
|
||||
const a = db.createLabel(nodeId, 'idem-a', 'teal');
|
||||
db.addStackLabels('idem-stack', nodeId, [a.id]);
|
||||
db.addStackLabels('idem-stack', nodeId, [a.id]);
|
||||
expect(db.getLabelsForStacks(nodeId)['idem-stack'].filter(l => l.name === 'idem-a')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('throws when a label id does not belong to the node', () => {
|
||||
expect(() => db.addStackLabels('bad-id-stack', nodeId, [999999])).toThrow(/invalid for this node/);
|
||||
});
|
||||
|
||||
it('no-ops on an empty id list', () => {
|
||||
expect(() => db.addStackLabels('empty-id-stack', nodeId, [])).not.toThrow();
|
||||
expect(db.getLabelsForStacks(nodeId)['empty-id-stack']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulk-assign orchestrator: local node', () => {
|
||||
let db: import('../services/DatabaseService').DatabaseService;
|
||||
let nodeId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
const { DatabaseService } = await import('../services/DatabaseService');
|
||||
const { NodeRegistry } = await import('../services/NodeRegistry');
|
||||
db = DatabaseService.getInstance();
|
||||
nodeId = NodeRegistry.getInstance().getDefaultNodeId();
|
||||
});
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('creates the label on the local node and assigns it, preserving existing labels', async () => {
|
||||
makeStack('orch-local');
|
||||
const existing = db.createLabel(nodeId, 'orch-existing', 'amber');
|
||||
db.setStackLabels('orch-local', nodeId, [existing.id]);
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/labels/bulk-assign')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ label: { name: 'orch-media', color: 'teal' }, targets: [{ nodeId, stackNames: ['orch-local'] }] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.results).toHaveLength(1);
|
||||
const row = res.body.results[0];
|
||||
expect(row.reachable).toBe(true);
|
||||
expect(row.created).toBe(true);
|
||||
expect(row.stackResults).toEqual([{ stackName: 'orch-local', success: true }]);
|
||||
const names = db.getLabelsForStacks(nodeId)['orch-local'].map(l => l.name).sort();
|
||||
expect(names).toEqual(['orch-existing', 'orch-media']);
|
||||
});
|
||||
|
||||
it('reuses an existing local label (created:false)', async () => {
|
||||
makeStack('orch-reuse');
|
||||
db.createLabel(nodeId, 'orch-reused', 'cyan');
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/labels/bulk-assign')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ label: { name: 'orch-reused', color: 'cyan' }, targets: [{ nodeId, stackNames: ['orch-reuse'] }] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.results[0].created).toBe(false);
|
||||
expect(res.body.results[0].stackResults).toEqual([{ stackName: 'orch-reuse', success: true }]);
|
||||
});
|
||||
|
||||
it('dedupes repeated stack names within a target', async () => {
|
||||
makeStack('orch-dedupe');
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/labels/bulk-assign')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ label: { name: 'orch-dd', color: 'pink' }, targets: [{ nodeId, stackNames: ['orch-dedupe', 'orch-dedupe'] }] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.results[0].stackResults).toEqual([{ stackName: 'orch-dedupe', success: true }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulk-assign orchestrator: remote fan-out', () => {
|
||||
let db: import('../services/DatabaseService').DatabaseService;
|
||||
let NodeRegistry: typeof import('../services/NodeRegistry').NodeRegistry;
|
||||
|
||||
beforeAll(async () => {
|
||||
db = (await import('../services/DatabaseService')).DatabaseService.getInstance();
|
||||
({ NodeRegistry } = await import('../services/NodeRegistry'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
for (const n of db.getNodes().filter(n => n.type === 'remote')) db.deleteNode(n.id);
|
||||
});
|
||||
|
||||
function addRemote(name: string, mode: 'proxy' | 'pilot_agent' = 'proxy'): number {
|
||||
return db.addNode({
|
||||
name, type: 'remote', mode,
|
||||
compose_dir: '/tmp', is_default: false,
|
||||
api_url: mode === 'proxy' ? 'https://remote.example.com:1852' : '',
|
||||
api_token: mode === 'proxy' ? 'remote-tok' : '',
|
||||
});
|
||||
}
|
||||
|
||||
it('fans out to the remote local-assign receiver with Bearer auth and the template body', async () => {
|
||||
const remoteId = addRemote('assign-remote-ok');
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok' });
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(
|
||||
JSON.stringify({ created: true, results: [{ stackName: 'r1', success: true }] }),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
));
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/labels/bulk-assign')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ label: { name: 'media', color: 'teal' }, targets: [{ nodeId: remoteId, stackNames: ['r1'] }] });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const row = res.body.results.find((r: { nodeId: number }) => r.nodeId === remoteId);
|
||||
expect(row.reachable).toBe(true);
|
||||
expect(row.created).toBe(true);
|
||||
expect(row.stackResults).toEqual([{ stackName: 'r1', success: true }]);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const call = fetchMock.mock.calls[0];
|
||||
expect(String(call[0])).toBe('https://remote.example.com:1852/api/fleet-actions/labels/local-assign');
|
||||
const init = call[1] as { method: string; headers: Record<string, string>; body: string };
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.headers['Authorization']).toBe('Bearer remote-tok');
|
||||
expect(JSON.parse(init.body)).toEqual({ label: { name: 'media', color: 'teal' }, stackNames: ['r1'] });
|
||||
});
|
||||
|
||||
it('omits the Authorization header for a pilot-agent remote with an empty token', async () => {
|
||||
const remoteId = addRemote('assign-remote-pilot', 'pilot_agent');
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'http://127.0.0.1:9', apiToken: '' });
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(
|
||||
JSON.stringify({ created: false, results: [{ stackName: 'p1', success: true }] }),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
));
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/labels/bulk-assign')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ label: { name: 'media', color: 'teal' }, targets: [{ nodeId: remoteId, stackNames: ['p1'] }] });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const init = fetchMock.mock.calls[0][1] as { headers: Record<string, string> };
|
||||
expect(init.headers).not.toHaveProperty('Authorization');
|
||||
});
|
||||
|
||||
it('reports no-proxy-target as a per-node failure without blocking the request', async () => {
|
||||
const remoteId = addRemote('assign-remote-down', 'pilot_agent');
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue(null);
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/labels/bulk-assign')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ label: { name: 'media', color: 'teal' }, targets: [{ nodeId: remoteId, stackNames: ['r1'] }] });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const row = res.body.results.find((r: { nodeId: number }) => r.nodeId === remoteId);
|
||||
expect(row.reachable).toBe(false);
|
||||
expect(row.error).toBeTruthy();
|
||||
expect(row.stackResults).toEqual([{ stackName: 'r1', success: false, error: row.error }]);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats a mixed-version remote (404 on local-assign) as a per-node failure', async () => {
|
||||
const remoteId = addRemote('assign-remote-404');
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok' });
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('Not Found', { status: 404 }));
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/labels/bulk-assign')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ label: { name: 'media', color: 'teal' }, targets: [{ nodeId: remoteId, stackNames: ['r1'] }] });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const row = res.body.results.find((r: { nodeId: number }) => r.nodeId === remoteId);
|
||||
expect(row.reachable).toBe(false);
|
||||
expect(row.error).toMatch(/404/);
|
||||
expect(row.stackResults[0]).toMatchObject({ stackName: 'r1', success: false });
|
||||
});
|
||||
|
||||
it('reports a transport failure as a per-node failure', async () => {
|
||||
const remoteId = addRemote('assign-remote-transport');
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok' });
|
||||
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/labels/bulk-assign')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ label: { name: 'media', color: 'teal' }, targets: [{ nodeId: remoteId, stackNames: ['r1'] }] });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const row = res.body.results.find((r: { nodeId: number }) => r.nodeId === remoteId);
|
||||
expect(row.reachable).toBe(false);
|
||||
expect(row.stackResults[0]).toMatchObject({ stackName: 'r1', success: false });
|
||||
});
|
||||
|
||||
it('reports a malformed 200 body as a per-node failure', async () => {
|
||||
const remoteId = addRemote('assign-remote-malformed');
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockReturnValue({ apiUrl: 'https://remote.example.com:1852', apiToken: 'remote-tok' });
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(
|
||||
JSON.stringify({ created: true, results: 'not-an-array' }),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
));
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/fleet/labels/bulk-assign')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ label: { name: 'media', color: 'teal' }, targets: [{ nodeId: remoteId, stackNames: ['r1'] }] });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const row = res.body.results.find((r: { nodeId: number }) => r.nodeId === remoteId);
|
||||
expect(row.reachable).toBe(false);
|
||||
expect(row.error).toMatch(/malformed/);
|
||||
expect(row.stackResults).toEqual([{ stackName: 'r1', success: false, error: 'Remote returned a malformed response' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fleet-stop degrades the local leg per-node instead of failing the whole fan-out', () => {
|
||||
let db: import('../services/DatabaseService').DatabaseService;
|
||||
let nodeId: number;
|
||||
@@ -266,7 +656,7 @@ describe('fleet-stop degrades the local leg per-node instead of failing the whol
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('returns 200 with per-stack errors when the control filesystem read throws', async () => {
|
||||
const label = db.createLabel(nodeId, 'degrade-label', '#ffffff');
|
||||
const label = db.createLabel(nodeId, 'degrade-label', 'slate');
|
||||
db.setStackLabels('degrade-stack', nodeId, [label.id]);
|
||||
const { FileSystemService } = await import('../services/FileSystemService');
|
||||
vi.spyOn(FileSystemService.prototype, 'getStacks').mockRejectedValue(new Error('compose dir unreadable'));
|
||||
|
||||
@@ -14,6 +14,11 @@ export const BCRYPT_SALT_ROUNDS = 10;
|
||||
export const VALID_LABEL_COLORS = ['teal', 'blue', 'purple', 'rose', 'amber', 'green', 'orange', 'pink', 'cyan', 'slate'] as const;
|
||||
export type LabelColor = typeof VALID_LABEL_COLORS[number];
|
||||
export const MAX_LABELS_PER_NODE = 50;
|
||||
// Hard cap on stack assignments a single bulk-assign request may carry, summed
|
||||
// across all target nodes. A node typically has tens of stacks, not thousands;
|
||||
// the cap bounds the DB writes one request can force. Shared by the per-node
|
||||
// receiver and the fleet orchestrator so they cannot drift.
|
||||
export const MAX_ASSIGNMENTS = 1000;
|
||||
|
||||
// Session cookies
|
||||
export const COOKIE_NAME = 'sencho_token';
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { FileSystemService } from '../services/FileSystemService';
|
||||
import { VALID_LABEL_COLORS, MAX_LABELS_PER_NODE } from './constants';
|
||||
import { isValidStackName } from '../utils/validation';
|
||||
import { getErrorMessage, isSqliteUniqueViolation } from '../utils/errors';
|
||||
|
||||
export interface LabelTemplate {
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface LabelAssignResult {
|
||||
stackName: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface LabelAssignOutcome {
|
||||
/** True when this node did not have the label and it was created here. */
|
||||
created: boolean;
|
||||
stackResults: LabelAssignResult[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire shape of `POST /api/fleet-actions/labels/local-assign`. The in-process
|
||||
* helper returns `stackResults`; the HTTP response names the same array
|
||||
* `results` to match the assign fan-out's remote contract. Keep the rename in
|
||||
* this one type so the producer and the control-side consumer cannot drift.
|
||||
*/
|
||||
export interface LabelLocalAssignResponse {
|
||||
created: boolean;
|
||||
results: LabelAssignResult[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-node row in the fleet bulk-assign orchestrator response
|
||||
* (`POST /api/fleet/labels/bulk-assign`). `reachable` is always set; `error`
|
||||
* carries the node-level cause when a node could not be reached or resolved.
|
||||
*/
|
||||
export interface AssignNodeResult {
|
||||
nodeId: number;
|
||||
nodeName: string;
|
||||
reachable: boolean;
|
||||
created: boolean;
|
||||
error?: string;
|
||||
stackResults: LabelAssignResult[];
|
||||
}
|
||||
|
||||
/** Attribute one node-level error to every stack a node was meant to receive. */
|
||||
export function failAllAssign(stackNames: string[], error: string): LabelAssignResult[] {
|
||||
return Array.from(new Set(stackNames)).map(stackName => ({ stackName, success: false, error }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a label template (the name/color a cross-node assign propagates).
|
||||
* Mirrors the create-label rules in `routes/labels.ts` and is the single
|
||||
* validator shared by the per-node receiver and the fleet orchestrator.
|
||||
*/
|
||||
export function validateLabelTemplate(
|
||||
input: unknown,
|
||||
): { ok: true; template: LabelTemplate } | { ok: false; error: string } {
|
||||
if (!input || typeof input !== 'object') {
|
||||
return { ok: false, error: 'label is required' };
|
||||
}
|
||||
const { name, color } = input as { name?: unknown; color?: unknown };
|
||||
if (typeof name !== 'string' || name.trim().length === 0 || name.length > 30) {
|
||||
return { ok: false, error: 'label.name is required and must be 1-30 characters' };
|
||||
}
|
||||
if (!/^[a-zA-Z0-9 -]+$/.test(name)) {
|
||||
return { ok: false, error: 'label.name may only contain letters, numbers, spaces, and hyphens' };
|
||||
}
|
||||
if (typeof color !== 'string' || !(VALID_LABEL_COLORS as readonly string[]).includes(color)) {
|
||||
return { ok: false, error: `label.color must be one of: ${VALID_LABEL_COLORS.join(', ')}` };
|
||||
}
|
||||
return { ok: true, template: { name: name.trim(), color } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve-or-create a label by name on one node, then assign it to the given
|
||||
* stacks while preserving their existing labels (add semantics).
|
||||
*
|
||||
* Used by the gateway-orchestrated bulk-assign for the control node's own stacks
|
||||
* and by the per-node `POST /api/fleet-actions/labels/local-assign` receiver that
|
||||
* a control instance calls on each remote. Matching/creating by name (never by a
|
||||
* shared id) keeps labels node-local: each node owns its own label id, so the
|
||||
* control never reuses a local id on a remote.
|
||||
*/
|
||||
export async function runLocalLabelAssign(
|
||||
nodeId: number,
|
||||
label: LabelTemplate,
|
||||
stackNames: string[],
|
||||
): Promise<LabelAssignOutcome> {
|
||||
const db = DatabaseService.getInstance();
|
||||
|
||||
// Resolve the label on this node by exact name; create it if missing.
|
||||
let resolved = db.getLabels(nodeId).find(l => l.name === label.name);
|
||||
let created = false;
|
||||
if (!resolved) {
|
||||
if (db.getLabelCount(nodeId) >= MAX_LABELS_PER_NODE) {
|
||||
return { created: false, stackResults: failAllAssign(stackNames, `Maximum of ${MAX_LABELS_PER_NODE} labels per node reached`) };
|
||||
}
|
||||
try {
|
||||
resolved = db.createLabel(nodeId, label.name, label.color);
|
||||
created = true;
|
||||
} catch (err) {
|
||||
// A concurrent create can win the UNIQUE(node_id, name) race; re-fetch and
|
||||
// reuse the now-existing label rather than failing the assignment.
|
||||
if (isSqliteUniqueViolation(err)) {
|
||||
resolved = db.getLabels(nodeId).find(l => l.name === label.name);
|
||||
}
|
||||
if (!resolved) {
|
||||
return { created: false, stackResults: failAllAssign(stackNames, getErrorMessage(err, 'Failed to create label')) };
|
||||
}
|
||||
}
|
||||
}
|
||||
const labelId = resolved.id;
|
||||
|
||||
const fsStacks = new Set(await FileSystemService.getInstance(nodeId).getStacks());
|
||||
const stackResults: LabelAssignResult[] = [];
|
||||
for (const stackName of Array.from(new Set(stackNames))) {
|
||||
if (!isValidStackName(stackName)) {
|
||||
stackResults.push({ stackName, success: false, error: 'Invalid stack name' });
|
||||
continue;
|
||||
}
|
||||
if (!fsStacks.has(stackName)) {
|
||||
stackResults.push({ stackName, success: false, error: 'Stack not found' });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
db.addStackLabels(stackName, nodeId, [labelId]);
|
||||
stackResults.push({ stackName, success: true });
|
||||
} catch (err) {
|
||||
stackResults.push({ stackName, success: false, error: getErrorMessage(err, 'Failed to assign label') });
|
||||
}
|
||||
}
|
||||
return { created, stackResults };
|
||||
}
|
||||
@@ -39,6 +39,8 @@ import { invalidateNodeCaches, invalidateRemoteMetaCache } from '../helpers/cach
|
||||
import { activeBulkActions } from './labels';
|
||||
import { runLocalLabelStop, isLabelLocalStopResponse, type StackStopResult } from '../helpers/fleetLabelStop';
|
||||
import { collectFleetLabelSummaries } from '../helpers/fleetLabelSummary';
|
||||
import { runLocalLabelAssign, validateLabelTemplate, failAllAssign, type LabelLocalAssignResponse, type AssignNodeResult } from '../helpers/fleetLabelAssign';
|
||||
import { MAX_ASSIGNMENTS } from '../helpers/constants';
|
||||
import { buildLocalConfigurationStatus, type ConfigurationStatus } from './dashboard';
|
||||
import { buildLocalGraph, mergeFleetGraph, isLocalDependencyGraph, type FleetNodeGraphResult } from '../services/DependencyGraphService';
|
||||
import { PROXY_TIER_HEADER } from '../services/license-headers';
|
||||
@@ -1351,6 +1353,133 @@ fleetRouter.post('/labels/fleet-stop', authMiddleware, async (req: Request, res:
|
||||
}
|
||||
});
|
||||
|
||||
// Fleet-wide bulk label assign. Propagates a label template (name + color) to
|
||||
// stacks across one or more nodes: for each target node the label is resolved or
|
||||
// created by name on that node, then assigned to the given stacks preserving
|
||||
// their existing labels (add semantics). Labels are node-local, so the control
|
||||
// never reuses a local label id on a remote; the local node runs in-process and
|
||||
// each remote runs its own `/api/fleet-actions/labels/local-assign` receiver.
|
||||
// Per-node failures (unknown node, no proxy target, unreachable, mixed-version
|
||||
// remote, malformed response) degrade that node only and never discard the rest
|
||||
// of the fan-out.
|
||||
// Tier: requireAdmin (admin-only fleet plumbing; available on every license).
|
||||
fleetRouter.post('/labels/bulk-assign', authMiddleware, async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
const body = req.body as { label?: unknown; targets?: unknown } | undefined;
|
||||
if (!body || typeof body !== 'object') {
|
||||
res.status(400).json({ error: 'Request body is required' });
|
||||
return;
|
||||
}
|
||||
const validated = validateLabelTemplate(body.label);
|
||||
if (!validated.ok) {
|
||||
res.status(400).json({ error: validated.error });
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(body.targets) || body.targets.length === 0) {
|
||||
res.status(400).json({ error: 'targets must be a non-empty array' });
|
||||
return;
|
||||
}
|
||||
// Normalize targets: each must name a node and carry a string array of stacks.
|
||||
// Stack names are deduped per node and empty groups are dropped, so the cap
|
||||
// measures real assignments rather than padded input.
|
||||
const targets: { nodeId: number; stackNames: string[] }[] = [];
|
||||
let totalStacks = 0;
|
||||
for (const raw of body.targets as unknown[]) {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
res.status(400).json({ error: 'each target must be an object' });
|
||||
return;
|
||||
}
|
||||
const { nodeId, stackNames } = raw as { nodeId?: unknown; stackNames?: unknown };
|
||||
if (typeof nodeId !== 'number' || !Number.isInteger(nodeId)) {
|
||||
res.status(400).json({ error: 'target.nodeId must be an integer' });
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(stackNames) || !stackNames.every(s => typeof s === 'string')) {
|
||||
res.status(400).json({ error: 'target.stackNames must be an array of strings' });
|
||||
return;
|
||||
}
|
||||
const unique = Array.from(new Set(stackNames as string[]));
|
||||
if (unique.length === 0) continue;
|
||||
totalStacks += unique.length;
|
||||
targets.push({ nodeId, stackNames: unique });
|
||||
}
|
||||
if (targets.length === 0) {
|
||||
res.status(400).json({ error: 'no target stacks provided' });
|
||||
return;
|
||||
}
|
||||
if (totalStacks > MAX_ASSIGNMENTS) {
|
||||
res.status(400).json({ error: `targets may not exceed ${MAX_ASSIGNMENTS} stack assignments` });
|
||||
return;
|
||||
}
|
||||
const { template } = validated;
|
||||
try {
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodesById = new Map(db.getNodes().map(n => [n.id, n]));
|
||||
if (isDebugEnabled()) console.debug('[Fleet:debug] bulk-assign:', { label: template.name, targets: targets.length, totalStacks });
|
||||
const results: AssignNodeResult[] = await Promise.all(targets.map(async (target): Promise<AssignNodeResult> => {
|
||||
const node = nodesById.get(target.nodeId);
|
||||
if (!node) {
|
||||
return {
|
||||
nodeId: target.nodeId, nodeName: `Node ${target.nodeId}`, reachable: false, created: false, error: 'Unknown node',
|
||||
stackResults: failAllAssign(target.stackNames, 'Unknown node'),
|
||||
};
|
||||
}
|
||||
if (node.type === 'local') {
|
||||
try {
|
||||
const outcome = await runLocalLabelAssign(node.id, template, target.stackNames);
|
||||
return { nodeId: node.id, nodeName: node.name, reachable: true, created: outcome.created, stackResults: outcome.stackResults };
|
||||
} catch (err) {
|
||||
return {
|
||||
nodeId: node.id, nodeName: node.name, reachable: true, created: false,
|
||||
stackResults: failAllAssign(target.stackNames, getErrorMessage(err, 'Failed to assign labels')),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const proxyTarget = NodeRegistry.getInstance().getProxyTarget(node.id);
|
||||
if (!proxyTarget) {
|
||||
const error = formatNoTargetError(node);
|
||||
return { nodeId: node.id, nodeName: node.name, reachable: false, created: false, error, stackResults: failAllAssign(target.stackNames, error) };
|
||||
}
|
||||
try {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (proxyTarget.apiToken) headers.Authorization = `Bearer ${proxyTarget.apiToken}`;
|
||||
const response = await fetch(`${proxyTarget.apiUrl.replace(/\/$/, '')}/api/fleet-actions/labels/local-assign`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ label: template, stackNames: target.stackNames }),
|
||||
signal: AbortSignal.timeout(60000),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const err = (await response.json().catch(() => ({}))) as { error?: string };
|
||||
const message = err.error || `Remote returned ${response.status}`;
|
||||
return { nodeId: node.id, nodeName: node.name, reachable: false, created: false, error: message, stackResults: failAllAssign(target.stackNames, message) };
|
||||
}
|
||||
// A 200 whose body is not the expected { created, results } shape is a
|
||||
// degraded node, not a clean no-op: report it as a per-node failure so a
|
||||
// malformed remote cannot read as a successful zero-stack assign.
|
||||
const remote = (await response.json().catch(() => null)) as Partial<LabelLocalAssignResponse> | null;
|
||||
if (!remote || typeof remote.created !== 'boolean' || !Array.isArray(remote.results)) {
|
||||
const message = 'Remote returned a malformed response';
|
||||
return { nodeId: node.id, nodeName: node.name, reachable: false, created: false, error: message, stackResults: failAllAssign(target.stackNames, message) };
|
||||
}
|
||||
return {
|
||||
nodeId: node.id, nodeName: node.name, reachable: true,
|
||||
created: remote.created,
|
||||
stackResults: remote.results,
|
||||
};
|
||||
} catch (err) {
|
||||
const errorMsg = getErrorMessage(err, 'Failed to reach remote node');
|
||||
return { nodeId: node.id, nodeName: node.name, reachable: false, created: false, error: errorMsg, stackResults: failAllAssign(target.stackNames, errorMsg) };
|
||||
}
|
||||
}));
|
||||
res.json({ results });
|
||||
} catch (error) {
|
||||
console.error('[Fleet] bulk-assign error:', error);
|
||||
res.status(500).json({ error: getErrorMessage(error, 'Failed to run bulk label assign') });
|
||||
}
|
||||
});
|
||||
|
||||
// Fleet-wide Docker prune. Fans out to every node, running per-target prune
|
||||
// (images/volumes/networks) under the chosen scope. Local nodes call
|
||||
// DockerController directly under a per-node bulk-prune lock; remote nodes
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { authMiddleware } from '../middleware/auth';
|
||||
import { requireAdmin, requireBody } from '../middleware/tierGates';
|
||||
import { isValidStackName } from '../utils/validation';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { MAX_ASSIGNMENTS } from '../helpers/constants';
|
||||
import { runLocalLabelStop, type LabelLocalStopResponse } from '../helpers/fleetLabelStop';
|
||||
import { runLocalLabelAssign, validateLabelTemplate, type LabelLocalAssignResponse } from '../helpers/fleetLabelAssign';
|
||||
|
||||
// Per-node fleet-action endpoints. Mounted under `/api/fleet-actions/`, which
|
||||
// is NOT in `PROXY_EXEMPT_PREFIXES`, so when `x-node-id` targets a remote node
|
||||
@@ -14,61 +14,6 @@ import { runLocalLabelStop, type LabelLocalStopResponse } from '../helpers/fleet
|
||||
// because their path must sit behind the `/api/fleet/` proxy-exempt prefix.
|
||||
export const fleetActionsRouter = Router();
|
||||
|
||||
// Hard cap to bound a single bulk-assign request. A node typically has tens of
|
||||
// stacks, not thousands; the cap protects against accidental or malicious
|
||||
// payloads that would force thousands of DB writes in one handler.
|
||||
const MAX_ASSIGNMENTS = 1000;
|
||||
|
||||
// Bulk label assignment for many stacks on a single node. The single-stack
|
||||
// endpoint at `PUT /api/stacks/:stackName/labels` covers one stack at a time;
|
||||
// this wrapper applies the same operation to many stacks atomically per HTTP
|
||||
// request. Tier: requireAdmin (admin-only fleet plumbing). The per-stack
|
||||
// endpoint is Community-tier organization metadata; this multi-stack wrapper
|
||||
// matches the surrounding Fleet Actions surface, which is admin-only but
|
||||
// available on every license.
|
||||
fleetActionsRouter.post(
|
||||
'/labels/bulk-assign',
|
||||
authMiddleware,
|
||||
async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireBody(req, res)) return;
|
||||
const { assignments } = req.body as { assignments?: unknown };
|
||||
if (!Array.isArray(assignments)) {
|
||||
res.status(400).json({ error: 'assignments must be an array' });
|
||||
return;
|
||||
}
|
||||
if (assignments.length > MAX_ASSIGNMENTS) {
|
||||
res.status(400).json({ error: `assignments may not exceed ${MAX_ASSIGNMENTS} entries` });
|
||||
return;
|
||||
}
|
||||
const nodeId = req.nodeId ?? 0;
|
||||
const db = DatabaseService.getInstance();
|
||||
const results: { stackName: string; success: boolean; error?: string }[] = [];
|
||||
for (const entry of assignments as unknown[]) {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
results.push({ stackName: '', success: false, error: 'Invalid assignment entry' });
|
||||
continue;
|
||||
}
|
||||
const { stackName, labelIds } = entry as { stackName?: unknown; labelIds?: unknown };
|
||||
if (typeof stackName !== 'string' || !isValidStackName(stackName)) {
|
||||
results.push({ stackName: typeof stackName === 'string' ? stackName : '', success: false, error: 'Invalid stack name' });
|
||||
continue;
|
||||
}
|
||||
if (!Array.isArray(labelIds) || !labelIds.every(id => typeof id === 'number')) {
|
||||
results.push({ stackName, success: false, error: 'labelIds must be an array of numbers' });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
db.setStackLabels(stackName, nodeId, labelIds);
|
||||
results.push({ stackName, success: true });
|
||||
} catch (err) {
|
||||
results.push({ stackName, success: false, error: getErrorMessage(err, 'Failed to set stack labels') });
|
||||
}
|
||||
}
|
||||
res.json({ results });
|
||||
},
|
||||
);
|
||||
|
||||
// Per-node label-matched stop. A control instance calls this on each remote
|
||||
// node during a fleet-wide stop-by-label so the destructive work runs under the
|
||||
// remote's own admin auth and per-node bulk lock. Admin-only and available on
|
||||
@@ -100,3 +45,43 @@ fleetActionsRouter.post(
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Per-node label assign. A control instance calls this on each target node
|
||||
// during a fleet-wide bulk label assign so the label is resolved or created
|
||||
// under the node's own database, by name, and assigned to the given stacks while
|
||||
// preserving their existing labels (add semantics). Admin-only and available on
|
||||
// every license, matching the rest of the Fleet Actions surface. Labels are
|
||||
// node-local, so the control never reuses a local label id on a remote: the
|
||||
// receiver owns label resolution for its own node.
|
||||
fleetActionsRouter.post(
|
||||
'/labels/local-assign',
|
||||
authMiddleware,
|
||||
async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requireBody(req, res)) return;
|
||||
const { label, stackNames } = req.body as { label?: unknown; stackNames?: unknown };
|
||||
const validated = validateLabelTemplate(label);
|
||||
if (!validated.ok) {
|
||||
res.status(400).json({ error: validated.error });
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(stackNames) || !stackNames.every(s => typeof s === 'string')) {
|
||||
res.status(400).json({ error: 'stackNames must be an array of strings' });
|
||||
return;
|
||||
}
|
||||
if (stackNames.length > MAX_ASSIGNMENTS) {
|
||||
res.status(400).json({ error: `stackNames may not exceed ${MAX_ASSIGNMENTS} entries` });
|
||||
return;
|
||||
}
|
||||
const nodeId = req.nodeId ?? 0;
|
||||
try {
|
||||
const outcome = await runLocalLabelAssign(nodeId, validated.template, stackNames as string[]);
|
||||
if (isDebugEnabled()) console.debug('[FleetActions:debug] local-assign:', { nodeId, label: validated.template.name, created: outcome.created, stacks: outcome.stackResults.length });
|
||||
const body: LabelLocalAssignResponse = { created: outcome.created, results: outcome.stackResults };
|
||||
res.json(body);
|
||||
} catch (err) {
|
||||
console.error('[FleetActions] local-assign error:', { nodeId, label: validated.template.name }, err);
|
||||
res.status(500).json({ error: getErrorMessage(err, 'Failed to run local label assign') });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -5343,6 +5343,33 @@ export class DatabaseService {
|
||||
txn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add labels to a stack without disturbing its existing assignments. Unlike
|
||||
* `setStackLabels` (replace), this is additive: validation and
|
||||
* `INSERT OR IGNORE` run in one transaction and the
|
||||
* `(label_id, stack_name, node_id)` primary key makes re-adds idempotent, so
|
||||
* two additive writes to the same stack never drop each other's labels. A
|
||||
* concurrent `setStackLabels` (replace) still wins last-writer, which is the
|
||||
* intended replace semantics, not a lost update.
|
||||
*/
|
||||
public addStackLabels(stackName: string, nodeId: number, labelIds: number[]): void {
|
||||
if (labelIds.length === 0) return;
|
||||
const txn = this.db.transaction(() => {
|
||||
const placeholders = labelIds.map(() => '?').join(',');
|
||||
const validCount = this.db.prepare(
|
||||
`SELECT COUNT(*) as cnt FROM stack_labels WHERE id IN (${placeholders}) AND node_id = ?`
|
||||
).get(...labelIds, nodeId) as { cnt: number };
|
||||
if (validCount.cnt !== labelIds.length) {
|
||||
throw new Error('One or more label IDs are invalid for this node');
|
||||
}
|
||||
const insert = this.db.prepare('INSERT OR IGNORE INTO stack_label_assignments (label_id, stack_name, node_id) VALUES (?, ?, ?)');
|
||||
for (const labelId of labelIds) {
|
||||
insert.run(labelId, stackName, nodeId);
|
||||
}
|
||||
});
|
||||
txn();
|
||||
}
|
||||
|
||||
public getLabelsForStacks(nodeId: number): Record<string, Label[]> {
|
||||
const rows = this.db.prepare(`
|
||||
SELECT a.stack_name, l.id, l.node_id, l.name, l.color
|
||||
|
||||
Reference in New Issue
Block a user