feat: add confirmed Take down stack action with optional volume removal (#1599)

* feat: add confirmed Take down stack action with optional volume removal

Expose Take down in the stack header and sidebar with a confirmation dialog
that runs compose down while keeping the stack definition on disk. Optional
volume removal is gated by node capability and stack:deploy permission, with
remote gateway preflight before proxying removeVolumes requests.

Closes #1582

* fix: reset take-down volume checkbox when dialog closes

* test: align getStackMenuVisibility assertions with showTakeDown key

getStackMenuVisibility now returns a fifth lifecycle flag, showTakeDown,
but three exhaustive toEqual assertions still listed only the prior four
keys and failed. Add the expected showTakeDown value to each: true for
the partial and exited running-stack cases, false for the self stack.

* test: cover Take down visibility for running non-self stacks

The getStackMenuVisibility assertions exercised the partial and exited
branches and the self-stack guard, but not the raw === 'running' literal
that drives showTakeDown for a normal running stack. Add a case so a
regression dropping 'running' from that check is caught.

* fix: drop Take down from header overflow and wire activity shortcut

Remove duplicate Take down from More actions.

Keep inline button when running, sidebar menu, and Cmd+ArrowDown.

Record stack_taken_down in activity on successful POST /down.
This commit is contained in:
Anso
2026-07-09 12:20:13 -04:00
committed by GitHub
parent 296ddff2a0
commit d113004359
48 changed files with 952 additions and 110 deletions
@@ -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<typeof import('../services/ComposeService')>(
'../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<typeof vi.spyOn>;
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);
});
});