diff --git a/backend/src/__tests__/audit-log.test.ts b/backend/src/__tests__/audit-log.test.ts index 2b9128d3..84921872 100644 --- a/backend/src/__tests__/audit-log.test.ts +++ b/backend/src/__tests__/audit-log.test.ts @@ -57,7 +57,7 @@ describe('getAuditSummary()', () => { }); it('resolves wildcard match: POST /stacks/mystack/down', () => { - expect(getAuditSummary('POST', '/stacks/mystack/down')).toBe('Stopped stack: mystack'); + expect(getAuditSummary('POST', '/stacks/mystack/down')).toBe('Took stack down: mystack'); }); it('resolves wildcard match: POST /stacks/mystack/rollback', () => { diff --git a/backend/src/__tests__/compose-service.test.ts b/backend/src/__tests__/compose-service.test.ts index ac8a029d..3ff706f8 100644 --- a/backend/src/__tests__/compose-service.test.ts +++ b/backend/src/__tests__/compose-service.test.ts @@ -1093,6 +1093,49 @@ describe('ComposeService - withRegistryAuth', () => { // ── downStack ────────────────────────────────────────────────────────── +describe('ComposeService - runDown', () => { + it('runs plain docker compose down by default', async () => { + setupAutoCloseSpawn(); + + const svc = ComposeService.getInstance(1); + await svc.runDown('my-stack'); + + expect(mockSpawn).toHaveBeenCalledWith( + 'docker', + ['compose', 'down'], + expect.any(Object) + ); + }); + + it('runs docker compose down --volumes when removeVolumes is true', async () => { + setupAutoCloseSpawn(); + + const svc = ComposeService.getInstance(1); + await svc.runDown('my-stack', { removeVolumes: true }); + + expect(mockSpawn).toHaveBeenCalledWith( + 'docker', + ['compose', 'down', '--volumes'], + expect.any(Object) + ); + }); + + it('does not pass --volumes when removeVolumes is false', async () => { + setupAutoCloseSpawn(); + + const svc = ComposeService.getInstance(1); + await svc.runDown('my-stack', { removeVolumes: false }); + + expect(mockSpawn).toHaveBeenCalledWith( + 'docker', + ['compose', 'down'], + expect.any(Object) + ); + }); +}); + +// ── downStack ────────────────────────────────────────────────────────── + describe('ComposeService - downStack', () => { it('runs docker compose down with volumes and remove-orphans', async () => { setupAutoCloseSpawn(); diff --git a/backend/src/__tests__/proxy-stack-down-volume-gate.test.ts b/backend/src/__tests__/proxy-stack-down-volume-gate.test.ts new file mode 100644 index 00000000..75a8cfa0 --- /dev/null +++ b/backend/src/__tests__/proxy-stack-down-volume-gate.test.ts @@ -0,0 +1,122 @@ +/** + * Gateway preflight for POST /stacks/:name/down?removeVolumes=true on remote nodes. + * Unsupported remotes must return 400 before the proxy forwards the request. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import http from 'http'; +import bcrypt from 'bcrypt'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb'; + +let tmpDir: string; +let app: import('express').Express; +let viewerBearer: string; +let adminBearer: string; +let capServer: http.Server; +let noCapServer: http.Server; +let capNodeId: number; +let noCapNodeId: number; + +const noCapPaths: string[] = []; +const capPaths: string[] = []; + +function metaServer(capabilities: string[], seen: string[]): http.Server { + return http.createServer((req, res) => { + if (req.url) seen.push(req.url); + res.writeHead(200, { 'Content-Type': 'application/json' }); + if (req.url?.startsWith('/api/meta')) { + res.end(JSON.stringify({ version: '0.93.0', capabilities })); + } else { + res.end(JSON.stringify({ status: 'Command started' })); + } + }); +} + +async function listen(server: http.Server): Promise { + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + return (server.address() as import('net').AddressInfo).port; +} + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ app } = await import('../index')); + const { DatabaseService } = await import('../services/DatabaseService'); + const db = DatabaseService.getInstance(); + + const hash = await bcrypt.hash('password123', 1); + db.addUser({ username: 'vol-viewer', password_hash: hash, role: 'viewer' }); + const viewer = db.getUserByUsername('vol-viewer')!; + viewerBearer = jwt.sign({ username: 'vol-viewer', role: 'viewer', tv: viewer.token_version }, TEST_JWT_SECRET, { expiresIn: '1m' }); + adminBearer = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' }); + + capServer = metaServer(['cross-node-rbac', 'stack-down-remove-volumes'], capPaths); + noCapServer = metaServer(['cross-node-rbac'], noCapPaths); + const capPort = await listen(capServer); + const noCapPort = await listen(noCapServer); + + capNodeId = db.addNode({ + name: 'vol-cap-remote', type: 'remote', mode: 'proxy', compose_dir: '/tmp', + is_default: false, api_url: `http://127.0.0.1:${capPort}`, api_token: 'cap-token', + }); + noCapNodeId = db.addNode({ + name: 'vol-nocap-remote', type: 'remote', mode: 'proxy', compose_dir: '/tmp', + is_default: false, api_url: `http://127.0.0.1:${noCapPort}`, api_token: 'nocap-token', + }); +}); + +afterAll(async () => { + await new Promise((resolve) => capServer.close(() => resolve())); + await new Promise((resolve) => noCapServer.close(() => resolve())); + cleanupTestDb(tmpDir); +}); + +describe('remote proxy stack-down volume gate', () => { + it('returns 400 for removeVolumes=true when remote lacks stack-down-remove-volumes (admin)', async () => { + noCapPaths.length = 0; + const res = await request(app) + .post('/api/stacks/web/down?removeVolumes=true') + .set('Authorization', `Bearer ${adminBearer}`) + .set('x-node-id', String(noCapNodeId)); + + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/not supported/i); + expect(noCapPaths.some(p => p.includes('/api/stacks/web/down'))).toBe(false); + expect(noCapPaths.some(p => p.startsWith('/api/meta'))).toBe(true); + }); + + it('returns 400 for removeVolumes=true when remote lacks capability (non-admin)', async () => { + noCapPaths.length = 0; + const res = await request(app) + .post('/api/stacks/web/down?removeVolumes=true') + .set('Authorization', `Bearer ${viewerBearer}`) + .set('x-node-id', String(noCapNodeId)); + + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/not supported/i); + expect(noCapPaths.some(p => p.includes('/api/stacks/web/down'))).toBe(false); + }); + + it('proxies removeVolumes=true when remote advertises stack-down-remove-volumes', async () => { + capPaths.length = 0; + const res = await request(app) + .post('/api/stacks/web/down?removeVolumes=true') + .set('Authorization', `Bearer ${adminBearer}`) + .set('x-node-id', String(capNodeId)); + + expect(res.status).toBe(200); + expect(capPaths.some(p => p.includes('/api/stacks/web/down'))).toBe(true); + }); + + it('does not preflight plain down without removeVolumes', async () => { + noCapPaths.length = 0; + const res = await request(app) + .post('/api/stacks/web/down') + .set('Authorization', `Bearer ${adminBearer}`) + .set('x-node-id', String(noCapNodeId)); + + expect(res.status).toBe(200); + expect(noCapPaths.some(p => p.includes('/api/stacks/web/down'))).toBe(true); + expect(noCapPaths.some(p => p.startsWith('/api/meta'))).toBe(false); + }); +}); diff --git a/backend/src/__tests__/remote-capabilities.test.ts b/backend/src/__tests__/remote-capabilities.test.ts index f365dbd0..cad711ab 100644 --- a/backend/src/__tests__/remote-capabilities.test.ts +++ b/backend/src/__tests__/remote-capabilities.test.ts @@ -10,6 +10,7 @@ import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; import type { RemoteMeta } from '../services/CapabilityRegistry'; let remoteSupportsCrossNodeRbac: typeof import('../helpers/remoteCapabilities').remoteSupportsCrossNodeRbac; +let remoteAdvertisesCapability: typeof import('../helpers/remoteCapabilities').remoteAdvertisesCapability; let NodeRegistry: typeof import('../services/NodeRegistry').NodeRegistry; let tmpDir: string; @@ -17,7 +18,7 @@ const NODE_ID = 4242; beforeAll(async () => { tmpDir = await setupTestDb(); - ({ remoteSupportsCrossNodeRbac } = await import('../helpers/remoteCapabilities')); + ({ remoteSupportsCrossNodeRbac, remoteAdvertisesCapability } = await import('../helpers/remoteCapabilities')); ({ NodeRegistry } = await import('../services/NodeRegistry')); }); @@ -79,3 +80,23 @@ describe('remoteSupportsCrossNodeRbac', () => { expect(spy).toHaveBeenCalledTimes(1); }); }); + +describe('remoteAdvertisesCapability', () => { + it('returns true when the remote advertises the requested capability', async () => { + vi.spyOn(NodeRegistry.getInstance(), 'fetchMetaForNode') + .mockResolvedValue({ version: '0.93.0', capabilities: ['stack-down-remove-volumes'], ...ONLINE }); + expect(await remoteAdvertisesCapability(NODE_ID, 'stack-down-remove-volumes')).toBe(true); + }); + + it('dedupes concurrent probes per node+capability, not per node alone', async () => { + const spy = vi.spyOn(NodeRegistry.getInstance(), 'fetchMetaForNode') + .mockResolvedValue({ version: '0.93.0', capabilities: ['cross-node-rbac', 'stack-down-remove-volumes'], ...ONLINE }); + const [rbac, volumes] = await Promise.all([ + remoteAdvertisesCapability(NODE_ID, 'cross-node-rbac'), + remoteAdvertisesCapability(NODE_ID, 'stack-down-remove-volumes'), + ]); + expect(rbac).toBe(true); + expect(volumes).toBe(true); + expect(spy).toHaveBeenCalledTimes(2); + }); +}); diff --git a/backend/src/__tests__/stack-actions-missing-stack.test.ts b/backend/src/__tests__/stack-actions-missing-stack.test.ts index 46b8a8f8..04137a76 100644 --- a/backend/src/__tests__/stack-actions-missing-stack.test.ts +++ b/backend/src/__tests__/stack-actions-missing-stack.test.ts @@ -18,10 +18,12 @@ import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTes const { mockDeployStack, mockRunCommand, + mockRunDown, mockUpdateStack, } = vi.hoisted(() => ({ mockDeployStack: vi.fn(), mockRunCommand: vi.fn(), + mockRunDown: vi.fn(), mockUpdateStack: vi.fn(), })); @@ -36,6 +38,7 @@ vi.mock('../services/ComposeService', async () => { getInstance: () => ({ deployStack: mockDeployStack, runCommand: mockRunCommand, + runDown: mockRunDown, updateStack: mockUpdateStack, }), }, @@ -72,8 +75,8 @@ describe('POST /api/stacks/:stackName/deploy on a nonexistent stack', () => { }); describe('POST /api/stacks/:stackName/down on a nonexistent stack', () => { - it('returns 404 with "Stack not found" and never enters ComposeService.runCommand', async () => { - mockRunCommand.mockClear(); + it('returns 404 with "Stack not found" and never enters ComposeService.runDown', async () => { + mockRunDown.mockClear(); const res = await request(app) .post('/api/stacks/does-not-exist-f7/down') @@ -81,7 +84,7 @@ describe('POST /api/stacks/:stackName/down on a nonexistent stack', () => { expect(res.status).toBe(404); expect(res.body).toEqual({ error: 'Stack not found' }); - expect(mockRunCommand).not.toHaveBeenCalled(); + expect(mockRunDown).not.toHaveBeenCalled(); }); }); diff --git a/backend/src/__tests__/stack-docker-disconnect.test.ts b/backend/src/__tests__/stack-docker-disconnect.test.ts index 4a33625d..90dfcf8f 100644 --- a/backend/src/__tests__/stack-docker-disconnect.test.ts +++ b/backend/src/__tests__/stack-docker-disconnect.test.ts @@ -18,6 +18,7 @@ import { isDockerUnavailableError } from '../routes/stacks'; const { mockDeployStack, mockRunCommand, + mockRunDown, mockUpdateStack, mockGetContainersByStack, mockRestartContainer, @@ -26,6 +27,7 @@ const { } = vi.hoisted(() => ({ mockDeployStack: vi.fn(), mockRunCommand: vi.fn(), + mockRunDown: vi.fn(), mockUpdateStack: vi.fn(), mockGetContainersByStack: vi.fn(), mockRestartContainer: vi.fn(), @@ -44,6 +46,7 @@ vi.mock('../services/ComposeService', async () => { getInstance: () => ({ deployStack: mockDeployStack, runCommand: mockRunCommand, + runDown: mockRunDown, updateStack: mockUpdateStack, }), }, @@ -98,6 +101,7 @@ afterAll(() => { beforeEach(() => { mockDeployStack.mockReset(); mockRunCommand.mockReset(); + mockRunDown.mockReset(); mockUpdateStack.mockReset(); mockGetContainersByStack.mockReset(); mockRestartContainer.mockReset(); @@ -211,8 +215,8 @@ describe('POST /api/stacks/:name/deploy with daemon down', () => { }); describe('POST /api/stacks/:name/down with daemon down', () => { - it('returns 503 with code: docker_unavailable when runCommand surfaces the daemon error', async () => { - mockRunCommand.mockRejectedValue(composeDaemonDownError()); + it('returns 503 with code: docker_unavailable when runDown surfaces the daemon error', async () => { + mockRunDown.mockRejectedValue(composeDaemonDownError()); const res = await request(app) .post('/api/stacks/web/down') diff --git a/backend/src/__tests__/stack-down-remove-volumes.test.ts b/backend/src/__tests__/stack-down-remove-volumes.test.ts new file mode 100644 index 00000000..e390a3d5 --- /dev/null +++ b/backend/src/__tests__/stack-down-remove-volumes.test.ts @@ -0,0 +1,181 @@ +/** + * Route tests for POST /api/stacks/:name/down and optional ?removeVolumes=true. + */ +import fs from 'fs'; +import path from 'path'; +import crypto from 'crypto'; +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import request from 'supertest'; +import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb'; +import { generateApiToken } from '../utils/apiTokenFormat'; +import { + disableCapability, + enableCapability, + STACK_DOWN_REMOVE_VOLUMES_CAPABILITY, +} from '../services/CapabilityRegistry'; + +const { mockRunDown } = vi.hoisted(() => ({ + mockRunDown: vi.fn(), +})); + +vi.mock('../services/ComposeService', async () => { + const actual = await vi.importActual( + '../services/ComposeService', + ); + return { + ...actual, + ComposeService: { + ...actual.ComposeService, + getInstance: () => ({ + runDown: mockRunDown, + }), + }, + }; +}); + +let tmpDir: string; +let app: import('express').Express; +let authCookie: string; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; +let dispatchAlertSpy: ReturnType; + +function writeStack(name: string) { + const dir = path.join(process.env.COMPOSE_DIR!, name); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'compose.yaml'), 'services:\n web:\n image: nginx\n'); +} + +function createDeployOnlyToken(): string { + const rawToken = generateApiToken(); + const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex'); + const db = DatabaseService.getInstance(); + db.addApiToken({ + token_hash: tokenHash, + name: `deploy-only-down-${Date.now()}`, + scope: 'deploy-only', + user_id: db.getUserByUsername('testadmin')!.id, + created_at: Date.now(), + expires_at: null, + }); + return rawToken; +} + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ DatabaseService } = await import('../services/DatabaseService')); + ({ app } = await import('../index')); + authCookie = await loginAsTestAdmin(app); + writeStack('web'); + + const { NotificationService } = await import('../services/NotificationService'); + dispatchAlertSpy = vi.spyOn(NotificationService.getInstance(), 'dispatchAlert').mockResolvedValue(undefined); +}); + +afterAll(() => { + vi.restoreAllMocks(); + enableCapability(STACK_DOWN_REMOVE_VOLUMES_CAPABILITY); + cleanupTestDb(tmpDir); +}); + +beforeEach(async () => { + mockRunDown.mockReset(); + mockRunDown.mockResolvedValue(undefined); + enableCapability(STACK_DOWN_REMOVE_VOLUMES_CAPABILITY); + const { StackOpLockService } = await import('../services/StackOpLockService'); + StackOpLockService.resetForTests(); +}); + +describe('POST /api/stacks/:name/down removeVolumes', () => { + it('runs plain down when removeVolumes is omitted', async () => { + const res = await request(app) + .post('/api/stacks/web/down') + .set('Cookie', authCookie); + + expect(res.status).toBe(200); + expect(mockRunDown).toHaveBeenCalledWith('web', { removeVolumes: false }, undefined); + }); + + it('dispatches stack_taken_down activity on plain down success', async () => { + dispatchAlertSpy.mockClear(); + mockRunDown.mockResolvedValue(undefined); + + const res = await request(app) + .post('/api/stacks/web/down') + .set('Cookie', authCookie); + + expect(res.status).toBe(200); + expect(dispatchAlertSpy).toHaveBeenCalledWith( + 'info', + 'stack_taken_down', + 'web taken down', + expect.objectContaining({ stackName: 'web', actor: 'testadmin' }), + ); + }); + + it('notes volume removal in the activity message when removeVolumes=true', async () => { + dispatchAlertSpy.mockClear(); + mockRunDown.mockResolvedValue(undefined); + + const res = await request(app) + .post('/api/stacks/web/down?removeVolumes=true') + .set('Cookie', authCookie); + + expect(res.status).toBe(200); + expect(dispatchAlertSpy).toHaveBeenCalledWith( + 'info', + 'stack_taken_down', + 'web taken down (volumes removed)', + expect.objectContaining({ stackName: 'web' }), + ); + }); + + it('runs plain down when removeVolumes=false', async () => { + const res = await request(app) + .post('/api/stacks/web/down?removeVolumes=false') + .set('Cookie', authCookie); + + expect(res.status).toBe(200); + expect(mockRunDown).toHaveBeenCalledWith('web', { removeVolumes: false }, undefined); + }); + + it('does not enable volumes when removeVolumes=1 (only exact true)', async () => { + const res = await request(app) + .post('/api/stacks/web/down?removeVolumes=1') + .set('Cookie', authCookie); + + expect(res.status).toBe(200); + expect(mockRunDown).toHaveBeenCalledWith('web', { removeVolumes: false }, undefined); + }); + + it('returns 400 when removeVolumes=true but capability is absent locally', async () => { + disableCapability(STACK_DOWN_REMOVE_VOLUMES_CAPABILITY); + + const res = await request(app) + .post('/api/stacks/web/down?removeVolumes=true') + .set('Cookie', authCookie); + + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/not supported/i); + expect(mockRunDown).not.toHaveBeenCalled(); + }); + + it('passes removeVolumes=true to runDown when capability is present', async () => { + const res = await request(app) + .post('/api/stacks/web/down?removeVolumes=true') + .set('Cookie', authCookie); + + expect(res.status).toBe(200); + expect(mockRunDown).toHaveBeenCalledWith('web', { removeVolumes: true }, undefined); + }); + + it('allows deploy-only API tokens to POST /down?removeVolumes=true', async () => { + const token = createDeployOnlyToken(); + const res = await request(app) + .post('/api/stacks/web/down?removeVolumes=true') + .set('Authorization', `Bearer ${token}`); + + expect(res.body.code).not.toBe('SCOPE_DENIED'); + expect(res.status).toBe(200); + expect(mockRunDown).toHaveBeenCalledWith('web', { removeVolumes: true }, undefined); + }); +}); diff --git a/backend/src/__tests__/stack-op-lock-routes.test.ts b/backend/src/__tests__/stack-op-lock-routes.test.ts index d9089824..08a1fb45 100644 --- a/backend/src/__tests__/stack-op-lock-routes.test.ts +++ b/backend/src/__tests__/stack-op-lock-routes.test.ts @@ -17,6 +17,7 @@ import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTes const { mockDeployStack, mockRunCommand, + mockRunDown, mockUpdateStack, mockGetContainersByStack, mockRestartContainer, @@ -25,6 +26,7 @@ const { } = vi.hoisted(() => ({ mockDeployStack: vi.fn(), mockRunCommand: vi.fn(), + mockRunDown: vi.fn(), mockUpdateStack: vi.fn(), mockGetContainersByStack: vi.fn(), mockRestartContainer: vi.fn(), @@ -43,6 +45,7 @@ vi.mock('../services/ComposeService', async () => { getInstance: () => ({ deployStack: mockDeployStack, runCommand: mockRunCommand, + runDown: mockRunDown, updateStack: mockUpdateStack, }), }, @@ -97,6 +100,7 @@ afterAll(() => { beforeEach(async () => { mockDeployStack.mockReset(); mockRunCommand.mockReset(); + mockRunDown.mockReset(); mockUpdateStack.mockReset(); mockGetContainersByStack.mockReset(); mockRestartContainer.mockReset(); @@ -233,13 +237,13 @@ describe('Stack lifecycle mutex', () => { it('blocks update while down is in flight', async () => { const gate = deferred(); - mockRunCommand.mockImplementationOnce(() => gate.promise); + mockRunDown.mockImplementationOnce(() => gate.promise); const down = request(app) .post('/api/stacks/web/down') .set('Cookie', authCookie) .then(r => r); - await vi.waitFor(() => expect(mockRunCommand).toHaveBeenCalled()); + await vi.waitFor(() => expect(mockRunDown).toHaveBeenCalled()); const update = await request(app) .post('/api/stacks/web/update') diff --git a/backend/src/__tests__/stack-self-protected-routes.test.ts b/backend/src/__tests__/stack-self-protected-routes.test.ts index 9365c588..30210dfb 100644 --- a/backend/src/__tests__/stack-self-protected-routes.test.ts +++ b/backend/src/__tests__/stack-self-protected-routes.test.ts @@ -13,6 +13,7 @@ import { SELF_STACK_PROTECTED_CODE } from '../helpers/selfStackGuard'; const { mockDeployStack, mockRunCommand, + mockRunDown, mockUpdateStack, mockDownStack, mockGetContainersByStack, @@ -22,6 +23,7 @@ const { } = vi.hoisted(() => ({ mockDeployStack: vi.fn(), mockRunCommand: vi.fn(), + mockRunDown: vi.fn(), mockUpdateStack: vi.fn(), mockDownStack: vi.fn(), mockGetContainersByStack: vi.fn(), @@ -41,6 +43,7 @@ vi.mock('../services/ComposeService', async () => { getInstance: () => ({ deployStack: mockDeployStack, runCommand: mockRunCommand, + runDown: mockRunDown, updateStack: mockUpdateStack, downStack: mockDownStack, }), @@ -146,6 +149,7 @@ describe('self stack lifecycle refusal', () => { expect(mockDeployStack).not.toHaveBeenCalled(); expect(mockUpdateStack).not.toHaveBeenCalled(); expect(mockRunCommand).not.toHaveBeenCalled(); + expect(mockRunDown).not.toHaveBeenCalled(); expect(mockDownStack).not.toHaveBeenCalled(); expect(mockStopContainer).not.toHaveBeenCalled(); }); diff --git a/backend/src/__tests__/stacks-failure-notifications.test.ts b/backend/src/__tests__/stacks-failure-notifications.test.ts index 4a225329..b742b2ec 100644 --- a/backend/src/__tests__/stacks-failure-notifications.test.ts +++ b/backend/src/__tests__/stacks-failure-notifications.test.ts @@ -19,6 +19,7 @@ import * as policyGate from '../helpers/policyGate'; const { mockDeployStack, mockRunCommand, + mockRunDown, mockUpdateStack, mockGetContainersByStack, mockRestartContainer, @@ -33,6 +34,7 @@ const { } = vi.hoisted(() => ({ mockDeployStack: vi.fn(), mockRunCommand: vi.fn(), + mockRunDown: vi.fn(), mockUpdateStack: vi.fn(), mockGetContainersByStack: vi.fn(), mockRestartContainer: vi.fn(), @@ -57,6 +59,7 @@ vi.mock('../services/ComposeService', async () => { getInstance: () => ({ deployStack: mockDeployStack, runCommand: mockRunCommand, + runDown: mockRunDown, updateStack: mockUpdateStack, }), }, @@ -140,6 +143,7 @@ afterAll(() => { beforeEach(() => { mockDeployStack.mockReset(); mockRunCommand.mockReset(); + mockRunDown.mockReset(); mockUpdateStack.mockReset(); mockGetContainersByStack.mockReset(); mockRestartContainer.mockReset(); @@ -388,8 +392,8 @@ describe('post-deploy scan opt-out', () => { }); describe('deploy_failure notification on /down error', () => { - it('dispatches deploy_failure alert when runCommand (down) throws', async () => { - mockRunCommand.mockRejectedValue(new Error('container removal error')); + it('dispatches deploy_failure alert when runDown throws', async () => { + mockRunDown.mockRejectedValue(new Error('container removal error')); const res = await request(app) .post('/api/stacks/myapp/down') diff --git a/backend/src/helpers/remoteCapabilities.ts b/backend/src/helpers/remoteCapabilities.ts index ea336610..72e2baa1 100644 --- a/backend/src/helpers/remoteCapabilities.ts +++ b/backend/src/helpers/remoteCapabilities.ts @@ -2,53 +2,51 @@ import { NodeRegistry } from '../services/NodeRegistry'; import { CROSS_NODE_RBAC_CAPABILITY } from '../services/CapabilityRegistry'; import { getErrorMessage } from '../utils/errors'; -// In-flight probes deduped per node so a burst of concurrent gated requests -// shares one /api/meta round-trip. The entry is dropped as soon as it settles, -// so the NEXT request re-probes. The verdict is deliberately NOT cached across -// requests: a remote can be replaced by older code at the same URL (a rollback -// or image pin), and a stale "supported" verdict would reopen the cross-node -// escalation, so each gated action re-verifies against the live remote. -const inFlight = new Map>(); +// In-flight probes deduped per node+capability so concurrent checks for +// different capabilities on the same node cannot share the wrong boolean. +const inFlight = new Map>(); + +function probeKey(nodeId: number, capability: string): string { + return `${nodeId}:${capability}`; +} /** - * Whether a remote node advertises that it enforces cross-node RBAC: the - * forwarded actor role on HTTP requests and the exact-stack allowlist on - * stop-by-label. Probes the remote's live /api/meta on every call (concurrent - * calls for the same node share one probe). + * Whether a remote node advertises a given capability. Probes the remote's live + * /api/meta on every call (concurrent calls for the same node+capability share + * one probe). * - * Fails closed: a remote that does not advertise the capability, that cannot be - * read (offline/unreachable, which yields empty capabilities), or that errors - * is treated as unsupported, so the caller denies rather than risk escalating a - * non-admin request or over-stopping. Because the probe is live, a remote - * downgraded to older code is detected on the next gated action rather than - * trusted until a cache expires. + * Fails closed: unsupported, offline, or unreachable remotes return false. */ -export async function remoteSupportsCrossNodeRbac(nodeId: number): Promise { - const existing = inFlight.get(nodeId); +export async function remoteAdvertisesCapability(nodeId: number, capability: string): Promise { + const key = probeKey(nodeId, capability); + const existing = inFlight.get(key); if (existing) return existing; const probe = (async (): Promise => { try { const meta = await NodeRegistry.getInstance().fetchMetaForNode(nodeId); - // Check the advertised capability directly. An offline/unreadable remote - // yields OFFLINE_META with empty capabilities, so this already fails - // closed; keying off the capability (not the version) also correctly - // trusts a reachable remote whose version string is non-semver, e.g. a - // 0.0.0-dev image, but that genuinely advertises the capability. - return meta.capabilities.includes(CROSS_NODE_RBAC_CAPABILITY); + return meta.capabilities.includes(capability); } catch (err) { console.warn( - `[CrossNodeRBAC] Could not verify capability for node ${nodeId}; treating as unsupported:`, + `[RemoteCapability] Could not verify "${capability}" for node ${nodeId}; treating as unsupported:`, getErrorMessage(err, 'unknown'), ); return false; } })(); - inFlight.set(nodeId, probe); + inFlight.set(key, probe); try { return await probe; } finally { - inFlight.delete(nodeId); + inFlight.delete(key); } } + +/** + * Whether a remote node advertises cross-node RBAC enforcement for proxied + * requests. Thin wrapper over {@link remoteAdvertisesCapability}. + */ +export async function remoteSupportsCrossNodeRbac(nodeId: number): Promise { + return remoteAdvertisesCapability(nodeId, CROSS_NODE_RBAC_CAPABILITY); +} diff --git a/backend/src/proxy/remoteNodeProxy.ts b/backend/src/proxy/remoteNodeProxy.ts index d2a1b56b..4c923d0b 100644 --- a/backend/src/proxy/remoteNodeProxy.ts +++ b/backend/src/proxy/remoteNodeProxy.ts @@ -4,7 +4,8 @@ import { NodeRegistry } from '../services/NodeRegistry'; import { PROXY_TIER_HEADER, PROXY_ROLE_HEADER } from '../services/license-headers'; import { LicenseService } from '../services/LicenseService'; import { isProxyExemptPath } from '../helpers/proxyExemptPaths'; -import { remoteSupportsCrossNodeRbac } from '../helpers/remoteCapabilities'; +import { remoteSupportsCrossNodeRbac, remoteAdvertisesCapability } from '../helpers/remoteCapabilities'; +import { STACK_DOWN_REMOVE_VOLUMES_CAPABILITY } from '../services/CapabilityRegistry'; import { getErrorMessage } from '../utils/errors'; import { DatabaseService } from '../services/DatabaseService'; import { redactSensitiveText } from '../utils/safeLog'; @@ -146,31 +147,37 @@ export function createRemoteProxyMiddleware(): RequestHandler { return; } - // Mixed-version RBAC gate. The forwarded actor role is enforced only by a - // remote that advertises cross-node-rbac; an older remote ignores the - // header and runs the proxied request as admin. So a non-admin must not be - // forwarded to a remote that does not advertise the capability. Admins are - // unaffected (they are admin on the remote regardless), and the check is - // skipped for them so it never adds latency to the admin path. Fails closed - // when the capability cannot be determined. Using `?.` so an unresolved user - // (not reachable past authGate, but defensive) is gated, never waved through. - if (req.user?.role !== 'admin') { - remoteSupportsCrossNodeRbac(req.nodeId) - .then((supported) => { - if (!supported) { - res.status(403).json({ - error: `Remote node "${node.name}" is running a version that does not enforce per-user permissions. Upgrade it before non-admin users can act on it.`, - }); - return; - } - req.proxyTarget = target; - proxy(req, res, next); - }) - .catch(next); - return; - } + const runGatedProxy = async (): Promise => { + if (isStackDownWithRemoveVolumes(req)) { + const supported = await remoteAdvertisesCapability(req.nodeId, STACK_DOWN_REMOVE_VOLUMES_CAPABILITY); + if (!supported) { + res.status(400).json({ error: 'Volume removal is not supported on this node' }); + return; + } + } - req.proxyTarget = target; - proxy(req, res, next); + // Mixed-version RBAC gate (non-admin only). + if (req.user?.role !== 'admin') { + const rbacSupported = await remoteSupportsCrossNodeRbac(req.nodeId); + if (!rbacSupported) { + res.status(403).json({ + error: `Remote node "${node.name}" is running a version that does not enforce per-user permissions. Upgrade it before non-admin users can act on it.`, + }); + return; + } + } + + req.proxyTarget = target; + proxy(req, res, next); + }; + + runGatedProxy().catch(next); }; } + +/** POST /stacks/:stackName/down with ?removeVolumes=true (path is post-/api strip). */ +function isStackDownWithRemoveVolumes(req: Request): boolean { + if (req.method !== 'POST') return false; + if (!/^\/stacks\/[^/]+\/down$/.test(req.path)) return false; + return req.query.removeVolumes === 'true'; +} diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts index da40891e..764cea33 100644 --- a/backend/src/routes/stacks.ts +++ b/backend/src/routes/stacks.ts @@ -56,6 +56,7 @@ import { resolveStackEnvSources, discoverStackLocalEnvFiles } from '../helpers/e import { STACK_STATUSES_CACHE_TTL_MS } from '../helpers/constants'; import { getTerminalWs, DEPLOY_SESSION_HEADER } from '../websocket/generic'; import { isSelfStack, refuseIfSelfStack, selfStackProtectedBulkResult } from '../helpers/selfStackGuard'; +import { getActiveCapabilities, STACK_DOWN_REMOVE_VOLUMES_CAPABILITY } from '../services/CapabilityRegistry'; // Authenticated users with edit permission can write arbitrarily large compose // files. Refuse to YAML.parse anything beyond this bound so a malformed (or @@ -86,7 +87,7 @@ function notifyActionSuccess(category: NotificationCategory, message: string, st const STACK_OP_PRESENT_PARTICIPLE: Record = { deploy: 'deploying', - down: 'stopping', + down: 'taking down', restart: 'restarting', stop: 'stopping', start: 'starting', @@ -1664,11 +1665,22 @@ stacksRouter.post('/:stackName/down', async (req: Request, res: Response) => { if (!tryAcquireStackOpLock(req, res, stackName, 'down')) return; const t0 = Date.now(); let ok = false; + const removeVolumes = req.query.removeVolumes === 'true'; try { - if (isDebugEnabled()) console.debug(`[Stacks:debug] Down starting`, { stackName: sanitizeForLog(stackName), nodeId: req.nodeId }); - await ComposeService.getInstance(req.nodeId).runCommand(stackName, 'down', getTerminalWs(req.get(DEPLOY_SESSION_HEADER))); + if (removeVolumes && !getActiveCapabilities().includes(STACK_DOWN_REMOVE_VOLUMES_CAPABILITY)) { + res.status(400).json({ error: 'Volume removal is not supported on this node' }); + return; + } + if (isDebugEnabled()) console.debug(`[Stacks:debug] Down starting`, { stackName: sanitizeForLog(stackName), nodeId: req.nodeId, removeVolumes }); + await ComposeService.getInstance(req.nodeId).runDown(stackName, { removeVolumes }, getTerminalWs(req.get(DEPLOY_SESSION_HEADER))); invalidateNodeCaches(req.nodeId); dlog(`[Stacks] Down completed: ${sanitizeForLog(stackName)}`); + notifyActionSuccess( + 'stack_taken_down', + `${stackName} taken down${removeVolumes ? ' (volumes removed)' : ''}`, + stackName, + req.user?.username ?? 'system', + ); ok = true; res.json({ status: 'Command started' }); } catch (error: unknown) { diff --git a/backend/src/services/CapabilityRegistry.ts b/backend/src/services/CapabilityRegistry.ts index 5bacc2be..5b5a9d69 100644 --- a/backend/src/services/CapabilityRegistry.ts +++ b/backend/src/services/CapabilityRegistry.ts @@ -54,6 +54,7 @@ export const CAPABILITIES = [ 'project-env-files', 'compose-storage', 'cross-node-rbac', + 'stack-down-remove-volumes', ] as const; /** @@ -67,6 +68,9 @@ export const CROSS_NODE_RBAC_CAPABILITY = 'cross-node-rbac'; export type Capability = (typeof CAPABILITIES)[number]; +/** Capability for optional `?removeVolumes=true` on POST /stacks/:name/down. */ +export const STACK_DOWN_REMOVE_VOLUMES_CAPABILITY = 'stack-down-remove-volumes' as const satisfies Capability; + /** Returns true when the string is a usable semver version. */ export function isValidVersion(v: string | null | undefined): v is string { return !!v && v !== 'unknown' && v !== '0.0.0-dev' && !!semver.valid(v); diff --git a/backend/src/services/ComposeService.ts b/backend/src/services/ComposeService.ts index e6017c27..3c325035 100644 --- a/backend/src/services/ComposeService.ts +++ b/backend/src/services/ComposeService.ts @@ -390,6 +390,13 @@ export class ComposeService { await this.execute('docker', await this.authoredComposeArgs(stackName, [action]), stackDir, ws); } + /** Interactive compose down (Take down UI / POST /down). Plain `down` by default. */ + async runDown(stackName: string, options?: { removeVolumes?: boolean }, ws?: WebSocket): Promise { + const stackDir = path.join(this.baseDir, stackName); + const args = options?.removeVolumes ? ['down', '--volumes'] : ['down']; + await this.execute('docker', await this.authoredComposeArgs(stackName, args), stackDir, ws); + } + /** * Opt-in guard: when `env_block_deploy_on_missing_required` is enabled, refuse a * deploy whose required `${VAR:?err}` variables are unset OR empty, before any diff --git a/backend/src/services/NotificationService.ts b/backend/src/services/NotificationService.ts index 8d619ba5..e7756479 100644 --- a/backend/src/services/NotificationService.ts +++ b/backend/src/services/NotificationService.ts @@ -19,6 +19,7 @@ export type NotificationCategory = | 'stack_started' | 'stack_stopped' | 'stack_restarted' + | 'stack_taken_down' | 'image_update_available' | 'image_update_applied' | 'autoheal_triggered' @@ -43,7 +44,7 @@ export type NotificationCategory = export const ALL_NOTIFICATION_CATEGORIES: readonly NotificationCategory[] = [ 'deploy_success', 'deploy_failure', 'stack_started', 'stack_stopped', - 'stack_restarted', 'image_update_available', 'image_update_applied', + 'stack_restarted', 'stack_taken_down', 'image_update_available', 'image_update_applied', 'autoheal_triggered', 'monitor_alert', 'scan_finding', 'blueprint_deployed', 'blueprint_deployment_failed', 'blueprint_drift_detected', 'blueprint_drift_correction_failed', diff --git a/backend/src/utils/audit-summaries.ts b/backend/src/utils/audit-summaries.ts index 97015ba0..eebf44a0 100644 --- a/backend/src/utils/audit-summaries.ts +++ b/backend/src/utils/audit-summaries.ts @@ -14,7 +14,7 @@ export const AUDIT_ROUTE_SUMMARIES: Record = { // Stack lifecycle 'POST /stacks/*/deploy': 'Deployed stack', - 'POST /stacks/*/down': 'Stopped stack', + 'POST /stacks/*/down': 'Took stack down', 'POST /stacks/*/start': 'Started stack', 'POST /stacks/*/stop': 'Stopped stack', 'POST /stacks/*/restart': 'Restarted stack', diff --git a/docs/features/api-tokens.mdx b/docs/features/api-tokens.mdx index a9b544b6..dd0028e1 100644 --- a/docs/features/api-tokens.mdx +++ b/docs/features/api-tokens.mdx @@ -150,7 +150,7 @@ Authorises every `GET`, plus exactly six `POST` patterns that operate on a stack | Method | Path pattern | Action | |--------|--------------|--------| | `POST` | `/api/stacks/:name/deploy` | Create or update and bring up. | -| `POST` | `/api/stacks/:name/down` | Tear down. | +| `POST` | `/api/stacks/:name/down` | Tear down containers and compose-created networks. Add `?removeVolumes=true` to also remove compose volumes when the node supports it. | | `POST` | `/api/stacks/:name/restart` | Restart in place. | | `POST` | `/api/stacks/:name/stop` | Stop without removing. | | `POST` | `/api/stacks/:name/start` | Start a stopped stack. | diff --git a/docs/features/deploy-progress.mdx b/docs/features/deploy-progress.mdx index 939717f1..70cae529 100644 --- a/docs/features/deploy-progress.mdx +++ b/docs/features/deploy-progress.mdx @@ -160,7 +160,7 @@ Of those, **Deploy**, **Update**, **Install**, and Git **Apply** route through ` The [health gate](#health-gate) activates only after **Deploy** and **Update**; it does not fire for Restart, Stop, Install, Git Apply, or Scanning. -The HTTP API also exposes a `down` action (compose-level teardown) that streams its output the same way Deploy and Update do, but no UI control currently triggers it; the `down` endpoint is reachable from automation and from Sencho's own internal cleanup paths. +The HTTP API exposes **Take down** as `POST /api/stacks/:name/down`. The stack header, sidebar context menu, and confirmation dialog call this endpoint. Compose output streams through the same progress modal as Deploy and Update. Optional `?removeVolumes=true` removes compose volumes when the node advertises support. ## Troubleshooting diff --git a/docs/features/editor.mdx b/docs/features/editor.mdx index ea1a4c9a..cb7ed794 100644 --- a/docs/features/editor.mdx +++ b/docs/features/editor.mdx @@ -25,12 +25,13 @@ The top card on the left holds the stack's identity and primary controls. ### Stack action bar -The action bar runs every state transition for the whole stack. The primary buttons (**Start**, **Restart**, **Stop**, **Update**) require the `stack:deploy` permission; the **Delete** entry in the kebab dropdown requires the `stack:delete` permission. The bar still appears when only **Delete** is authorised so the operator has a way to remove the stack. +The action bar runs every state transition for the whole stack. The primary buttons (**Start**, **Restart**, **Stop**, **Take down** when running, **Update**) require the `stack:deploy` permission; the **Delete** entry in the kebab dropdown requires the `stack:delete` permission. The bar still appears when only **Delete** is authorised so the operator has a way to remove the stack. | Button | Behavior | |--------|----------| | **Start** / **Restart** | A single button that becomes **Restart** when at least one container is running and **Start** otherwise. | | **Stop** | Stops every container in the stack. Hidden when nothing is running. | +| **Take down** | Shown when at least one container is running. Opens a confirmation dialog, then runs `docker compose down`. Removes containers and compose-created networks while keeping the stack directory. Optional volume removal is offered when the active node supports it. On stopped stacks, use the sidebar context menu or `⌘↓` / `Ctrl+↓`. | | **Update** | Pulls fresh images and reapplies the compose file. | | **More actions** (`⋮`) | Opens a dropdown with secondary actions. | diff --git a/docs/features/rbac.mdx b/docs/features/rbac.mdx index db14642f..34f803d5 100644 --- a/docs/features/rbac.mdx +++ b/docs/features/rbac.mdx @@ -30,7 +30,7 @@ Each row is one of the permission keys the backend checks. The matrix below is t | Permission | Admin | Node Admin | Deployer | Auditor | Viewer | |------------|:-----:|:----------:|:--------:|:-------:|:------:| | View stacks, logs, stats (`stack:read`) | Yes | Yes | Yes | Yes | Yes | -| Deploy, restart, stop, start (`stack:deploy`) | Yes | Yes | Yes | No | No | +| Deploy, restart, stop, start, take down (`stack:deploy`) | Yes | Yes | Yes | No | No | | Edit compose and `.env` files (`stack:edit`) | Yes | Yes | No | No | No | | Create stacks (`stack:create`) | Yes | Yes | No | No | No | | Delete stacks (`stack:delete`) | Yes | Yes | No | No | No | diff --git a/docs/features/sidebar.mdx b/docs/features/sidebar.mdx index 385c81ae..cfe99d50 100644 --- a/docs/features/sidebar.mdx +++ b/docs/features/sidebar.mdx @@ -119,6 +119,7 @@ Shortcuts fire on the currently selected stack. They are blocked while a text in | Ctrl+. | Stop | Stack is running | | Ctrl+R | Restart | Stack is running | | Ctrl+ | Update images | Stack is running | +| Ctrl+ | Take down | Stack has been deployed (opens confirmation) | | Ctrl+Backspace | Delete | Stack exists | On macOS, use Cmd in place of Ctrl. diff --git a/docs/features/stack-management.mdx b/docs/features/stack-management.mdx index f1e14e63..690b526e 100644 --- a/docs/features/stack-management.mdx +++ b/docs/features/stack-management.mdx @@ -347,6 +347,7 @@ The stack header groups actions by frequency of use. The most common action is t |-----------|--------|---------|--------------| | Primary | **Restart** | `docker compose restart` | Restarts all containers in the stack. | | Secondary | **Stop** | `docker compose stop` | Stops containers without removing them. State is preserved. | +| Secondary | **Take down** | `docker compose down` | Removes containers and compose-created networks. The stack definition stays on disk so you can deploy again later. Optional volume removal is available in the confirmation dialog. | | Secondary | **Update** | `docker compose pull` + `up -d` (or build-aware rebuild when services declare `build:`) | Pulls registry images and recreates containers. When one or more services use `build:`, Update rebuilds those images from source (`compose build --pull`), pulls any remaining registry images, then recreates containers. | | Overflow | **Rollback** | Restores backup | Reverts compose and env files to the pre-deploy snapshot and redeploys. Only shown when a backup exists. | | Overflow | **Scan config** | Trivy config scan | Scans the compose file for misconfigurations (admin role). | @@ -360,8 +361,12 @@ The stack header groups actions by frequency of use. The most common action is t | Secondary | **Update** | `docker compose pull` + `up -d` (or build-aware rebuild when services declare `build:`) | Pulls registry images and recreates containers. When one or more services use `build:`, Update rebuilds those images from source (`compose build --pull`), pulls any remaining registry images, then recreates containers. | | Overflow | **Delete** | Removes files | Deletes the stack directory. | +Use the sidebar context menu or `⌘↓` / `Ctrl+↓` for **Take down** on a stopped stack. + **Delete** is irreversible. It removes the stack directory, including `compose.yaml`, `.env`, and any bind-mounted files stored there. Back up important files before deleting. + + **Take down** keeps your compose files on disk. It removes running containers and compose-created networks. When you opt in to volume removal, named and anonymous compose volumes are removed as well. Use **Stop** when you want containers to stay in place for a quick resume. @@ -426,6 +431,7 @@ Click the kebab on a stack row in the sidebar to open its context menu. The menu **lifecycle** - **Stop** (`⌘.`): shown when running. +- **Take down** (`⌘↓`): shown when the stack has been deployed (running, partial, or exited). Opens a confirmation dialog; optional volume removal is offered when the active node supports it. - **Restart** (`⌘R`): shown when running. - **Update** (`⌘↑`): pulls the latest image tags and redeploys. - **Schedule task**: open the scheduler pre-filled for this stack; pick **Auto-update Stack** to set up unattended image updates on your own cadence. See [Scheduled Operations](/features/scheduled-operations) and [Auto-Update Policies](/features/auto-update-policies). diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 88e8950a..acf01b9f 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1420,10 +1420,16 @@ paths: operationId: downStack tags: [Stacks] summary: Tear down stack - description: Runs `docker compose down` for the stack, removing containers and networks. Requires `stack:deploy` permission. + description: Runs `docker compose down` for the stack, removing containers and networks. The stack definition remains on disk. Requires `stack:deploy` permission. Pass `removeVolumes=true` to also remove compose volumes when the node advertises the `stack-down-remove-volumes` capability. parameters: - $ref: "#/components/parameters/stackName" - $ref: "#/components/parameters/nodeId" + - name: removeVolumes + in: query + required: false + schema: + type: boolean + description: When `true`, runs `docker compose down --volumes`. Only honored when the target node advertises `stack-down-remove-volumes`. responses: "200": description: Command started. @@ -1436,6 +1442,12 @@ paths: status: type: string example: Command started + "400": + description: Volume removal requested on a node that does not support it. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" "403": $ref: "#/components/responses/Forbidden" "500": diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 876755ee..4c8e2ec8 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -30,6 +30,7 @@ import { import { SENCHO_OPEN_LOGS_EVENT, SENCHO_OPEN_STACK_EVENT } from '@/lib/events'; import type { SenchoOpenLogsDetail, SenchoOpenStackDetail } from '@/lib/events'; import { useNodes } from '@/context/NodeContext'; +import { STACK_DOWN_REMOVE_VOLUMES_CAPABILITY } from '@/lib/capabilities'; import { useAuth } from '@/context/AuthContext'; import { useDeployFeedback } from '@/context/DeployFeedbackContext'; import { useTrivyStatus } from '@/hooks/useTrivyStatus'; @@ -145,7 +146,9 @@ export default function EditorLayout() { stacksLoadNodeId, } = stackListState; - const { nodes, activeNode, setActiveNode, hasCapability, isLoading: nodesLoading } = useNodes(); + const { nodes, activeNode, setActiveNode, hasCapability, activeNodeMeta, isLoading: nodesLoading } = useNodes(); + const canOfferVolumeRemoval = + activeNodeMeta?.capabilities.includes(STACK_DOWN_REMOVE_VOLUMES_CAPABILITY) === true; // Mirror activeNode.id in a ref so async handlers (e.g. CreateStackDialog's // post-create handoff) can detect a node switch that happened mid-flight. @@ -230,6 +233,7 @@ export default function EditorLayout() { getLastDeployOutputLine, diffPreviewEnabled, hasUpdateGuard: hasCapability('update-guard'), + canOfferVolumeRemoval, }); // Wire the ref now that stackActions is available @@ -554,6 +558,8 @@ export default function EditorLayout() { setEditingCompose={setEditingCompose} setGitSourceOpen={setGitSourceOpen} requestDeleteStack={stackActions.requestDeleteStack} + requestTakeDownStack={stackActions.requestTakeDownStack} + showTakeDown={selectedFile ? stackActions.getStackMenuVisibility(selectedFile).showTakeDown : false} isSelfStack={selectedFile ? stackSelfFlags[selectedFile] === true : false} recoveryResult={selectedFile ? lastActionResult[selectedFile] : undefined} onRefreshState={async () => { @@ -863,6 +869,7 @@ export default function EditorLayout() { gitSourceOpen={gitSourceOpen} setGitSourceOpen={setGitSourceOpen} canSelfUpdate={hasCapability('self-update')} + canOfferVolumeRemoval={canOfferVolumeRemoval} onOpenFleetNodeUpdates={() => { if (isMobile) { navigateMobileAware('fleet'); diff --git a/frontend/src/components/EditorLayout/EditorView.tsx b/frontend/src/components/EditorLayout/EditorView.tsx index 51643b3e..facb5f73 100644 --- a/frontend/src/components/EditorLayout/EditorView.tsx +++ b/frontend/src/components/EditorLayout/EditorView.tsx @@ -67,7 +67,8 @@ export type StackAction = | 'restart' | 'update' | 'delete' - | 'rollback'; + | 'rollback' + | 'down'; /** * Stack operations the recovery panel can offer safe next steps for. A failed @@ -184,6 +185,8 @@ export interface EditorViewProps { // Composed action: wraps setStackToDelete + setDeleteDialogOpen requestDeleteStack: () => void; + requestTakeDownStack: (stackName: string) => void; + showTakeDown: boolean; /** True when this stack is the running Sencho instance on the active node. */ isSelfStack?: boolean; @@ -264,6 +267,8 @@ export function EditorView(props: EditorViewProps) { setEditingCompose, setGitSourceOpen, requestDeleteStack, + requestTakeDownStack, + showTakeDown, isSelfStack, recoveryResult, onRefreshState, @@ -390,6 +395,8 @@ export function EditorView(props: EditorViewProps) { rollbackStack={rollbackStack} scanStackConfig={scanStackConfig} requestDeleteStack={requestDeleteStack} + requestTakeDownStack={requestTakeDownStack} + showTakeDown={showTakeDown} isSelfStack={isSelfStack} stackMuteActions={stackMuteActions} /> diff --git a/frontend/src/components/EditorLayout/MobileStackDetail.test.tsx b/frontend/src/components/EditorLayout/MobileStackDetail.test.tsx index 1f0dad70..75507382 100644 --- a/frontend/src/components/EditorLayout/MobileStackDetail.test.tsx +++ b/frontend/src/components/EditorLayout/MobileStackDetail.test.tsx @@ -75,6 +75,8 @@ function makeProps(over: Partial = {}): EditorViewProps { setEditingCompose: vi.fn(), setGitSourceOpen: vi.fn(), requestDeleteStack: vi.fn(), + requestTakeDownStack: vi.fn(), + showTakeDown: false, onMobileBack: vi.fn(), onCloseEditor: vi.fn(), hasUnsavedChanges: () => false, diff --git a/frontend/src/components/EditorLayout/MobileStackDetail.tsx b/frontend/src/components/EditorLayout/MobileStackDetail.tsx index 15c202e8..94078faf 100644 --- a/frontend/src/components/EditorLayout/MobileStackDetail.tsx +++ b/frontend/src/components/EditorLayout/MobileStackDetail.tsx @@ -67,6 +67,8 @@ export function MobileStackDetail(props: EditorViewProps) { setEditingCompose, setGitSourceOpen, requestDeleteStack, + requestTakeDownStack, + showTakeDown, isSelfStack = false, onMobileBack, onCloseEditor, @@ -148,6 +150,8 @@ export function MobileStackDetail(props: EditorViewProps) { rollbackStack={rollbackStack} scanStackConfig={scanStackConfig} requestDeleteStack={requestDeleteStack} + requestTakeDownStack={requestTakeDownStack} + showTakeDown={showTakeDown} isSelfStack={isSelfStack} stackMuteActions={stackMuteActions} /> diff --git a/frontend/src/components/EditorLayout/ShellOverlays.tsx b/frontend/src/components/EditorLayout/ShellOverlays.tsx index 3c86b0bd..d19f29aa 100644 --- a/frontend/src/components/EditorLayout/ShellOverlays.tsx +++ b/frontend/src/components/EditorLayout/ShellOverlays.tsx @@ -4,6 +4,7 @@ import { PreDeployScanDialog } from '../stack/PreDeployScanDialog'; import { UpdateReadinessDialog } from '../stack/UpdateReadinessDialog'; import { SelfStackProtectedDialog } from '../stack/SelfStackProtectedDialog'; import { DeleteStackDialog } from './DeleteStackDialog'; +import { TakeDownStackDialog } from './TakeDownStackDialog'; import { UnsavedChangesDialog } from './UnsavedChangesDialog'; import { StackAlertSheet } from '../StackAlertSheet'; import { GitSourcePanel } from '../stack/GitSourcePanel'; @@ -25,6 +26,7 @@ interface ShellOverlaysProps { gitSourceOpen: boolean; setGitSourceOpen: (open: boolean) => void; canSelfUpdate: boolean; + canOfferVolumeRemoval: boolean; onOpenFleetNodeUpdates: () => void; } @@ -39,10 +41,12 @@ export function ShellOverlays({ gitSourceOpen, setGitSourceOpen, canSelfUpdate, + canOfferVolumeRemoval, onOpenFleetNodeUpdates, }: ShellOverlaysProps) { const { deleteDialogOpen, closeDeleteDialog, stackToDelete, + takeDownDialogOpen, closeTakeDownDialog, stackToTakeDown, pendingUnsavedLoad, pendingLeaveAction, bashModalOpen, selectedContainer, logViewerOpen, logContainer, @@ -64,6 +68,14 @@ export function ShellOverlays({ onConfirm={stackActions.deleteStack} /> + { if (!open) closeTakeDownDialog(); }} + stackName={stackToTakeDown} + showVolumeOption={canOfferVolumeRemoval} + onConfirm={stackActions.takeDownStack} + /> + void; + stackName: string | null; + showVolumeOption: boolean; + onConfirm: (removeVolumes: boolean) => void | Promise; +} + +export function TakeDownStackDialog({ + open, + onOpenChange, + stackName, + showVolumeOption, + onConfirm, +}: TakeDownStackDialogProps) { + const [removeVolumes, setRemoveVolumes] = useState(false); + + // Parent closes via overlay state (not always through handleOpenChange); reset so + // a prior volume opt-in cannot leak into the next dialog session. + useEffect(() => { + if (!open) setRemoveVolumes(false); + }, [open]); + + return ( + + Take down {stackName}? + + ) : ( + 'Take down stack?' + ) + } + description="This removes running containers and compose-created networks. The stack configuration stays on disk so you can deploy again later." + hint={removeVolumes ? 'VOLUMES REMOVED' : 'VOLUMES KEPT'} + confirmLabel="Take down" + onConfirm={() => onConfirm(removeVolumes)} + > + {showVolumeOption && ( +
+ setRemoveVolumes(v === true)} + /> + +
+ )} +
+ ); +} diff --git a/frontend/src/components/EditorLayout/__tests__/EditorView.test.tsx b/frontend/src/components/EditorLayout/__tests__/EditorView.test.tsx index 21314a73..8807dd5d 100644 --- a/frontend/src/components/EditorLayout/__tests__/EditorView.test.tsx +++ b/frontend/src/components/EditorLayout/__tests__/EditorView.test.tsx @@ -75,6 +75,8 @@ function makeProps(over: Partial = {}): EditorViewProps { setEditingCompose: vi.fn(), setGitSourceOpen: vi.fn(), requestDeleteStack: vi.fn(), + requestTakeDownStack: vi.fn(), + showTakeDown: false, onRefreshState: vi.fn(), onDismissRecovery: vi.fn(), panelStartedAt: null, diff --git a/frontend/src/components/EditorLayout/__tests__/StackIdentityHeader.test.tsx b/frontend/src/components/EditorLayout/__tests__/StackIdentityHeader.test.tsx index 04a72465..f61b63f7 100644 --- a/frontend/src/components/EditorLayout/__tests__/StackIdentityHeader.test.tsx +++ b/frontend/src/components/EditorLayout/__tests__/StackIdentityHeader.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import type { ComponentProps } from 'react'; import { StackIdentityHeader } from '../editor-view-blocks'; import type { ContainerInfo } from '../EditorView'; @@ -50,6 +51,8 @@ function renderHeader(over: Partial> rollbackStack={vi.fn()} scanStackConfig={vi.fn()} requestDeleteStack={vi.fn()} + requestTakeDownStack={vi.fn()} + showTakeDown={false} {...over} />, ); @@ -59,13 +62,45 @@ describe('StackIdentityHeader', () => { it('renders stack identity and stack-wide actions without a header image line', () => { renderHeader(); - expect(screen.getByText('plex')).toBeInTheDocument(); - expect(screen.getByText(/running · healthy/i)).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Restart' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Update' })).toBeInTheDocument(); + expect(screen.getByText('plex')).toBeTruthy(); + expect(screen.getByText(/running · healthy/i)).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Restart' })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Update' })).toBeTruthy(); - expect(screen.queryByText(/^image$/i)).not.toBeInTheDocument(); - expect(screen.queryByText('nginx:alpine')).not.toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Copy digest' })).not.toBeInTheDocument(); + expect(screen.queryByText(/^image$/i)).toBeNull(); + expect(screen.queryByText('nginx:alpine')).toBeNull(); + expect(screen.queryByRole('button', { name: 'Copy digest' })).toBeNull(); + }); + + it('shows Take down when running and showTakeDown is true', () => { + renderHeader({ showTakeDown: true }); + + expect(screen.getByTestId('stack-take-down-button')).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Take down' })).toBeTruthy(); + }); + + it('hides Take down when showTakeDown is false', () => { + renderHeader({ showTakeDown: false, isRunning: true }); + + expect(screen.queryByTestId('stack-take-down-button')).toBeNull(); + }); + + it('calls requestTakeDownStack with the stack name when Take down is clicked', async () => { + const user = userEvent.setup(); + const requestTakeDownStack = vi.fn(); + renderHeader({ showTakeDown: true, requestTakeDownStack }); + + await user.click(screen.getByTestId('stack-take-down-button')); + + expect(requestTakeDownStack).toHaveBeenCalledWith('plex'); + }); + + it('does not show Take down in the overflow menu when running', async () => { + const user = userEvent.setup(); + renderHeader({ showTakeDown: true, isRunning: true, backupInfo: { exists: true, timestamp: Date.now() } }); + + await user.click(screen.getByRole('button', { name: 'More actions' })); + + expect(screen.queryByRole('menuitem', { name: /Take down/i })).toBeNull(); }); }); diff --git a/frontend/src/components/EditorLayout/__tests__/TakeDownStackDialog.test.tsx b/frontend/src/components/EditorLayout/__tests__/TakeDownStackDialog.test.tsx new file mode 100644 index 00000000..37358d1e --- /dev/null +++ b/frontend/src/components/EditorLayout/__tests__/TakeDownStackDialog.test.tsx @@ -0,0 +1,91 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { ComponentProps } from 'react'; +import { TakeDownStackDialog } from '../TakeDownStackDialog'; + +function renderDialog( + open: boolean, + overrides: Partial> = {}, +) { + return render( + , + ); +} + +describe('TakeDownStackDialog', () => { + it('resets removeVolumes after parent-driven close and reopen', async () => { + const user = userEvent.setup(); + const { rerender } = renderDialog(true); + + const checkbox = screen.getByTestId('take-down-remove-volumes'); + await user.click(checkbox); + expect(checkbox.getAttribute('data-state')).toBe('checked'); + + // Parent closes after async success without routing through onOpenChange(false). + rerender( + , + ); + rerender( + , + ); + + expect(screen.getByTestId('take-down-remove-volumes').getAttribute('data-state')).toBe('unchecked'); + }); + + it('passes removeVolumes=false on confirm after parent-driven reopen', async () => { + const user = userEvent.setup(); + const onConfirm = vi.fn(); + const { rerender } = render( + , + ); + + await user.click(screen.getByTestId('take-down-remove-volumes')); + rerender( + , + ); + rerender( + , + ); + + await user.click(screen.getByRole('button', { name: 'Take down' })); + expect(onConfirm).toHaveBeenCalledWith(false); + }); +}); diff --git a/frontend/src/components/EditorLayout/editor-view-blocks.tsx b/frontend/src/components/EditorLayout/editor-view-blocks.tsx index c6c8d6cc..e68b2d19 100644 --- a/frontend/src/components/EditorLayout/editor-view-blocks.tsx +++ b/frontend/src/components/EditorLayout/editor-view-blocks.tsx @@ -16,6 +16,7 @@ import { ArrowUpRight, Copy, CloudDownload, + ArrowDownToLine, Layers, List, Maximize2, @@ -133,6 +134,8 @@ export interface StackIdentityHeaderProps { rollbackStack: () => Promise; scanStackConfig: () => Promise; requestDeleteStack: () => void; + requestTakeDownStack: (stackName: string) => void; + showTakeDown: boolean; /** True when this stack is the running Sencho instance on the active node. */ isSelfStack?: boolean; stackMuteActions?: ReturnType; @@ -158,6 +161,8 @@ export function StackIdentityHeader({ rollbackStack, scanStackConfig, requestDeleteStack, + requestTakeDownStack, + showTakeDown, isSelfStack = false, stackMuteActions, }: StackIdentityHeaderProps) { @@ -219,6 +224,20 @@ export function StackIdentityHeader({ {loadingAction === 'stop' ? 'Stopping...' : 'Stop'} )} + {isRunning && showTakeDown && ( + + )}