mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 03:36:59 +00:00
fix: condition --volumes in downStack() on the removeVolumes option (#1764)
* fix: condition --volumes in downStack() on the removeVolumes option
ComposeService.downStack() hardcoded --volumes on every stack delete,
ignoring the "Also remove associated volumes" checkbox and destroying
volumes the operator asked to keep. The sibling Take-down path (runDown)
already conditions --volumes correctly.
- Add options?: { removeVolumes?: boolean } to downStack()
- Default to data-preserving (no --volumes when option absent)
- DeletedStackDeletionService reads the persisted intent flag
- Templates rollback passes removeVolumes: true (clean up failed deploy)
- Blueprint withdraw passes removeVolumes: false (volumes preserved)
* docs: update Delete row to reflect conditional volume removal
The Delete row now describes that volumes are removed only when the
operator opts in, matching the behavior introduced by the downStack fix.
* fix: add capability gate for delete pruneVolumes and fix QA findings
Four P0 issues found in live QA:
P0-1/P0-4 - No capability gate on delete's pruneVolumes:
Add stack-delete-prune-volumes capability so the frontend hides the
"Also remove associated volumes" checkbox on nodes that don't support
conditional volume removal on delete. Without this, an operator on an
old node sees a VOLUMES KEPT promise the old node silently breaks.
Frontend-only gate: no API or proxy gate because the old node's
fallback (always destroy) is correct for the checked case.
P0-2 - Checkbox state leaked across dialogs:
Reset pruneVolumes in onConfirm before calling the parent, so a
previously checked box doesn't appear pre-checked when the dialog
opens for a different stack.
P0-3 - Delete not bound to the active node:
Capture activeNode.id at delete time and pass it as an explicit
nodeId to apiFetch, matching the Take Down pattern. Without this,
switching the active node while the dialog is open silently deletes
the wrong stack on the wrong node.
* fix: update test assertions for nodeId binding and showVolumeOption gate
P0-3 added nodeId to apiFetch DELETE calls — two useStackActions tests
now expect the parameter. P0-1 gated the volume checkbox behind
showVolumeOption — the confirming test now passes the prop.
* fix: gate volume hint on showVolumeOption to prevent false promise
On nodes without stack-delete-prune-volumes, volumes are always
destroyed. Showing VOLUMES KEPT was a lie. Now the hint is hidden
entirely when the capability is absent.
* fix: gate delete against nodes that cannot guarantee volume preservation
Hiding the checkbox and the misleading hint stopped the false promise but
not the data loss: an unchecked delete against a node lacking
stack-delete-prune-volumes still reached that node and its downStack()
still destroyed volumes unconditionally, now with no warning at all.
- remoteNodeProxy.ts: block an unacknowledged DELETE /stacks/:name
(no pruneVolumes=true) to a remote lacking the capability, mirroring
the existing removeVolumes gate on the down route. An explicit
pruneVolumes=true always proxies through since that matches what an
unsupported remote does anyway.
- DeleteStackDialog: rework around a three-state model (supported /
unsupported / unknown) instead of a boolean. A node whose capabilities
have not been confirmed (meta not yet fetched, or a failed probe) is
now treated like a supported node, not forced onto the destructive
path just because its state is unresolved.
- Fix deleteStack's error toast, which surfaced the raw JSON response
body instead of the parsed error message.
- Fix CreateStackDialog's orphan-stack rollback (docker-run import),
which silently no-op'd against a node requiring acknowledgement.
- Update node-compatibility.mdx and stack-management.mdx to describe
the new gate.
* test: advertise stack-delete-prune-volumes on the scoped-evidence fixtures
These mock remotes simulate nodes capable enough to run scoped-stack-auth-evidence
RBAC and were pinned before stack-delete-prune-volumes existed, so the new delete
gate now blocked their unacknowledged DELETE calls before reaching the mock server,
failing the grant-tuple-cleanup assertions the tests actually check.
This commit is contained in:
@@ -513,18 +513,19 @@ describe('BlueprintService local withdraw clears stack-scoped role assignments',
|
||||
);
|
||||
const { ComposeService } = await import('../services/ComposeService');
|
||||
const { FileSystemService } = await import('../services/FileSystemService');
|
||||
vi.spyOn(ComposeService.prototype, 'downStack').mockResolvedValue(undefined);
|
||||
const downStackSpy = vi.spyOn(ComposeService.prototype, 'downStack').mockResolvedValue(undefined);
|
||||
const deleteStackSpy = vi.spyOn(FileSystemService.prototype, 'deleteStack').mockResolvedValue(undefined);
|
||||
|
||||
return { bp, node, nodeId, userId, deleteStackSpy, db };
|
||||
return { bp, node, nodeId, userId, downStackSpy, deleteStackSpy, db };
|
||||
}
|
||||
|
||||
it('clears the target assignment after successful filesystem deletion and preserves unrelated grants', async () => {
|
||||
const { bp, node, userId, deleteStackSpy, db } = await arrangeLocalWithdraw();
|
||||
const { bp, node, userId, downStackSpy, deleteStackSpy, db } = await arrangeLocalWithdraw();
|
||||
|
||||
const outcome = await BlueprintService.getInstance().withdrawFromNode(bp, node);
|
||||
|
||||
expect(outcome.status).toBe('withdrawn');
|
||||
expect(downStackSpy).toHaveBeenCalledWith(bp.name, { removeVolumes: false });
|
||||
expect(deleteStackSpy).toHaveBeenCalledWith(bp.name);
|
||||
expect(db.getDeployment(bp.id, node.id)).toBeUndefined();
|
||||
expect(hasAssignment(userId, 'stack', bp.name)).toBe(false);
|
||||
|
||||
@@ -1328,7 +1328,7 @@ describe('ComposeService - withRegistryAuth', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── downStack ──────────────────────────────────────────────────────────
|
||||
// ── runDown ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('ComposeService - runDown', () => {
|
||||
it('runs plain docker compose down by default', async () => {
|
||||
@@ -1374,7 +1374,33 @@ describe('ComposeService - runDown', () => {
|
||||
// ── downStack ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('ComposeService - downStack', () => {
|
||||
it('runs docker compose down with volumes and remove-orphans', async () => {
|
||||
it('runs docker compose down with volumes and remove-orphans when removeVolumes is true', async () => {
|
||||
setupAutoCloseSpawn();
|
||||
|
||||
const svc = ComposeService.getInstance(1);
|
||||
await svc.downStack('my-stack', { removeVolumes: true });
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledWith(
|
||||
'docker',
|
||||
['compose', 'down', '--volumes', '--remove-orphans'],
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('runs docker compose down without --volumes when removeVolumes is false', async () => {
|
||||
setupAutoCloseSpawn();
|
||||
|
||||
const svc = ComposeService.getInstance(1);
|
||||
await svc.downStack('my-stack', { removeVolumes: false });
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledWith(
|
||||
'docker',
|
||||
['compose', 'down', '--remove-orphans'],
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults to no --volumes when options are omitted (safe default)', async () => {
|
||||
setupAutoCloseSpawn();
|
||||
|
||||
const svc = ComposeService.getInstance(1);
|
||||
@@ -1382,7 +1408,7 @@ describe('ComposeService - downStack', () => {
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledWith(
|
||||
'docker',
|
||||
['compose', 'down', '--volumes', '--remove-orphans'],
|
||||
['compose', 'down', '--remove-orphans'],
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
@@ -52,7 +52,7 @@ function evidenceRemote(): http.Server {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
version: '0.93.0',
|
||||
capabilities: ['cross-node-rbac', 'scoped-stack-auth-evidence'],
|
||||
capabilities: ['cross-node-rbac', 'scoped-stack-auth-evidence', 'stack-delete-prune-volumes'],
|
||||
}));
|
||||
return;
|
||||
}
|
||||
@@ -88,7 +88,7 @@ function failDeleteRemote(): http.Server {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
version: '0.93.0',
|
||||
capabilities: ['cross-node-rbac', 'scoped-stack-auth-evidence'],
|
||||
capabilities: ['cross-node-rbac', 'scoped-stack-auth-evidence', 'stack-delete-prune-volumes'],
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Gateway preflight for DELETE /stacks/:name on remote nodes. See
|
||||
* isUnacknowledgedStackDelete in remoteNodeProxy.ts for why this gate exists; this file
|
||||
* covers what the gate must get right: block an unacknowledged delete to an unsupported
|
||||
* remote (whatever the caller's role, and with or without a trailing slash), accept only
|
||||
* the exact string "true" as acknowledgement, and forward without a capability probe once
|
||||
* removal is acknowledged or the remote advertises support.
|
||||
*/
|
||||
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: 'deleted' }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function listen(server: http.Server): Promise<number> {
|
||||
await new Promise<void>((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: 'delvol-viewer', password_hash: hash, role: 'viewer' });
|
||||
const viewer = db.getUserByUsername('delvol-viewer')!;
|
||||
viewerBearer = jwt.sign({ username: 'delvol-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-delete-prune-volumes'], capPaths);
|
||||
noCapServer = metaServer(['cross-node-rbac', 'stack-down-remove-volumes'], noCapPaths);
|
||||
const capPort = await listen(capServer);
|
||||
const noCapPort = await listen(noCapServer);
|
||||
|
||||
capNodeId = db.addNode({
|
||||
name: 'delvol-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: 'delvol-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<void>((resolve) => capServer.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => noCapServer.close(() => resolve()));
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
describe('remote proxy stack-delete volume gate', () => {
|
||||
it('returns 400 for an unacknowledged delete when remote lacks stack-delete-prune-volumes (admin)', async () => {
|
||||
noCapPaths.length = 0;
|
||||
const res = await request(app)
|
||||
.delete('/api/stacks/web')
|
||||
.set('Authorization', `Bearer ${adminBearer}`)
|
||||
.set('x-node-id', String(noCapNodeId));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('capability_unavailable');
|
||||
expect(noCapPaths.some(p => p.includes('/api/stacks/web') && !p.startsWith('/api/meta'))).toBe(false);
|
||||
expect(noCapPaths.some(p => p.startsWith('/api/meta'))).toBe(true);
|
||||
});
|
||||
|
||||
// A viewer has no stack:delete, so this also pins gate order: the data-safety 400 wins
|
||||
// over the permission 403 that the named-stack pre-check would otherwise return.
|
||||
it('returns 400, not 403, for an unacknowledged delete by a viewer when remote lacks capability', async () => {
|
||||
noCapPaths.length = 0;
|
||||
const res = await request(app)
|
||||
.delete('/api/stacks/web')
|
||||
.set('Authorization', `Bearer ${viewerBearer}`)
|
||||
.set('x-node-id', String(noCapNodeId));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('capability_unavailable');
|
||||
});
|
||||
|
||||
it('proxies through when the operator explicitly acknowledges volume removal (pruneVolumes=true), even without the capability, and never queries /api/meta', async () => {
|
||||
noCapPaths.length = 0;
|
||||
const res = await request(app)
|
||||
.delete('/api/stacks/web?pruneVolumes=true')
|
||||
.set('Authorization', `Bearer ${adminBearer}`)
|
||||
.set('x-node-id', String(noCapNodeId));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(noCapPaths.some(p => p.includes('/api/stacks/web?pruneVolumes=true'))).toBe(true);
|
||||
expect(noCapPaths.some(p => p.startsWith('/api/meta'))).toBe(false);
|
||||
});
|
||||
|
||||
it('treats only the exact string "true" as acknowledgement, blocking loose truthy values like "1"', async () => {
|
||||
noCapPaths.length = 0;
|
||||
const res = await request(app)
|
||||
.delete('/api/stacks/web?pruneVolumes=1')
|
||||
.set('Authorization', `Bearer ${adminBearer}`)
|
||||
.set('x-node-id', String(noCapNodeId));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('capability_unavailable');
|
||||
});
|
||||
|
||||
it('proxies an unacknowledged delete through when the remote advertises stack-delete-prune-volumes', async () => {
|
||||
capPaths.length = 0;
|
||||
const res = await request(app)
|
||||
.delete('/api/stacks/web')
|
||||
.set('Authorization', `Bearer ${adminBearer}`)
|
||||
.set('x-node-id', String(capNodeId));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(capPaths.some(p => p.includes('/api/stacks/web'))).toBe(true);
|
||||
});
|
||||
|
||||
it('still gates an unacknowledged delete with a trailing slash', async () => {
|
||||
noCapPaths.length = 0;
|
||||
const res = await request(app)
|
||||
.delete('/api/stacks/web/')
|
||||
.set('Authorization', `Bearer ${adminBearer}`)
|
||||
.set('x-node-id', String(noCapNodeId));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('capability_unavailable');
|
||||
expect(noCapPaths.some(p => p.includes('/api/stacks/web/') && !p.startsWith('/api/meta'))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@
|
||||
* longer skews the Security Overview. Image scans are intentionally left to the
|
||||
* janitor reconciler (images are shared and may still exist on the host).
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi, type Mock } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
|
||||
|
||||
@@ -14,6 +14,8 @@ let DatabaseService: typeof import('../services/DatabaseService').DatabaseServic
|
||||
let ComposeService: typeof import('../services/ComposeService').ComposeService;
|
||||
let FileSystemService: typeof import('../services/FileSystemService').FileSystemService;
|
||||
let MeshService: typeof import('../services/MeshService').MeshService;
|
||||
let DeployedStackDeletionService: typeof import('../services/DeployedStackDeletionService').DeployedStackDeletionService;
|
||||
let DockerController: typeof import('../services/DockerController').default;
|
||||
let adminCookie: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -22,6 +24,8 @@ beforeAll(async () => {
|
||||
({ ComposeService } = await import('../services/ComposeService'));
|
||||
({ FileSystemService } = await import('../services/FileSystemService'));
|
||||
({ MeshService } = await import('../services/MeshService'));
|
||||
({ DeployedStackDeletionService } = await import('../services/DeployedStackDeletionService'));
|
||||
({ default: DockerController } = await import('../services/DockerController'));
|
||||
({ app } = await import('../index'));
|
||||
adminCookie = await loginAsTestAdmin(app);
|
||||
});
|
||||
@@ -90,3 +94,68 @@ describe('DELETE /api/stacks/:stackName purges scan data', () => {
|
||||
expect(db.getDistinctScanImageRefs(nodeId)).toEqual(['nginx:1']);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Wiring: pruneVolumes flag reaches downStack ────────────────────────
|
||||
|
||||
describe('DELETE /api/stacks/:stackName wires pruneVolumes to downStack', () => {
|
||||
let downStackSpy: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.spyOn(FileSystemService.prototype, 'deleteStack').mockResolvedValue(undefined);
|
||||
vi.spyOn(MeshService.getInstance(), 'optOutStack').mockResolvedValue(undefined);
|
||||
downStackSpy = vi.spyOn(ComposeService.prototype, 'downStack').mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('passes removeVolumes: true when pruneVolumes=true', async () => {
|
||||
const res = await request(app).delete('/api/stacks/web?pruneVolumes=true').set('Cookie', adminCookie);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const lastArgs = downStackSpy.mock.calls.at(-1);
|
||||
expect(lastArgs?.[1]).toEqual({ removeVolumes: true });
|
||||
});
|
||||
|
||||
it('passes a falsy removeVolumes when pruneVolumes is absent', async () => {
|
||||
const res = await request(app).delete('/api/stacks/web').set('Cookie', adminCookie);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const lastArgs = downStackSpy.mock.calls.at(-1);
|
||||
expect(lastArgs?.[1]?.removeVolumes).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Continuation guard: persisted flag beats input flag ─────────────────
|
||||
|
||||
describe('DeployedStackDeletionService continuation', () => {
|
||||
it('uses intent.prune_volumes_requested (not input.pruneVolumes) on resumed deletion', async () => {
|
||||
vi.restoreAllMocks();
|
||||
const downStackSpy = vi.spyOn(ComposeService.prototype, 'downStack').mockResolvedValue(undefined);
|
||||
vi.spyOn(FileSystemService.prototype, 'deleteStack').mockResolvedValue(undefined);
|
||||
vi.spyOn(MeshService.getInstance(), 'optOutStack').mockResolvedValue(undefined);
|
||||
vi.spyOn(DockerController.prototype, 'pruneManagedOnly').mockResolvedValue({ success: true, reclaimedBytes: 0 });
|
||||
|
||||
const db = DatabaseService.getInstance();
|
||||
const nodeId = db.getNodes()[0].id;
|
||||
|
||||
const intentId = 'test-continuation-guard';
|
||||
db.getDb().prepare(`
|
||||
INSERT OR REPLACE INTO stack_update_cleanup_pending
|
||||
(id, node_id, stack_name, status, target_kind, rollback_tags_json, override_paths_json, prune_volumes_requested, required_blueprint_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, 'prepared', 'local_socket', '[]', '[]', 0, NULL, ?, ?)
|
||||
`).run(intentId, nodeId, 'web', Date.now(), Date.now());
|
||||
|
||||
const svc = DeployedStackDeletionService.getInstance();
|
||||
await svc.deleteDeployedStack({
|
||||
nodeId,
|
||||
stackName: 'web',
|
||||
pruneVolumes: true,
|
||||
actor: 'test',
|
||||
continuationIntentId: intentId,
|
||||
});
|
||||
|
||||
db.getDb().prepare('DELETE FROM stack_update_cleanup_pending WHERE id = ?').run(intentId);
|
||||
|
||||
const lastArgs = downStackSpy.mock.calls.at(-1);
|
||||
expect(lastArgs?.[1]?.removeVolumes).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import { isProxyExemptPath } from '../helpers/proxyExemptPaths';
|
||||
import { remoteSupportsCrossNodeRbac, remoteAdvertisesCapability } from '../helpers/remoteCapabilities';
|
||||
import {
|
||||
STACK_DOWN_REMOVE_VOLUMES_CAPABILITY,
|
||||
STACK_DELETE_PRUNE_VOLUMES_CAPABILITY,
|
||||
SERVICE_SCOPED_UPDATE_CAPABILITY,
|
||||
SERVICE_SCOPED_STACK_ALERT_CAPABILITY,
|
||||
SCOPED_STACK_AUTH_EVIDENCE_CAPABILITY,
|
||||
@@ -316,6 +317,17 @@ export function createRemoteProxyMiddleware(): RequestHandler {
|
||||
}
|
||||
}
|
||||
|
||||
if (isUnacknowledgedStackDelete(req)) {
|
||||
const supported = await remoteAdvertisesCapability(req.nodeId, STACK_DELETE_PRUNE_VOLUMES_CAPABILITY);
|
||||
if (!supported) {
|
||||
res.status(400).json({
|
||||
error: 'This node cannot guarantee volumes are preserved on delete. Upgrade it before deleting this stack.',
|
||||
code: 'capability_unavailable',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (isServiceScopedUpdateRoute(req)) {
|
||||
const supported = await remoteAdvertisesCapability(req.nodeId, SERVICE_SCOPED_UPDATE_CAPABILITY);
|
||||
if (!supported) {
|
||||
@@ -645,6 +657,21 @@ function isStackDownWithRemoveVolumes(req: Request): boolean {
|
||||
return req.query.removeVolumes === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /stacks/:stackName without an explicit ?pruneVolumes=true (path is post-/api
|
||||
* strip). downStack() hardcoded --volumes on every delete before stack-delete-prune-volumes
|
||||
* existed, unlike runDown which was already volume-safe. So an unacknowledged delete
|
||||
* forwarded to a remote lacking the capability would silently destroy volumes the
|
||||
* operator asked to keep, with no local route or UI check able to catch it. Gate the
|
||||
* unacknowledged path here; an explicit pruneVolumes=true matches what an unsupported
|
||||
* remote will do anyway and needs no gate.
|
||||
*/
|
||||
function isUnacknowledgedStackDelete(req: Request): boolean {
|
||||
if (req.method !== 'DELETE') return false;
|
||||
if (!/^\/stacks\/[^/]+\/?$/.test(req.path)) return false;
|
||||
return req.query.pruneVolumes !== 'true';
|
||||
}
|
||||
|
||||
/** Nested service update/restore/recovery routes (path is post-/api strip). */
|
||||
function isServiceScopedUpdateRoute(req: Request): boolean {
|
||||
if (req.method === 'GET') {
|
||||
|
||||
@@ -164,7 +164,7 @@ templatesRouter.post('/deploy', authMiddleware, async (req: Request, res: Respon
|
||||
let dockerDownCompleted = true;
|
||||
let fileDeleteCompleted = true;
|
||||
try {
|
||||
await ComposeService.getInstance(req.nodeId).downStack(stackName);
|
||||
await ComposeService.getInstance(req.nodeId).downStack(stackName, { removeVolumes: true });
|
||||
} catch (downErr) {
|
||||
dockerDownCompleted = false;
|
||||
console.error("[Templates] Rollback Stage 1 (Docker down) failed:", downErr);
|
||||
|
||||
@@ -59,6 +59,7 @@ export const CAPABILITIES = [
|
||||
'compose-storage',
|
||||
'cross-node-rbac',
|
||||
'stack-down-remove-volumes',
|
||||
'stack-delete-prune-volumes',
|
||||
'guided-external-network-preflight',
|
||||
'service-scoped-update',
|
||||
'service-scoped-stack-alert',
|
||||
@@ -97,6 +98,11 @@ export const NOTIFICATION_SUPPRESSION_REPLICA_RETRACTION_CAPABILITY =
|
||||
/** 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;
|
||||
|
||||
/** Capability for honoring `?pruneVolumes` on DELETE /stacks/:name. Nodes without this
|
||||
* capability always destroy volumes on delete (pre-existing behavior); nodes with it
|
||||
* honor the operator's checkbox choice. */
|
||||
export const STACK_DELETE_PRUNE_VOLUMES_CAPABILITY = 'stack-delete-prune-volumes' as const satisfies Capability;
|
||||
|
||||
/** Capability for the nested per-service update/restore routes and the `effective-services` model they read. */
|
||||
export const SERVICE_SCOPED_UPDATE_CAPABILITY = 'service-scoped-update' as const satisfies Capability;
|
||||
|
||||
|
||||
@@ -1132,10 +1132,13 @@ export class ComposeService {
|
||||
}, sendOutput);
|
||||
}
|
||||
|
||||
public async downStack(stackName: string): Promise<void> {
|
||||
public async downStack(stackName: string, options?: { removeVolumes?: boolean }): Promise<void> {
|
||||
const stackPath = path.join(this.baseDir, stackName);
|
||||
try {
|
||||
await this.execute('docker', await this.authoredComposeArgs(stackName, ['down', '--volumes', '--remove-orphans']), stackPath, undefined, false);
|
||||
const args = options?.removeVolumes
|
||||
? ['down', '--volumes', '--remove-orphans']
|
||||
: ['down', '--remove-orphans'];
|
||||
await this.execute('docker', await this.authoredComposeArgs(stackName, args), stackPath, undefined, false);
|
||||
} catch (error) {
|
||||
console.warn(`[Teardown] Docker down failed or nothing to clean up for ${sanitizeForLog(stackName)}`);
|
||||
}
|
||||
|
||||
@@ -302,7 +302,9 @@ export class DeployedStackDeletionService {
|
||||
|
||||
if (!skipPhysical) {
|
||||
try {
|
||||
await ComposeService.getInstance(nodeId).downStack(stackName);
|
||||
await ComposeService.getInstance(nodeId).downStack(stackName, {
|
||||
removeVolumes: intent.prune_volumes_requested === 1,
|
||||
});
|
||||
} catch (downErr) {
|
||||
console.warn(
|
||||
'[DeployedStackDeletion] Compose down failed or no-op for %s:',
|
||||
|
||||
Reference in New Issue
Block a user