From a7e856f44752a7e67263a7fbe0eb6dfc34a0f4df Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 5 Jul 2026 05:30:56 -0400 Subject: [PATCH] feat: enforce singleton local node per instance (#1567) Only one local node is allowed. Creating a second local returns 409, and the last local node cannot be deleted or converted to a remote type. Existing duplicate local nodes from older versions are preserved and can be cleaned up individually. Zero-local recovery auto-assigns the default flag. Frontend delete surfaces and the Add Node form respect the new invariant. Enforced in DatabaseService (addNode/updateNode/deleteNode guards) and routes (error translations). Legacy test fixtures use raw SQL helpers. --- .../AutoHealService.evaluate.test.ts | 27 +- .../DatabaseService.autoHeal.test.ts | 30 +- .../src/__tests__/auto-heal-routes.test.ts | 30 +- backend/src/__tests__/nodes.test.ts | 295 ++++++++++++++++++ backend/src/routes/nodes.ts | 20 +- backend/src/services/DatabaseService.ts | 86 ++++- docs/features/multi-node.mdx | 8 +- docs/openapi.yaml | 20 +- .../src/components/FleetView/NodeCard.tsx | 10 +- frontend/src/components/NodeManager.tsx | 5 +- .../src/components/nodes/useNodeActions.tsx | 95 +++--- 11 files changed, 507 insertions(+), 119 deletions(-) diff --git a/backend/src/__tests__/AutoHealService.evaluate.test.ts b/backend/src/__tests__/AutoHealService.evaluate.test.ts index 264934bf..a6085940 100644 --- a/backend/src/__tests__/AutoHealService.evaluate.test.ts +++ b/backend/src/__tests__/AutoHealService.evaluate.test.ts @@ -63,6 +63,15 @@ afterAll(() => { cleanupTestDb(tmpDir); }); +/** Insert a second local node via raw SQL, bypassing the addNode singleton guard. */ +function insertLegacyLocal(name: string): number { + const db = DatabaseService.getInstance().getDb(); + const result = db.prepare( + "INSERT INTO nodes (name, type, compose_dir, is_default, status, created_at) VALUES (?, 'local', ?, 0, 'online', ?)" + ).run(name, process.env.COMPOSE_DIR ?? '', Date.now()); + return result.lastInsertRowid as number; +} + describe('AutoHealService.evaluate', () => { it('evaluates existing policies on the Community tier (no paid gate)', async () => { const db = DatabaseService.getInstance(); @@ -318,14 +327,7 @@ describe('AutoHealService.evaluate', () => { it('evaluates only policies scoped to each local node', async () => { const db = DatabaseService.getInstance(); - const secondNodeId = db.addNode({ - name: 'second-local', - type: 'local', - compose_dir: process.env.COMPOSE_DIR ?? '', - is_default: false, - api_url: '', - api_token: '', - }); + const secondNodeId = insertLegacyLocal('second-local'); makePolicy(db, { node_id: secondNodeId, stack_name: 'second-stack' }); vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); const getAllContainers = vi.fn().mockResolvedValue([]); @@ -341,14 +343,7 @@ describe('AutoHealService.evaluate', () => { it('keeps restart rate-limit state isolated by node while pruning', async () => { const db = DatabaseService.getInstance(); - const secondNodeId = db.addNode({ - name: 'rate-limit-second-local', - type: 'local', - compose_dir: process.env.COMPOSE_DIR ?? '', - is_default: false, - api_url: '', - api_token: '', - }); + const secondNodeId = insertLegacyLocal('rate-limit-second-local'); const policy = makePolicy(db, { node_id: secondNodeId, stack_name: 'second-rate-stack' }); vi.spyOn(DockerController, 'getInstance').mockReturnValue({ getAllContainers: vi.fn().mockResolvedValue([]), diff --git a/backend/src/__tests__/DatabaseService.autoHeal.test.ts b/backend/src/__tests__/DatabaseService.autoHeal.test.ts index 151f9c86..b46ee588 100644 --- a/backend/src/__tests__/DatabaseService.autoHeal.test.ts +++ b/backend/src/__tests__/DatabaseService.autoHeal.test.ts @@ -40,6 +40,18 @@ afterAll(() => { cleanupTestDb(tmpDir); }); +/** Insert a second local node via raw SQL, bypassing the addNode singleton guard. */ +function insertLegacyLocal(name: string, isDefault = false): number { + const rawDb = db.getDb(); + if (isDefault) { + rawDb.prepare('UPDATE nodes SET is_default = 0').run(); + } + const result = rawDb.prepare( + "INSERT INTO nodes (name, type, compose_dir, is_default, status, created_at) VALUES (?, 'local', ?, ?, 'online', ?)" + ).run(name, process.env.COMPOSE_DIR ?? '', isDefault ? 1 : 0, Date.now()); + return result.lastInsertRowid as number; +} + describe('DatabaseService - auto-heal policy CRUD', () => { it('addAutoHealPolicy + getAutoHealPolicy round-trip preserves all fields', () => { const input = makePolicy({ @@ -111,14 +123,7 @@ describe('DatabaseService - auto-heal policy CRUD', () => { it('auto-heal node migration does not rewrite already-scoped node 1 policies', () => { const created = db.addAutoHealPolicy(makePolicy({ stack_name: 'migration-node-one', node_id: 1 })); - db.addNode({ - name: 'new-default-node', - type: 'local', - compose_dir: process.env.COMPOSE_DIR ?? '', - is_default: true, - api_url: '', - api_token: '', - }); + insertLegacyLocal('new-default-node', true); (db as any).migrateAutoHealNodeId(); @@ -128,14 +133,7 @@ describe('DatabaseService - auto-heal policy CRUD', () => { it('auto-heal node migration resumes backfill when the completion marker is missing', () => { db.updateGlobalSetting('migration_auto_heal_node_scope_v1', ''); const created = db.addAutoHealPolicy(makePolicy({ stack_name: 'migration-partial', node_id: 1 })); - const newDefaultId = db.addNode({ - name: 'partial-new-default-node', - type: 'local', - compose_dir: process.env.COMPOSE_DIR ?? '', - is_default: true, - api_url: '', - api_token: '', - }); + const newDefaultId = insertLegacyLocal('partial-new-default-node', true); (db as any).migrateAutoHealNodeId(); diff --git a/backend/src/__tests__/auto-heal-routes.test.ts b/backend/src/__tests__/auto-heal-routes.test.ts index bfd9b96f..962290a0 100644 --- a/backend/src/__tests__/auto-heal-routes.test.ts +++ b/backend/src/__tests__/auto-heal-routes.test.ts @@ -76,6 +76,18 @@ afterAll(() => { cleanupTestDb(tmpDir); }); +/** Insert a second local node via raw SQL, bypassing the addNode singleton guard. */ +function insertLegacyLocal(name: string, isDefault = false): number { + const db = DatabaseService.getInstance().getDb(); + if (isDefault) { + db.prepare('UPDATE nodes SET is_default = 0').run(); + } + const result = db.prepare( + "INSERT INTO nodes (name, type, compose_dir, is_default, status, created_at) VALUES (?, 'local', ?, ?, 'online', ?)" + ).run(name, process.env.COMPOSE_DIR ?? '', isDefault ? 1 : 0, Date.now()); + return result.lastInsertRowid as number; +} + describe('/api/auto-heal routes', () => { it('allows Community tier access', async () => { vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community'); @@ -135,14 +147,7 @@ describe('/api/auto-heal routes', () => { it('lists only policies for the active node', async () => { const defaultNodeId = DatabaseService.getInstance().getDefaultNode()?.id ?? 1; - const secondNodeId = DatabaseService.getInstance().addNode({ - name: 'route-second-local', - type: 'local', - compose_dir: process.env.COMPOSE_DIR ?? '', - is_default: false, - api_url: '', - api_token: '', - }); + const secondNodeId = insertLegacyLocal('route-second-local'); makePolicy(defaultNodeId, 'same-stack'); makePolicy(secondNodeId, 'same-stack'); @@ -163,14 +168,7 @@ describe('/api/auto-heal routes', () => { }); it('rejects history access for a policy owned by a different node', async () => { - const secondNodeId = DatabaseService.getInstance().addNode({ - name: 'history-second-local', - type: 'local', - compose_dir: process.env.COMPOSE_DIR ?? '', - is_default: false, - api_url: '', - api_token: '', - }); + const secondNodeId = insertLegacyLocal('history-second-local'); const policy = makePolicy(secondNodeId, 'history-stack'); const res = await request(app) diff --git a/backend/src/__tests__/nodes.test.ts b/backend/src/__tests__/nodes.test.ts index 25ece4c4..d85603b3 100644 --- a/backend/src/__tests__/nodes.test.ts +++ b/backend/src/__tests__/nodes.test.ts @@ -285,3 +285,298 @@ describe('DELETE /api/nodes/:id default-node guard (M-4)', () => { expect(res.body.error).toMatch(/default node/i); }); }); + +// ---- helpers for singleton tests ---- + +function getLocalNodeId(): number { + return DatabaseService.getInstance().getNodes().find(n => n.type === 'local')!.id; +} + +async function makeRemoteDefault(token: string): Promise<{ remoteId: number; originalDefaultId: number }> { + const list = await request(app).get('/api/nodes').set('Authorization', token); + const originalDefault = (list.body as Array<{ id: number; is_default: boolean }>).find(n => n.is_default)!; + const remoteId = await createRemoteNode(token); + await request(app) + .put(`/api/nodes/${remoteId}`) + .set('Authorization', token) + .send({ is_default: true }); + return { remoteId, originalDefaultId: originalDefault.id }; +} + +async function restoreDefault(token: string, defaultId: number): Promise { + await request(app) + .put(`/api/nodes/${defaultId}`) + .set('Authorization', token) + .send({ is_default: true }); +} + +/** Insert a second local node via raw SQL, bypassing the addNode singleton guard. */ +function insertLegacyLocal(name: string, isDefault = false): number { + const db = DatabaseService.getInstance().getDb(); + if (isDefault) { + db.prepare('UPDATE nodes SET is_default = 0').run(); + } + const result = db.prepare( + "INSERT INTO nodes (name, type, compose_dir, is_default, status, created_at) VALUES (?, 'local', ?, ?, 'online', ?)" + ).run(name, process.env.COMPOSE_DIR ?? '', isDefault ? 1 : 0, Date.now()); + return result.lastInsertRowid as number; +} + +// ---- singleton enforcement (HTTP layer) ---- + +describe('Local node singleton enforcement', () => { + it('POST rejects a second local node with 409', async () => { + const res = await request(app) + .post('/api/nodes') + .set('Authorization', authHeader) + .send({ name: 'second-local', type: 'local', compose_dir: '/app/compose' }); + expect(res.status).toBe(409); + expect(res.body.error).toMatch(/a local node already exists/i); + }); + + it('POST local with is_default:true does not clear the existing default when rejected', async () => { + const listBefore = await request(app).get('/api/nodes').set('Authorization', authHeader); + const defBefore = (listBefore.body as Array<{ id: number; is_default: boolean }>).find(n => n.is_default)!; + + const res = await request(app) + .post('/api/nodes') + .set('Authorization', authHeader) + .send({ name: 'rejected-local', type: 'local', is_default: true, compose_dir: '/app/compose' }); + expect(res.status).toBe(409); + + const listAfter = await request(app).get('/api/nodes').set('Authorization', authHeader); + const defAfter = (listAfter.body as Array<{ id: number; is_default: boolean }>).find(n => n.is_default)!; + expect(defAfter.id).toBe(defBefore.id); + }); + + it('PUT rejects type change from local to remote with 400', async () => { + const localId = getLocalNodeId(); + const res = await request(app) + .put(`/api/nodes/${localId}`) + .set('Authorization', authHeader) + .send({ type: 'remote' }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/cannot be changed/i); + }); + + it('PUT rejects type change from remote to local with 400', async () => { + const remoteId = await createRemoteNode(authHeader); + const res = await request(app) + .put(`/api/nodes/${remoteId}`) + .set('Authorization', authHeader) + .send({ type: 'local' }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/cannot be changed/i); + }); + + it('PUT rejects invalid type value with 400', async () => { + const localId = getLocalNodeId(); + const res = await request(app) + .put(`/api/nodes/${localId}`) + .set('Authorization', authHeader) + .send({ type: 'invalid' }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/must be "local" or "remote"/i); + }); + + it('PUT allows renaming the local node', async () => { + const localId = getLocalNodeId(); + const originalName = DatabaseService.getInstance().getNode(localId)!.name; + try { + const res = await request(app) + .put(`/api/nodes/${localId}`) + .set('Authorization', authHeader) + .send({ name: 'Renamed Local' }); + expect(res.status).toBe(200); + expect(DatabaseService.getInstance().getNode(localId)!.name).toBe('Renamed Local'); + } finally { + DatabaseService.getInstance().updateNode(localId, { name: originalName }); + } + }); + + it('PUT allows changing compose_dir on the local node', async () => { + const localId = getLocalNodeId(); + const originalDir = DatabaseService.getInstance().getNode(localId)!.compose_dir; + try { + const res = await request(app) + .put(`/api/nodes/${localId}`) + .set('Authorization', authHeader) + .send({ compose_dir: '/tmp/test-compose' }); + expect(res.status).toBe(200); + expect(DatabaseService.getInstance().getNode(localId)!.compose_dir).toBe('/tmp/test-compose'); + } finally { + DatabaseService.getInstance().updateNode(localId, { compose_dir: originalDir }); + } + }); + + it('DELETE rejects the last local node with 400', async () => { + const { remoteId, originalDefaultId } = await makeRemoteDefault(authHeader); + try { + const res = await request(app) + .delete(`/api/nodes/${originalDefaultId}`) + .set('Authorization', authHeader); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/only local node/i); + } finally { + await restoreDefault(authHeader, originalDefaultId); + const db = DatabaseService.getInstance(); + if (db.getNode(remoteId)) db.deleteNode(remoteId); + } + }); + + it('DELETE allows removing an extra local when more than one exists', async () => { + const extraId = insertLegacyLocal('legacy-extra-local'); + try { + const remoteId = await createRemoteNode(authHeader); + await request(app) + .put(`/api/nodes/${remoteId}`) + .set('Authorization', authHeader) + .send({ is_default: true }); + try { + const res = await request(app) + .delete(`/api/nodes/${extraId}`) + .set('Authorization', authHeader); + expect(res.status).toBe(200); + expect(DatabaseService.getInstance().getLocalNodeCount()).toBe(1); + } finally { + await restoreDefault(authHeader, getLocalNodeId()); + const db = DatabaseService.getInstance(); + if (db.getNode(remoteId)) db.deleteNode(remoteId); + } + } finally { + const db = DatabaseService.getInstance(); + if (db.getNode(extraId)) db.deleteNode(extraId); + } + }); +}); + +// ---- DatabaseService direct enforcement ---- + +describe('DatabaseService direct local-node enforcement', () => { + it('addNode throws when a second local is inserted directly', () => { + const db = DatabaseService.getInstance(); + expect(() => db.addNode({ + name: 'direct-second-local', + type: 'local', + compose_dir: '/app/compose', + is_default: false, + api_url: '', + api_token: '', + })).toThrow(/a local node already exists/i); + }); + + it('addNode with is_default:true does not clear existing default when it throws', () => { + const db = DatabaseService.getInstance(); + const defaultBefore = db.getDefaultNode()!.id; + expect(() => db.addNode({ + name: 'direct-rejected-local', + type: 'local', + compose_dir: '/app/compose', + is_default: true, + api_url: '', + api_token: '', + })).toThrow(/a local node already exists/i); + expect(db.getDefaultNode()!.id).toBe(defaultBefore); + }); + + it('updateNode throws when type is changed', () => { + const db = DatabaseService.getInstance(); + const localId = getLocalNodeId(); + expect(() => db.updateNode(localId, { type: 'remote' as any })).toThrow(/cannot be changed/i); + }); + + it('deleteNode throws when the last local is deleted directly', () => { + const db = DatabaseService.getInstance(); + const localId = getLocalNodeId(); + const remoteId = db.addNode({ + name: `direct-remote-${Date.now()}`, + type: 'remote', + mode: 'proxy', + compose_dir: '/app/compose', + is_default: true, + api_url: 'http://192.168.1.77:1852', + api_token: 'tok', + }); + try { + expect(() => db.deleteNode(localId)).toThrow(/only local node/i); + } finally { + db.updateNode(localId, { is_default: true }); + db.deleteNode(remoteId); + } + }); + + it('addNode auto-assigns is_default when creating a local during zero-local recovery', () => { + const db = DatabaseService.getInstance(); + const localId = getLocalNodeId(); + const originalType = db.getNode(localId)!.type; + // Temporarily remove the only local by flipping its type, simulating a + // legacy DB with remotes only. + db.getDb().prepare("UPDATE nodes SET type = 'remote' WHERE id = ?").run(localId); + let newId: number | undefined; + try { + newId = db.addNode({ + name: 'recovery-local', + type: 'local', + compose_dir: '/app/compose', + is_default: false, + api_url: '', + api_token: '', + }); + expect(db.getNode(newId)!.is_default).toBe(true); + } finally { + // Restore the original local identity. The recovery node was made + // default; re-assign to the original before cleanup. + db.getDb().prepare("UPDATE nodes SET type = ? WHERE id = ?").run(originalType, localId); + if (newId !== undefined && db.getNode(newId)) { + db.updateNode(localId, { is_default: true }); + db.deleteNode(newId); + } + } + }); +}); + +// ---- startup warnings ---- + +describe('logLocalNodeWarnings', () => { + it('warns when there are zero local nodes', () => { + const db = DatabaseService.getInstance(); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const localId = getLocalNodeId(); + const originalType = db.getNode(localId)!.type; + db.getDb().prepare("UPDATE nodes SET type = 'remote' WHERE id = ?").run(localId); + try { + db.logLocalNodeWarnings(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('No local node found')); + } finally { + db.getDb().prepare('UPDATE nodes SET type = ? WHERE id = ?').run(originalType, localId); + } + } finally { + warnSpy.mockRestore(); + } + }); + + it('warns when there are multiple local nodes', () => { + const db = DatabaseService.getInstance(); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const extraId = insertLegacyLocal('warn-extra-local'); + try { + db.logLocalNodeWarnings(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Found 2 local nodes')); + } finally { + warnSpy.mockRestore(); + if (db.getNode(extraId)) db.deleteNode(extraId); + } + }); + + it('does not warn when exactly one local node exists', () => { + const db = DatabaseService.getInstance(); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + db.logLocalNodeWarnings(); + expect(warnSpy).not.toHaveBeenCalled(); + } finally { + warnSpy.mockRestore(); + } + }); +}); diff --git a/backend/src/routes/nodes.ts b/backend/src/routes/nodes.ts index bf0cb95a..eabc54bf 100644 --- a/backend/src/routes/nodes.ts +++ b/backend/src/routes/nodes.ts @@ -245,6 +245,9 @@ nodesRouter.post('/', enrollmentLimiter, async (req: Request, res: Response) => }); } catch (error: unknown) { const message = error instanceof Error ? error.message : ''; + if (message.includes('A local node already exists')) { + return res.status(409).json({ error: message }); + } if (message.includes('UNIQUE constraint')) { return res.status(409).json({ error: 'A node with that name already exists' }); } @@ -308,6 +311,10 @@ nodesRouter.put('/:id', async (req: Request, res: Response) => { updates.compose_dir = composeDir; } + if (updates.type !== undefined && !['local', 'remote'].includes(updates.type)) { + return res.status(400).json({ error: 'Node type must be "local" or "remote"' }); + } + if (updates.api_url !== undefined && updates.api_url !== '') { const urlCheck = isValidRemoteUrl(updates.api_url); if (!urlCheck.valid) { @@ -348,9 +355,12 @@ nodesRouter.put('/:id', async (req: Request, res: Response) => { }) }); } catch (error: unknown) { + const message = error instanceof Error ? error.message : ''; + if (message.includes('Node type cannot be changed')) { + return res.status(400).json({ error: message }); + } console.error('Failed to update node:', error); - const message = error instanceof Error ? error.message : 'Failed to update node'; - res.status(500).json({ error: message }); + res.status(500).json({ error: message || 'Failed to update node' }); } }); @@ -384,8 +394,12 @@ nodesRouter.delete('/:id', async (req: Request, res: Response) => { console.log(`[Nodes] Deleted node ${id} ("${sanitizeForLog(existing.name)}")`); res.json({ success: true }); } catch (error: unknown) { + const message = error instanceof Error ? error.message : ''; + if (message.includes('Cannot delete the only local node')) { + return res.status(400).json({ error: message }); + } console.error('Failed to delete node:', error); - res.status(500).json({ error: error instanceof Error ? error.message : 'Failed to delete node' }); + res.status(500).json({ error: message || 'Failed to delete node' }); } }); diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 07e2f0b5..9a61ae9b 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -1717,6 +1717,36 @@ export class DatabaseService { 'INSERT INTO nodes (name, type, compose_dir, is_default, status, created_at) VALUES (?, ?, ?, ?, ?, ?)' ).run('Local', 'local', process.env.COMPOSE_DIR || '/app/compose', 1, 'online', Date.now()); } + + this.logLocalNodeWarnings(); + } + + /** Count nodes with type='local'. */ + public getLocalNodeCount(): number { + const row = this.db.prepare("SELECT COUNT(*) as count FROM nodes WHERE type = 'local'").get() as { count: number }; + return row.count; + } + + /** + * Log warnings when the local-node count is not exactly one. Extracted as a + * public method so tests can drive it against known database states without + * re-running the full schema migration. + */ + public logLocalNodeWarnings(): void { + const count = this.getLocalNodeCount(); + if (count > 1) { + console.warn( + `[Startup] Found ${count} local nodes (expected 1). ` + + 'Extra local nodes can be removed in Settings → Nodes. ' + + 'Deleting a local node removes its schedules, labels, dossiers, ' + + 'and other node-scoped data; containers on the host are not affected.' + ); + } else if (count === 0) { + console.warn( + '[Startup] No local node found. ' + + 'Create one in Settings → Nodes to manage this instance\'s Docker engine.' + ); + } } private migrateAdminToUsersTable(): void { @@ -3342,24 +3372,38 @@ export class DatabaseService { } public addNode(node: Omit & { mode?: NodeMode }): number { - if (node.is_default) { - this.db.prepare('UPDATE nodes SET is_default = 0').run(); + const isLocal = node.type === 'local'; + // Guard against duplicate local nodes before any mutation so a throw + // cannot leave the table in a broken state (e.g. default cleared). + if (isLocal && this.getLocalNodeCount() > 0) { + throw new Error('A local node already exists. Only one local node is allowed per instance.'); } - const crypto = CryptoService.getInstance(); - const stmt = this.db.prepare( - 'INSERT INTO nodes (name, type, compose_dir, is_default, status, created_at, api_url, api_token, mode) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)' - ); - const result = stmt.run( - node.name, - node.type, - node.compose_dir || '/app/compose', - node.is_default ? 1 : 0, - 'unknown', - Date.now(), - node.api_url || '', - node.api_token ? crypto.encrypt(node.api_token) : '', - node.mode || 'proxy' - ); + + // When creating a local node and none exists (zero-local recovery), + // make it the default so documentation remains accurate. + const shouldBeDefault = node.is_default || isLocal; + + const runInsert = () => { + if (shouldBeDefault) { + this.db.prepare('UPDATE nodes SET is_default = 0').run(); + } + const crypto = CryptoService.getInstance(); + const stmt = this.db.prepare( + 'INSERT INTO nodes (name, type, compose_dir, is_default, status, created_at, api_url, api_token, mode) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)' + ); + return stmt.run( + node.name, + node.type, + node.compose_dir || '/app/compose', + shouldBeDefault ? 1 : 0, + 'unknown', + Date.now(), + node.api_url || '', + node.api_token ? crypto.encrypt(node.api_token) : '', + node.mode || 'proxy' + ); + }; + const result = this.db.transaction(runInsert)(); return result.lastInsertRowid as number; } @@ -3367,6 +3411,10 @@ export class DatabaseService { const node = this.getNode(id); if (!node) throw new Error(`Node with id ${id} not found`); + if (updates.type !== undefined && updates.type !== node.type) { + throw new Error('Node type cannot be changed after creation.'); + } + if (updates.is_default) { this.db.prepare('UPDATE nodes SET is_default = 0').run(); } @@ -3396,6 +3444,10 @@ export class DatabaseService { public deleteNode(id: number): void { const node = this.getNode(id); + // Protect the last local node regardless of is_default flag. + if (node && node.type === 'local' && this.getLocalNodeCount() <= 1) { + throw new Error('Cannot delete the only local node. Each Sencho instance must retain its local identity.'); + } if (node?.is_default) { throw new Error('Cannot delete the default node'); } diff --git a/docs/features/multi-node.mdx b/docs/features/multi-node.mdx index 34889f83..9da28e69 100644 --- a/docs/features/multi-node.mdx +++ b/docs/features/multi-node.mdx @@ -21,7 +21,9 @@ Remote nodes connect in one of two modes. Pick the one that matches your network ## The local node -Your control Sencho instance is always listed as **Local**. It is the default node, marked with a star icon, and cannot be deleted. All operations on the local node run directly against the host's Docker socket. +Only one local node can exist per Sencho instance. The local node cannot be deleted because each instance must retain its own Docker engine identity. All operations on the local node run directly against the host's Docker socket. + +If multiple local nodes exist from an older version, the extra rows show a Delete action so you can clean them up. Deleting a local node removes its schedules, labels, dossiers, findings, and other node-scoped data; containers and compose files on the host are not affected. The last remaining local row hides Delete because it cannot be removed. ## Choose a remote mode @@ -172,7 +174,7 @@ The Nodes table surfaces routing, status, and per-node automation at a glance fo | **Labels** | Per-node label palette. The cell shows the label picker; an empty cell reads `No labels` with an Add label control. | | **Schedules** | Number of active scheduled tasks targeting this node, plus a `next X` countdown to the next run. Click the count or the calendar icon in the Actions column to filter the Schedules view to that node. | | **Updates** | `Auto` if at least one enabled `Auto-update Stack` or `Auto-update All Stacks on Node` schedule targets the node; `Off` otherwise. A pulsing dot and count appear when stacks have pending image updates. | -| **Actions** | **View Schedules**, **Test Connection**, **Edit Node**, and **Delete Node** icon buttons. The local row hides Delete because the local node cannot be removed. | +| **Actions** | **View Schedules**, **Test Connection**, **Edit Node**, and **Delete Node** icon buttons. The last local row hides Delete because the local node cannot be removed. When multiple local nodes exist from an older version, the extra rows show Delete so you can clean them up. | Clicking the schedules-link icon opens the Schedules view filtered to the selected node. From there you can create, edit, or manage scheduled tasks scoped to that node. The filter bar shows which node you are viewing, with a Clear filter control to return to the full list. @@ -218,7 +220,7 @@ This means: Click the pencil icon on any row to edit its name, URL, token, or compose directory. The API Token field opens blank for security: leave it blank to keep the current token, or paste a new one to rotate it. For a Pilot Agent row, the Edit modal also surfaces the **Regenerate enrollment token** card described earlier. -Click the trash icon to remove a remote node. The local row hides this icon because the default node cannot be deleted. Removing a node only deletes the routing entry on the control instance; the remote Sencho instance and its containers are not touched. +Click the trash icon to remove a remote node. The local row hides this icon when it is the only local node. When multiple local nodes exist from an older version, the extra rows expose Delete so you can clean them up. Deleting a local node removes its schedules, labels, dossiers, findings, and other node-scoped data; containers and compose files on the host are not affected. ## Security diff --git a/docs/openapi.yaml b/docs/openapi.yaml index ee280f22..adcc88e0 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -2274,9 +2274,11 @@ paths: tags: [Nodes] summary: Register a new node description: | - Adds a new local or remote node. Remote nodes require an API URL pointing to - another Sencho instance and an API token for authentication. - Requires `node:manage` permission. + Adds a new local or remote node. Only one local node is allowed per instance; + attempting to create a second local node returns 409. Remote nodes require + an API URL pointing to another Sencho instance and an API token for + authentication. + Requires `node:manage` permission. Node type is immutable after creation. **Note:** API tokens cannot manage nodes. requestBody: @@ -2332,7 +2334,7 @@ paths: "403": $ref: "#/components/responses/Forbidden" "409": - description: Node name already exists. + description: Node name already exists, or a local node already exists. content: application/json: schema: @@ -2362,7 +2364,7 @@ paths: operationId: updateNode tags: [Nodes] summary: Update node - description: Updates node configuration. Requires `node:manage` permission. + description: Updates node configuration. Node type is immutable after creation. Requires `node:manage` permission. parameters: - $ref: "#/components/parameters/idPath" requestBody: @@ -2409,7 +2411,7 @@ paths: operationId: deleteNode tags: [Nodes] summary: Delete node - description: Removes a node from the fleet. Requires `node:manage` permission. + description: Removes a node from the fleet. The last local node cannot be deleted. Requires `node:manage` permission. parameters: - $ref: "#/components/parameters/idPath" responses: @@ -2419,6 +2421,12 @@ paths: application/json: schema: $ref: "#/components/schemas/SuccessBoolean" + "400": + description: Cannot delete the only local node (or the default node). + content: + application/json: + schema: + $ref: "#/components/schemas/Error" "403": $ref: "#/components/responses/Forbidden" "500": diff --git a/frontend/src/components/FleetView/NodeCard.tsx b/frontend/src/components/FleetView/NodeCard.tsx index 8484c837..6e16409c 100644 --- a/frontend/src/components/FleetView/NodeCard.tsx +++ b/frontend/src/components/FleetView/NodeCard.tsx @@ -22,6 +22,7 @@ import { apiFetch } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; import { formatVersion } from '@/lib/version'; import { useAuth } from '@/context/AuthContext'; +import { useLicense } from '@/context/LicenseContext'; import { useNodes, type Node } from '@/context/NodeContext'; import { cordonNode, uncordonNode } from '@/lib/nodesApi'; import { UpdateStatusBadge } from './UpdateStatusBadge'; @@ -71,12 +72,15 @@ export function NodeCard({ node, onNavigate, labelMap, updateStatus, onUpdate, u const [cordonSubmitting, setCordonSubmitting] = useState(false); const { isAdmin, can } = useAuth(); + const { isPaid } = useLicense(); const { nodes: registryNodes } = useNodes(); const registryNode = registryNodes.find(n => n.id === node.id); + const isLastLocal = registryNode?.type === 'local' && registryNodes.filter(n => n.type === 'local').length <= 1; const canEdit = Boolean(isAdmin && onEdit && registryNode); - const canDelete = Boolean(isAdmin && onDelete && registryNode && !registryNode.is_default); - // Cordon requires node:manage, matching the backend guard. - const canCordon = can('node:manage', 'node', String(node.id)); + const canDelete = Boolean(isAdmin && onDelete && registryNode && !registryNode.is_default && !isLastLocal); + // Cordon requires the paid tier AND node:manage, matching the backend guard + // (requirePermission('node:manage','node',id) + requirePaid). + const canCordon = isPaid && can('node:manage', 'node', String(node.id)); const nodeMuteActions = useNodeMuteActions( node.id, node.name, diff --git a/frontend/src/components/NodeManager.tsx b/frontend/src/components/NodeManager.tsx index 6ec431b8..70be4410 100644 --- a/frontend/src/components/NodeManager.tsx +++ b/frontend/src/components/NodeManager.tsx @@ -288,7 +288,7 @@ export function NodeManager() { {resettingAnchor === nodeId ? 'Resetting...' : 'Reset anchor on peer'} )} - {node && !node.is_default && (isAdmin || can('node:manage', 'node', String(nodeId))) && ( + {node && !node.is_default && nodes.filter(n => n.type === 'local').length > 1 && (isAdmin || can('node:manage', 'node', String(nodeId))) && (