feat(fleet): show itemized prune plans (#1734)

* feat(fleet): itemize prune review plans

Build and display fingerprint-bound prune candidates for every reviewed
fleet node. Preflight all node plans before mutation and preserve detailed
removed, skipped, failed, and partial outcomes.

Add safe resource metadata projection, managed ownership attribution,
runtime contract validation, transport parity coverage, and operator docs.

Closes #1724

* fix(security): harden stack path lookup

Use a Map for Compose working-directory ownership resolution so untrusted
path strings cannot become object property writes.

* fix(fleet): harden prune execution safeguards
This commit is contained in:
Anso
2026-07-29 14:30:18 -04:00
committed by GitHub
parent 538a41b771
commit 44d6078241
21 changed files with 2582 additions and 869 deletions
@@ -19,6 +19,8 @@ const pruneManagedOnly = vi.fn();
const pruneSystem = vi.fn();
const estimateManagedReclaim = vi.fn();
const estimateSystemReclaim = vi.fn();
const buildPrunePlan = vi.fn();
const executePrunePlan = vi.fn();
const getContainersByStack = vi.fn();
const stopContainer = vi.fn();
const restartContainer = vi.fn();
@@ -47,6 +49,8 @@ vi.mock('../services/DockerController', () => ({
pruneSystem,
estimateManagedReclaim,
estimateSystemReclaim,
buildPrunePlan,
executePrunePlan,
getContainersByStack,
stopContainer,
restartContainer,
@@ -90,6 +94,11 @@ beforeEach(() => {
pruneSystem.mockResolvedValue({ success: true, reclaimedBytes: 0 });
estimateManagedReclaim.mockResolvedValue({ reclaimableBytes: 0 });
estimateSystemReclaim.mockResolvedValue({ reclaimableBytes: 0 });
buildPrunePlan.mockResolvedValue({
nodeId: 1, scope: 'managed', targets: ['images'], items: [], reclaimableBytes: 0,
fingerprint: 'empty-plan', createdAt: 1,
});
executePrunePlan.mockResolvedValue({ success: true, reclaimedBytes: 0, outcomes: [] });
getContainersByStack.mockResolvedValue([{ Id: 'container-1' }]);
stopContainer.mockResolvedValue(undefined);
restartContainer.mockResolvedValue(undefined);
@@ -1000,8 +1009,19 @@ describe('POST /api/fleet/labels/fleet-stop with dryRun: true', () => {
});
describe('POST /api/fleet/labels/fleet-prune with dryRun: true', () => {
it('routes to estimateManagedReclaim and marks each target dryRun: true', async () => {
estimateManagedReclaim.mockResolvedValue({ reclaimableBytes: 2048 });
it('returns one multi-target itemized plan without calling prune methods', async () => {
buildPrunePlan.mockResolvedValue({
nodeId: 1,
scope: 'managed',
targets: ['volumes', 'images'],
items: [
{ target: 'volumes', id: 'data', name: 'data', sizeBytes: 512, managed: true, reason: 'unused' },
{ target: 'images', id: 'image', name: 'app:latest', sizeBytes: 1536, managed: true, reason: 'unused' },
],
reclaimableBytes: 2048,
fingerprint: 'itemized-plan',
createdAt: 1,
});
const res = await request(app)
.post('/api/fleet/labels/fleet-prune')
.set('Authorization', authHeader)
@@ -1009,41 +1029,105 @@ describe('POST /api/fleet/labels/fleet-prune with dryRun: true', () => {
expect(res.status).toBe(200);
const node = res.body.results[0];
expect(node.reachable).toBe(true);
expect(node.fingerprint).toBe('itemized-plan');
expect(node.items).toHaveLength(2);
expect(node.targets).toHaveLength(2);
for (const t of node.targets) {
expect(t.success).toBe(true);
expect(t.reclaimedBytes).toBe(2048);
expect(t.dryRun).toBe(true);
}
expect(node.targets).toEqual([
{ target: 'images', success: true, reclaimedBytes: 1536, dryRun: true },
{ target: 'volumes', success: true, reclaimedBytes: 512, dryRun: true },
]);
expect(pruneManagedOnly).not.toHaveBeenCalled();
expect(pruneSystem).not.toHaveBeenCalled();
expect(estimateManagedReclaim).toHaveBeenCalledTimes(2);
expect(buildPrunePlan).toHaveBeenCalledTimes(1);
expect(invalidateNodeCaches).not.toHaveBeenCalled();
});
it('routes to estimateSystemReclaim when scope is "all"', async () => {
estimateSystemReclaim.mockResolvedValue({ reclaimableBytes: 8192 });
it('loads known stacks and builds attribution even when scope is "all"', async () => {
const res = await request(app)
.post('/api/fleet/labels/fleet-prune')
.set('Authorization', authHeader)
.send({ targets: ['images'], scope: 'all', dryRun: true });
expect(res.status).toBe(200);
expect(estimateManagedReclaim).not.toHaveBeenCalled();
expect(estimateSystemReclaim).toHaveBeenCalled();
expect(estimateSystemReclaim).not.toHaveBeenCalled();
expect(buildPrunePlan).toHaveBeenCalledWith(['images'], 'all', ['alpha', 'beta'], expect.any(Number), expect.any(Function));
expect(pruneManagedOnly).not.toHaveBeenCalled();
expect(pruneSystem).not.toHaveBeenCalled();
expect(res.body.results[0].targets[0].reclaimedBytes).toBe(8192);
});
it('still invokes pruneManagedOnly when dryRun is omitted', async () => {
pruneManagedOnly.mockResolvedValue({ success: true, reclaimedBytes: 512 });
it('requires reviewed roster and fingerprints when dryRun is omitted', async () => {
const res = await request(app)
.post('/api/fleet/labels/fleet-prune')
.set('Authorization', authHeader)
.send({ targets: ['images'], scope: 'managed' });
expect(res.status).toBe(200);
expect(pruneManagedOnly).toHaveBeenCalled();
expect(res.status).toBe(400);
expect(pruneManagedOnly).not.toHaveBeenCalled();
expect(executePrunePlan).not.toHaveBeenCalled();
expect(estimateManagedReclaim).not.toHaveBeenCalled();
expect(res.body.results[0].targets[0].dryRun).toBeUndefined();
});
it('invalidates local caches only after an item is removed', async () => {
const local = db.getNodes().find((node) => node.type === 'local')!;
buildPrunePlan.mockResolvedValue({
nodeId: local.id,
scope: 'managed',
targets: ['images'],
items: [{ target: 'images', id: 'image', name: 'app:latest', managed: true, reason: 'unused' }],
reclaimableBytes: 0,
fingerprint: 'reviewed-plan',
createdAt: 1,
});
executePrunePlan.mockResolvedValue({
success: true,
reclaimedBytes: 0,
outcomes: [{ target: 'images', id: 'image', status: 'removed' }],
});
const res = await request(app)
.post('/api/fleet/labels/fleet-prune')
.set('Authorization', authHeader)
.send({
targets: ['images'],
scope: 'managed',
dryRun: false,
reviewedNodes: [{ nodeId: local.id, reachable: true }],
plans: [{ nodeId: local.id, fingerprint: 'reviewed-plan' }],
});
expect(res.status).toBe(200);
expect(executePrunePlan).toHaveBeenCalledTimes(1);
expect(invalidateNodeCaches).toHaveBeenCalledWith(local.id);
});
it('does not invalidate local caches for empty, skipped, or failed outcomes', async () => {
const local = db.getNodes().find((node) => node.type === 'local')!;
const cases = [
{ items: [], outcomes: [] },
{
items: [{ target: 'images', id: 'image', name: 'app:latest', managed: true, reason: 'unused', image: { references: ['app:latest'] } }],
outcomes: [{ target: 'images', id: 'image', status: 'skipped', reason: 'became active' }],
},
{
items: [{ target: 'images', id: 'image', name: 'app:latest', managed: true, reason: 'unused', image: { references: ['app:latest'] } }],
outcomes: [{ target: 'images', id: 'image', status: 'failed', error: 'remove failed' }],
},
] as const;
for (const [index, testCase] of cases.entries()) {
const fingerprint = `reviewed-plan-${index}`;
buildPrunePlan.mockResolvedValue({
nodeId: local.id, scope: 'managed', targets: ['images'], items: [...testCase.items],
reclaimableBytes: 0, fingerprint, createdAt: 1,
});
executePrunePlan.mockResolvedValue({ success: true, reclaimedBytes: 0, outcomes: [...testCase.outcomes] });
invalidateNodeCaches.mockClear();
const res = await request(app)
.post('/api/fleet/labels/fleet-prune')
.set('Authorization', authHeader)
.send({
targets: ['images'], scope: 'managed', dryRun: false,
reviewedNodes: [{ nodeId: local.id, reachable: true }],
plans: [{ nodeId: local.id, fingerprint }],
});
expect(res.status).toBe(200);
expect(invalidateNodeCaches).not.toHaveBeenCalled();
}
});
});
@@ -40,6 +40,8 @@ let proxyNodeId: number;
let NodeRegistry: typeof import('../services/NodeRegistry').NodeRegistry;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let LicenseService: typeof import('../services/LicenseService').LicenseService;
let DockerController: typeof import('../services/DockerController').default;
let FileSystemService: typeof import('../services/FileSystemService').FileSystemService;
beforeAll(async () => {
tmpDir = await setupTestDb();
@@ -47,6 +49,8 @@ beforeAll(async () => {
({ NodeRegistry } = await import('../services/NodeRegistry'));
({ DatabaseService } = await import('../services/DatabaseService'));
({ LicenseService } = await import('../services/LicenseService'));
({ default: DockerController } = await import('../services/DockerController'));
({ FileSystemService } = await import('../services/FileSystemService'));
const db = DatabaseService.getInstance();
pilotNodeId = db.addNode({
@@ -259,7 +263,19 @@ describe('POST /api/fleet/labels/fleet-prune (pilot-agent dispatch)', () => {
mockFetch((url, init) => {
const headers = (init?.headers as Record<string, string>) ?? {};
calls.push({ url, auth: headers.Authorization });
return new Response(JSON.stringify({ success: true, reclaimedBytes: 999, dryRun: true }), {
return new Response(JSON.stringify({
nodeId: 1,
scope: 'managed',
targets: ['images'],
items: [{
target: 'images', id: 'sha256:pilot', name: 'pilot/app:latest', sizeBytes: 999,
managed: true, reason: 'Image is not used by any container', stackName: 'app',
image: { references: ['pilot/app:latest'] },
}],
reclaimableBytes: 999,
fingerprint: 'pilot-plan',
createdAt: 1,
}), {
status: 200, headers: { 'content-type': 'application/json' },
});
});
@@ -272,11 +288,75 @@ describe('POST /api/fleet/labels/fleet-prune (pilot-agent dispatch)', () => {
expect(res.status).toBe(200);
const pilotCall = calls.find(c => c.url.startsWith(PILOT_LOOPBACK));
expect(pilotCall).toBeDefined();
expect(pilotCall?.url).toBe(`${PILOT_LOOPBACK}/api/system/prune/plan`);
expect(pilotCall?.auth).toBeUndefined();
const pilotResult = res.body.results.find((r: { nodeId: number }) => r.nodeId === pilotNodeId);
expect(pilotResult.reachable).toBe(true);
expect(pilotResult.targets[0].reclaimedBytes).toBe(999);
});
it('uses one plan request and one fingerprint-bound execute request per remote', async () => {
mockPaidTier();
mockTargets();
const local = DatabaseService.getInstance().getNodes().find((node) => node.type === 'local')!;
const localPlan = {
nodeId: local.id, scope: 'managed' as const, targets: ['images' as const], items: [],
reclaimableBytes: 0, fingerprint: 'local-plan', createdAt: 1,
};
const executePrunePlan = vi.fn().mockResolvedValue({ success: true, reclaimedBytes: 0, outcomes: [] });
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
buildPrunePlan: vi.fn().mockResolvedValue(localPlan),
executePrunePlan,
} as unknown as ReturnType<typeof DockerController.getInstance>);
vi.spyOn(FileSystemService.prototype, 'getStacks').mockResolvedValue([]);
const calls: Array<{ url: string; auth: string | undefined; body: Record<string, unknown> }> = [];
mockFetch((url, init) => {
const headers = (init?.headers as Record<string, string>) ?? {};
const body = JSON.parse(String(init?.body)) as Record<string, unknown>;
calls.push({ url, auth: headers.Authorization, body });
const pilot = url.startsWith(PILOT_LOOPBACK);
if (url.endsWith('/api/system/prune/plan')) {
return new Response(JSON.stringify({
nodeId: 1, scope: 'managed', targets: ['images'], items: [], reclaimableBytes: 0,
fingerprint: pilot ? 'pilot-plan' : 'proxy-plan', createdAt: 1,
}), { status: 200, headers: { 'content-type': 'application/json' } });
}
return new Response(JSON.stringify({ success: true, reclaimedBytes: 0, outcomes: [] }), {
status: 200, headers: { 'content-type': 'application/json' },
});
});
const res = await request(app)
.post('/api/fleet/labels/fleet-prune')
.set('Authorization', authHeader)
.send({
targets: ['images'], scope: 'managed', dryRun: false,
reviewedNodes: [
{ nodeId: local.id, reachable: true },
{ nodeId: pilotNodeId, reachable: true },
{ nodeId: proxyNodeId, reachable: true },
],
plans: [
{ nodeId: local.id, fingerprint: 'local-plan' },
{ nodeId: pilotNodeId, fingerprint: 'pilot-plan' },
{ nodeId: proxyNodeId, fingerprint: 'proxy-plan' },
],
});
expect(res.status).toBe(200);
expect(executePrunePlan).toHaveBeenCalledTimes(1);
const pilotCalls = calls.filter((call) => call.url.startsWith(PILOT_LOOPBACK));
expect(pilotCalls.map((call) => call.url)).toEqual([
`${PILOT_LOOPBACK}/api/system/prune/plan`,
`${PILOT_LOOPBACK}/api/system/prune/system`,
]);
expect(pilotCalls.every((call) => call.auth === undefined)).toBe(true);
expect(pilotCalls[1].body).toMatchObject({ targets: ['images'], planFingerprint: 'pilot-plan' });
const proxyCalls = calls.filter((call) => call.url.startsWith(PROXY_URL));
expect(proxyCalls).toHaveLength(2);
expect(proxyCalls.every((call) => call.auth === `Bearer ${PROXY_TOKEN}`)).toBe(true);
});
});
describe('GET /api/image-updates/fleet (pilot inclusion)', () => {
@@ -1,8 +1,7 @@
/**
* F-6 regression: fleet routes that call estimateSystemReclaim on local
* nodes must also bound the slow `docker system df` call (8s) and surface
* a recognizable timeout message to the operator, matching the
* /api/system/prune/estimate behavior.
* F-6 regression: Fleet itemized plan enumeration and byte estimation both
* bound the slow `docker system df` call (8s) and surface a recognizable
* timeout message to the operator.
*
* Covers:
* - POST /api/fleet/labels/fleet-prune with dryRun: true
@@ -42,17 +41,27 @@ afterEach(() => {
activeBulkActions.clear();
});
function stubLocalEstimate(impl: () => Promise<{ reclaimableBytes: number }>) {
function stubLocalEstimate(
estimateImpl: () => Promise<{ reclaimableBytes: number }>,
planImpl: () => Promise<unknown> = async () => ({
nodeId: 1, scope: 'all', targets: ['volumes'], items: [], reclaimableBytes: 0,
fingerprint: 'empty', createdAt: 1,
}),
) {
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
estimateSystemReclaim: vi.fn().mockImplementation(impl),
estimateSystemReclaim: vi.fn().mockImplementation(estimateImpl),
estimateManagedReclaim: vi.fn().mockResolvedValue({ reclaimableBytes: 0 }),
buildPrunePlan: vi.fn().mockImplementation(planImpl),
} as unknown as ReturnType<typeof DockerController.getInstance>);
vi.spyOn(FileSystemService.prototype, 'getStacks').mockResolvedValue([]);
}
describe('Fleet prune routes bound docker df at 8s on local nodes (F-6)', () => {
it('POST /api/fleet/labels/fleet-prune dry-run surfaces a busy-daemon error on local timeout', async () => {
stubLocalEstimate(() => new Promise(() => { /* never resolves */ }));
stubLocalEstimate(
() => Promise.resolve({ reclaimableBytes: 0 }),
() => new Promise(() => { /* never resolves */ }),
);
const t0 = Date.now();
const res = await request(app)
@@ -86,7 +95,21 @@ describe('Fleet prune routes bound docker df at 8s on local nodes (F-6)', () =>
}, 20_000);
it('fleet-prune dry-run succeeds normally when estimateSystemReclaim resolves quickly', async () => {
stubLocalEstimate(() => Promise.resolve({ reclaimableBytes: 256 }));
stubLocalEstimate(
() => Promise.resolve({ reclaimableBytes: 256 }),
async () => ({
nodeId: 1,
scope: 'all',
targets: ['volumes'],
items: [{
target: 'volumes', id: 'volume-a', name: 'volume-a', sizeBytes: 256,
managed: false, reason: 'Volume is not referenced by any container',
}],
reclaimableBytes: 256,
fingerprint: 'volume-plan',
createdAt: 1,
}),
);
const res = await request(app)
.post('/api/fleet/labels/fleet-prune')
+426 -183
View File
@@ -1,17 +1,12 @@
/**
* Tests for the fleet-wide Docker prune endpoint. Covers auth, tier gating,
* input validation, local node orchestration with mocked DockerController,
* remote-node fan-out with mocked fetch, lock contention, and partial failures.
*/
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
import { cleanupTestDb, setupTestDb, TEST_JWT_SECRET, TEST_USERNAME } from './helpers/setupTestDb';
import type { PruneItemOutcome, PrunePlan, PrunePlanItem } from '../services/prunePlan';
let tmpDir: string;
let app: import('express').Express;
let authHeader: string;
let LicenseService: typeof import('../services/LicenseService').LicenseService;
let DockerController: typeof import('../services/DockerController').default;
let FileSystemService: typeof import('../services/FileSystemService').FileSystemService;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
@@ -20,13 +15,11 @@ let activeBulkActions: typeof import('../routes/labels').activeBulkActions;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
({ LicenseService } = await import('../services/LicenseService'));
({ default: DockerController } = await import('../services/DockerController'));
({ FileSystemService } = await import('../services/FileSystemService'));
({ DatabaseService } = await import('../services/DatabaseService'));
({ activeBulkActions } = await import('../routes/labels'));
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
authHeader = `Bearer ${token}`;
authHeader = `Bearer ${jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '10m' })}`;
});
afterAll(() => cleanupTestDb(tmpDir));
@@ -36,198 +29,448 @@ afterEach(() => {
activeBulkActions.clear();
});
function mockTier(tier: 'paid' | 'community') {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue(tier);
function item(overrides: Partial<PrunePlanItem> = {}): PrunePlanItem {
return {
target: 'images',
id: 'sha256:image',
name: 'example/app:latest',
sizeBytes: 256,
managed: true,
reason: 'Image is not used by any container',
stackName: 'app',
image: { references: ['example/app:latest'] },
...overrides,
} as PrunePlanItem;
}
function mockLocalPrune(opts: { managedBytes?: Partial<Record<string, number>>; allBytes?: Partial<Record<string, number>>; throwOn?: string } = {}) {
function plan(nodeId: number, fingerprint = `fingerprint-${nodeId}`, items: PrunePlanItem[] = [item()]): PrunePlan {
return {
nodeId,
scope: 'managed',
targets: ['images'],
items,
reclaimableBytes: items.reduce((sum, entry) => sum + (entry.sizeBytes ?? 0), 0),
fingerprint,
createdAt: 1,
};
}
function mockLocal(planFactory: (nodeId: number) => PrunePlan = (nodeId) => plan(nodeId)) {
const fake = {
pruneManagedOnly: vi.fn(async (target: string) => {
if (opts.throwOn === target) throw new Error(`mock pruneManagedOnly threw for ${target}`);
return { success: true, reclaimedBytes: opts.managedBytes?.[target] ?? 0 };
}),
pruneSystem: vi.fn(async (target: string) => {
if (opts.throwOn === target) throw new Error(`mock pruneSystem threw for ${target}`);
return { success: true, reclaimedBytes: opts.allBytes?.[target] ?? 0 };
}),
buildPrunePlan: vi.fn(async (_targets, _scope, _stacks, nodeId: number) => planFactory(nodeId)),
executePrunePlan: vi.fn(async (reviewedPlan: PrunePlan): Promise<{
success: boolean;
reclaimedBytes: number;
outcomes: PruneItemOutcome[];
}> => ({
success: true,
reclaimedBytes: reviewedPlan.reclaimableBytes,
outcomes: reviewedPlan.items.map((entry) => ({
id: entry.id,
target: entry.target,
status: 'removed' as const,
sizeBytes: entry.sizeBytes,
})),
})),
};
vi.spyOn(DockerController, 'getInstance').mockReturnValue(fake as unknown as ReturnType<typeof DockerController.getInstance>);
// Spy on the prototype so the mock applies to whichever FileSystemService
// instance the route creates for the local node id, not a throwaway one.
vi.spyOn(FileSystemService.prototype, 'getStacks').mockResolvedValue(['stack-a', 'stack-b']);
vi.spyOn(FileSystemService.prototype, 'getStacks').mockResolvedValue(['app']);
return fake;
}
function localReview(fingerprint: string) {
const local = DatabaseService.getInstance().getNodes().find((node) => node.type === 'local')!;
return {
local,
body: {
targets: ['images'],
scope: 'managed',
dryRun: false,
reviewedNodes: [{ nodeId: local.id, reachable: true }],
plans: [{ nodeId: local.id, fingerprint }],
},
};
}
function addRemote(name: string): number {
return DatabaseService.getInstance().addNode({
name,
type: 'remote',
api_url: `http://${name}.example:1852`,
api_token: 'token',
compose_dir: '/app/compose',
is_default: false,
});
}
describe('POST /api/fleet/labels/fleet-prune', () => {
it('returns 401 without auth', async () => {
const res = await request(app)
.post('/api/fleet/labels/fleet-prune')
.send({ targets: ['images'], scope: 'managed' });
expect(res.status).toBe(401);
});
it('is reachable on community tier for admins (no PAID_REQUIRED)', async () => {
mockTier('community');
mockLocalPrune({ managedBytes: { images: 128 } });
const res = await request(app)
it('requires authentication and validates the request', async () => {
expect((await request(app).post('/api/fleet/labels/fleet-prune').send({})).status).toBe(401);
const invalid = await request(app)
.post('/api/fleet/labels/fleet-prune')
.set('Authorization', authHeader)
.send({ targets: ['images'], scope: 'managed' });
expect(res.status).toBe(200);
expect(res.body.code).not.toBe('PAID_REQUIRED');
expect(Array.isArray(res.body.results)).toBe(true);
});
it('returns 400 when body is missing', async () => {
const res = await request(app)
.post('/api/fleet/labels/fleet-prune')
.set('Authorization', authHeader)
.send();
expect(res.status).toBe(400);
});
it('returns 400 when targets is empty', async () => {
const res = await request(app)
.post('/api/fleet/labels/fleet-prune')
.set('Authorization', authHeader)
.send({ targets: [], scope: 'managed' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/non-empty/);
});
it('returns 400 when a target is unrecognized', async () => {
const res = await request(app)
.post('/api/fleet/labels/fleet-prune')
.set('Authorization', authHeader)
.send({ targets: ['images', 'containers'], scope: 'managed' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/Invalid target/);
});
it('runs pruneManagedOnly per target on the local node and returns aggregated bytes', async () => {
const fake = mockLocalPrune({ managedBytes: { images: 1500, volumes: 320 } });
const res = await request(app)
.post('/api/fleet/labels/fleet-prune')
.set('Authorization', authHeader)
.send({ targets: ['images', 'volumes'], scope: 'managed' });
expect(res.status).toBe(200);
expect(res.body.results).toHaveLength(1);
const node = res.body.results[0];
expect(node.reachable).toBe(true);
expect(node.targets).toEqual([
{ target: 'images', success: true, reclaimedBytes: 1500 },
{ target: 'volumes', success: true, reclaimedBytes: 320 },
]);
expect(fake.pruneManagedOnly).toHaveBeenCalledTimes(2);
expect(fake.pruneSystem).not.toHaveBeenCalled();
});
it('runs pruneSystem when scope is "all" and dedupes targets', async () => {
const fake = mockLocalPrune({ allBytes: { networks: 0, images: 2048 } });
const res = await request(app)
.post('/api/fleet/labels/fleet-prune')
.set('Authorization', authHeader)
.send({ targets: ['images', 'networks', 'images'], scope: 'all' });
expect(res.status).toBe(200);
expect(fake.pruneManagedOnly).not.toHaveBeenCalled();
expect(fake.pruneSystem).toHaveBeenCalledTimes(2);
const node = res.body.results[0];
expect(node.targets.map((t: { target: string }) => t.target).sort()).toEqual(['images', 'networks']);
});
it('records per-target failure when DockerController throws but continues remaining targets', async () => {
mockLocalPrune({ managedBytes: { images: 100 }, throwOn: 'volumes' });
const res = await request(app)
.post('/api/fleet/labels/fleet-prune')
.set('Authorization', authHeader)
.send({ targets: ['images', 'volumes'], scope: 'managed' });
expect(res.status).toBe(200);
const node = res.body.results[0];
expect(node.targets.find((t: { target: string }) => t.target === 'images').success).toBe(true);
const volumes = node.targets.find((t: { target: string }) => t.target === 'volumes');
expect(volumes.success).toBe(false);
expect(volumes.reclaimedBytes).toBe(0);
expect(volumes.error).toMatch(/pruneManagedOnly threw/);
});
it('reports lock contention when bulk-prune lock is already held', async () => {
mockLocalPrune();
const db = DatabaseService.getInstance();
const localId = db.getNodes().find(n => n.type === 'local')!.id;
activeBulkActions.add(`bulk-prune:${localId}`);
const res = await request(app)
.post('/api/fleet/labels/fleet-prune')
.set('Authorization', authHeader)
.send({ targets: ['images'], scope: 'managed' });
expect(res.status).toBe(200);
const node = res.body.results.find((n: { nodeId: number }) => n.nodeId === localId);
expect(node.targets[0].success).toBe(false);
expect(node.targets[0].error).toMatch(/already running/);
});
it('marks a remote node unreachable when fetch throws and short-circuits later targets', async () => {
mockLocalPrune();
const db = DatabaseService.getInstance();
const remoteId = db.addNode({
name: 'remote-test',
type: 'remote',
api_url: 'http://remote.example:1852',
api_token: 'tok',
compose_dir: '/app/compose',
is_default: false,
});
try {
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('connect ECONNREFUSED'));
const res = await request(app)
.send({ targets: ['containers'], dryRun: true });
expect(invalid.status).toBe(400);
for (const scope of [undefined, 'everything', 1]) {
const response = await request(app)
.post('/api/fleet/labels/fleet-prune')
.set('Authorization', authHeader)
.send({ targets: ['images', 'volumes', 'networks'], scope: 'managed' });
expect(res.status).toBe(200);
const remote = res.body.results.find((n: { nodeId: number }) => n.nodeId === remoteId);
expect(remote.reachable).toBe(false);
expect(remote.error).toMatch(/ECONNREFUSED/);
expect(remote.targets).toHaveLength(3);
for (const t of remote.targets) expect(t.success).toBe(false);
// Only the first target attempts the fetch; the rest short-circuit.
expect(fetchSpy).toHaveBeenCalledTimes(1);
} finally {
db.deleteNode(remoteId);
.send({ targets: ['images'], scope, dryRun: true });
expect(response.status).toBe(400);
}
});
it('parses remote node responses into per-target reclaimed bytes', async () => {
mockLocalPrune();
const db = DatabaseService.getInstance();
const remoteId = db.addNode({
name: 'remote-ok',
type: 'remote',
api_url: 'http://remote-ok.example:1852/',
api_token: 'tok',
compose_dir: '/app/compose',
is_default: false,
});
it('rejects malformed remote plan contracts', async () => {
mockLocal();
const remoteId = addRemote('remote-malformed-plan');
const base = plan(remoteId);
const malformedPlans = [
{ ...base, targets: ['images', 'images'] },
{ ...base, items: [item(), item()], reclaimableBytes: 512 },
{ ...base, reclaimableBytes: -1 },
{ ...base, nodeId: 'remote' },
{ ...base, createdAt: Number.NaN },
];
try {
const responses = new Map<string, number>([['images', 4096], ['volumes', 512]]);
vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
const body = JSON.parse((init?.body as string) ?? '{}') as { target: string };
const reclaimedBytes = responses.get(body.target) ?? 0;
return new Response(JSON.stringify({ message: 'ok', success: true, reclaimedBytes }), {
status: 200, headers: { 'content-type': 'application/json' },
});
});
const res = await request(app)
for (const malformed of malformedPlans) {
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(JSON.stringify(malformed), { status: 200 }));
const response = await request(app)
.post('/api/fleet/labels/fleet-prune')
.set('Authorization', authHeader)
.send({ targets: ['images', 'volumes'], scope: 'managed', dryRun: true });
const remote = response.body.results.find((result: { nodeId: number }) => result.nodeId === remoteId);
expect(remote).toMatchObject({ reachable: true, code: 'REMOTE_PLAN_INVALID' });
expect(remote.fingerprint).toBeUndefined();
vi.restoreAllMocks();
mockLocal();
}
} finally {
DatabaseService.getInstance().deleteNode(remoteId);
}
});
it('returns itemized dry-run plans without taking the destructive lock', async () => {
const fake = mockLocal();
const response = await request(app)
.post('/api/fleet/labels/fleet-prune')
.set('Authorization', authHeader)
.send({ targets: ['images'], scope: 'managed', dryRun: true });
expect(response.status).toBe(200);
expect(response.body.results[0]).toMatchObject({
reachable: true,
fingerprint: expect.stringMatching(/^fingerprint-/),
reclaimableBytes: 256,
items: [expect.objectContaining({ name: 'example/app:latest', managed: true, stackName: 'app' })],
targets: [{ target: 'images', success: true, reclaimedBytes: 256, dryRun: true }],
});
expect(fake.executePrunePlan).not.toHaveBeenCalled();
expect(activeBulkActions.size).toBe(0);
});
it('loads known stacks for All unused attribution', async () => {
mockLocal();
const stackSpy = vi.spyOn(FileSystemService.prototype, 'getStacks');
await request(app)
.post('/api/fleet/labels/fleet-prune')
.set('Authorization', authHeader)
.send({ targets: ['images'], scope: 'all', dryRun: true });
expect(stackSpy).toHaveBeenCalled();
});
it('rejects missing, duplicate, and malformed reviewed entries', async () => {
mockLocal();
const { local } = localReview(`fingerprint-${DatabaseService.getInstance().getNodes()[0].id}`);
const cases = [
{ reviewedNodes: [{ nodeId: local.id, reachable: true }], plans: [] },
{ reviewedNodes: [{ nodeId: local.id, reachable: true }, { nodeId: local.id, reachable: true }], plans: [] },
{ reviewedNodes: [{ nodeId: local.id, reachable: true }], plans: [{ nodeId: local.id, fingerprint: '' }] },
];
for (const testCase of cases) {
const response = await request(app)
.post('/api/fleet/labels/fleet-prune')
.set('Authorization', authHeader)
.send({ targets: ['images', 'volumes'], scope: 'all' });
expect(res.status).toBe(200);
const remote = res.body.results.find((n: { nodeId: number }) => n.nodeId === remoteId);
expect(remote.reachable).toBe(true);
expect(remote.targets).toEqual([
{ target: 'images', success: true, reclaimedBytes: 4096 },
{ target: 'volumes', success: true, reclaimedBytes: 512 },
]);
.send({ targets: ['images'], scope: 'managed', dryRun: false, ...testCase });
expect([400, 409]).toContain(response.status);
}
});
it('executes a valid empty plan and releases the lock', async () => {
const fake = mockLocal((nodeId) => plan(nodeId, `empty-${nodeId}`, []));
const local = DatabaseService.getInstance().getNodes().find((node) => node.type === 'local')!;
const response = await request(app)
.post('/api/fleet/labels/fleet-prune')
.set('Authorization', authHeader)
.send({
targets: ['images'], scope: 'managed', dryRun: false,
reviewedNodes: [{ nodeId: local.id, reachable: true }],
plans: [{ nodeId: local.id, fingerprint: `empty-${local.id}` }],
});
expect(response.status).toBe(200);
expect(fake.executePrunePlan).toHaveBeenCalledTimes(1);
expect(response.body.results[0].outcomes).toEqual([]);
expect(activeBulkActions.size).toBe(0);
});
it('fails closed when a local prune lock is active', async () => {
const fake = mockLocal();
const { local, body } = localReview(`fingerprint-${DatabaseService.getInstance().getNodes()[0].id}`);
const remoteId = addRemote('remote-lock-check');
const fetchSpy = vi.spyOn(globalThis, 'fetch');
const lockKey = `bulk-prune:${local.id}`;
activeBulkActions.add(lockKey);
try {
const response = await request(app)
.post('/api/fleet/labels/fleet-prune')
.set('Authorization', authHeader)
.send({
...body,
reviewedNodes: [...body.reviewedNodes, { nodeId: remoteId, reachable: true }],
plans: [...body.plans, { nodeId: remoteId, fingerprint: 'remote-plan' }],
});
expect(response.status).toBe(409);
expect(response.body.code).toBe('PRUNE_ALREADY_RUNNING');
expect(fake.buildPrunePlan).not.toHaveBeenCalled();
expect(fake.executePrunePlan).not.toHaveBeenCalled();
expect(fetchSpy).not.toHaveBeenCalled();
expect(activeBulkActions.has(lockKey)).toBe(true);
} finally {
db.deleteNode(remoteId);
activeBulkActions.delete(lockKey);
DatabaseService.getInstance().deleteNode(remoteId);
}
});
it('returns a node failure when local execution setup fails after preflight', async () => {
const fake = mockLocal();
vi.spyOn(FileSystemService.prototype, 'getStacks')
.mockResolvedValueOnce(['app'])
.mockRejectedValueOnce(new Error('stack inventory unavailable'));
const { body } = localReview(`fingerprint-${DatabaseService.getInstance().getNodes()[0].id}`);
const response = await request(app)
.post('/api/fleet/labels/fleet-prune')
.set('Authorization', authHeader)
.send(body);
expect(response.status).toBe(200);
expect(response.body.results[0]).toMatchObject({
code: 'PRUNE_EXECUTE_FAILED',
error: 'stack inventory unavailable',
targets: [{ success: false }],
});
expect(fake.executePrunePlan).not.toHaveBeenCalled();
});
it('prevents every destructive call when one node plan is stale', async () => {
const fake = mockLocal();
const remoteId = addRemote('remote-stale');
try {
const local = DatabaseService.getInstance().getNodes().find((node) => node.type === 'local')!;
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(
JSON.stringify(plan(99, 'remote-new')),
{ status: 200, headers: { 'content-type': 'application/json' } },
));
const response = await request(app)
.post('/api/fleet/labels/fleet-prune')
.set('Authorization', authHeader)
.send({
targets: ['images'], scope: 'managed', dryRun: false,
reviewedNodes: [{ nodeId: local.id, reachable: true }, { nodeId: remoteId, reachable: true }],
plans: [
{ nodeId: local.id, fingerprint: `fingerprint-${local.id}` },
{ nodeId: remoteId, fingerprint: 'remote-old' },
],
});
expect(response.status).toBe(409);
expect(response.body.code).toBe('PRUNE_PLAN_STALE');
expect(fake.executePrunePlan).not.toHaveBeenCalled();
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(String(fetchSpy.mock.calls[0][0])).toContain('/api/system/prune/plan');
} finally {
DatabaseService.getInstance().deleteNode(remoteId);
}
});
it('rejects a reviewed-unreachable node that becomes reachable', async () => {
const fake = mockLocal();
const remoteId = addRemote('remote-newly-reachable');
try {
const local = DatabaseService.getInstance().getNodes().find((node) => node.type === 'local')!;
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify(plan(99)), { status: 200 }));
const response = await request(app)
.post('/api/fleet/labels/fleet-prune')
.set('Authorization', authHeader)
.send({
targets: ['images'], scope: 'managed', dryRun: false,
reviewedNodes: [{ nodeId: local.id, reachable: true }, { nodeId: remoteId, reachable: false }],
plans: [{ nodeId: local.id, fingerprint: `fingerprint-${local.id}` }],
});
expect(response.status).toBe(409);
expect(response.body.code).toBe('PRUNE_NODE_REACHABILITY_CHANGED');
expect(fake.executePrunePlan).not.toHaveBeenCalled();
} finally {
DatabaseService.getInstance().deleteNode(remoteId);
}
});
it('rejects a reviewed-reachable node that becomes unreachable', async () => {
const fake = mockLocal();
const remoteId = addRemote('remote-now-offline');
try {
const local = DatabaseService.getInstance().getNodes().find((node) => node.type === 'local')!;
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('connect ECONNREFUSED'));
const response = await request(app)
.post('/api/fleet/labels/fleet-prune')
.set('Authorization', authHeader)
.send({
targets: ['images'], scope: 'managed', dryRun: false,
reviewedNodes: [{ nodeId: local.id, reachable: true }, { nodeId: remoteId, reachable: true }],
plans: [
{ nodeId: local.id, fingerprint: `fingerprint-${local.id}` },
{ nodeId: remoteId, fingerprint: 'remote-plan' },
],
});
expect(response.status).toBe(409);
expect(response.body.code).toBe('PRUNE_NODE_REACHABILITY_CHANGED');
expect(fake.executePrunePlan).not.toHaveBeenCalled();
} finally {
DatabaseService.getInstance().deleteNode(remoteId);
}
});
it('rejects a changed configured-node roster before preflight', async () => {
const fake = mockLocal();
const remoteId = addRemote('remote-added-after-review');
try {
const local = DatabaseService.getInstance().getNodes().find((node) => node.type === 'local')!;
const response = await request(app)
.post('/api/fleet/labels/fleet-prune')
.set('Authorization', authHeader)
.send({
targets: ['images'], scope: 'managed', dryRun: false,
reviewedNodes: [{ nodeId: local.id, reachable: true }],
plans: [{ nodeId: local.id, fingerprint: `fingerprint-${local.id}` }],
});
expect(response.status).toBe(409);
expect(response.body.code).toBe('PRUNE_NODE_ROSTER_CHANGED');
expect(fake.buildPrunePlan).not.toHaveBeenCalled();
expect(fake.executePrunePlan).not.toHaveBeenCalled();
} finally {
DatabaseService.getInstance().deleteNode(remoteId);
}
});
it('rejects a present but incomplete remote outcome list', async () => {
const fake = mockLocal();
const remoteId = addRemote('remote-bad-outcomes');
try {
const local = DatabaseService.getInstance().getNodes().find((node) => node.type === 'local')!;
vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(new Response(JSON.stringify(plan(remoteId, 'remote-reviewed')), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({
success: true,
reclaimedBytes: 256,
outcomes: [],
}), { status: 200 }));
const response = await request(app)
.post('/api/fleet/labels/fleet-prune')
.set('Authorization', authHeader)
.send({
targets: ['images'], scope: 'managed', dryRun: false,
reviewedNodes: [{ nodeId: local.id, reachable: true }, { nodeId: remoteId, reachable: true }],
plans: [
{ nodeId: local.id, fingerprint: `fingerprint-${local.id}` },
{ nodeId: remoteId, fingerprint: 'remote-reviewed' },
],
});
expect(response.status).toBe(200);
expect(fake.executePrunePlan).toHaveBeenCalledTimes(1);
expect(response.body.results.find((result: { nodeId: number }) => result.nodeId === remoteId)).toMatchObject({
code: 'REMOTE_PRUNE_INVALID',
error: 'Remote returned malformed or incomplete prune outcomes',
});
} finally {
DatabaseService.getInstance().deleteNode(remoteId);
}
});
it('rejects malformed numeric and success fields in remote execute results', async () => {
mockLocal();
const remoteId = addRemote('remote-bad-result-fields');
const local = DatabaseService.getInstance().getNodes().find((node) => node.type === 'local')!;
const remotePlan = plan(remoteId, 'remote-fields');
const malformedResults = [
{ success: 'yes', reclaimedBytes: 256 },
{ success: true, reclaimedBytes: -1 },
{
success: true, reclaimedBytes: 0,
outcomes: [{ target: 'images', id: 'sha256:image', status: 'removed', sizeBytes: -1 }],
},
];
try {
const fetchSpy = vi.spyOn(globalThis, 'fetch');
for (const malformed of malformedResults) {
fetchSpy
.mockResolvedValueOnce(new Response(JSON.stringify(remotePlan), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify(malformed), { status: 200 }));
const response = await request(app)
.post('/api/fleet/labels/fleet-prune')
.set('Authorization', authHeader)
.send({
targets: ['images'], scope: 'managed', dryRun: false,
reviewedNodes: [{ nodeId: local.id, reachable: true }, { nodeId: remoteId, reachable: true }],
plans: [{ nodeId: local.id, fingerprint: `fingerprint-${local.id}` }, { nodeId: remoteId, fingerprint: 'remote-fields' }],
});
expect(response.body.results.find((result: { nodeId: number }) => result.nodeId === remoteId)).toMatchObject({
code: 'REMOTE_PRUNE_INVALID',
});
}
} finally {
DatabaseService.getInstance().deleteNode(remoteId);
}
});
it('projects mixed local outcomes and accepts a legacy remote total', async () => {
const items = [
item({ id: 'removed', sizeBytes: 100 }),
item({ id: 'skipped', sizeBytes: 200 }),
item({ id: 'failed', sizeBytes: 300 }),
];
const fake = mockLocal((nodeId) => plan(nodeId, `mixed-${nodeId}`, items));
fake.executePrunePlan.mockResolvedValue({
success: false,
reclaimedBytes: 100,
outcomes: [
{ target: 'images', id: 'removed', status: 'removed', sizeBytes: 100 },
{ target: 'images', id: 'skipped', status: 'skipped', reason: 'became active' },
{ target: 'images', id: 'failed', status: 'failed', error: 'remove failed' },
],
});
const remoteId = addRemote('remote-legacy-total');
const remotePlan = plan(remoteId, 'legacy-plan', []);
try {
vi.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(new Response(JSON.stringify(remotePlan), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ success: true, reclaimedBytes: 999 }), { status: 200 }));
const local = DatabaseService.getInstance().getNodes().find((node) => node.type === 'local')!;
const response = await request(app)
.post('/api/fleet/labels/fleet-prune')
.set('Authorization', authHeader)
.send({
targets: ['images'], scope: 'managed', dryRun: false,
reviewedNodes: [{ nodeId: local.id, reachable: true }, { nodeId: remoteId, reachable: true }],
plans: [{ nodeId: local.id, fingerprint: `mixed-${local.id}` }, { nodeId: remoteId, fingerprint: 'legacy-plan' }],
});
const localResult = response.body.results.find((result: { nodeId: number }) => result.nodeId === local.id);
expect(localResult.targets[0]).toMatchObject({
success: false, reclaimedBytes: 100, removed: 1, skipped: 1, failed: 1,
});
const remoteResult = response.body.results.find((result: { nodeId: number }) => result.nodeId === remoteId);
expect(remoteResult.reclaimedBytes).toBe(999);
expect(remoteResult.outcomes).toBeUndefined();
expect(remoteResult.targets[0]).toMatchObject({ success: true, reclaimedBytes: 0 });
} finally {
DatabaseService.getInstance().deleteNode(remoteId);
}
});
});
+162
View File
@@ -105,6 +105,168 @@ describe('normalizePruneTargets', () => {
});
describe('DockerController.buildPrunePlan', () => {
it('projects target metadata and only safe ownership labels in all scope', async () => {
mockDocker.listImages.mockResolvedValue([{
Id: 'sha256:dangling',
RepoTags: ['<none>:<none>'],
RepoDigests: ['example/app@sha256:digest'],
Created: 1_700_000_000,
Size: 100,
Containers: 0,
Labels: { 'com.docker.compose.project': 'my-stack', secret: 'do-not-return' },
}]);
mockDocker.listVolumes.mockResolvedValue({ Volumes: [{
Name: 'my-stack_data',
Driver: 'local',
Labels: { 'com.docker.compose.project': 'my-stack', secret: 'do-not-return' },
}] });
mockDocker.listNetworks.mockResolvedValue([{
Id: 'network-id',
Name: 'my-stack_default',
Driver: 'bridge',
Scope: 'local',
Labels: {
'com.docker.compose.project': 'my-stack',
'com.docker.compose.network': 'default',
secret: 'do-not-return',
},
}]);
mockDocker.getNetwork.mockReturnValue({ inspect: vi.fn().mockResolvedValue({ Containers: {} }) });
mockDocker.df.mockResolvedValue({
Volumes: [{ Name: 'my-stack_data', UsageData: { RefCount: 0, Size: 42 } }],
Images: [{ Id: 'sha256:dangling', SharedSize: 10 }],
LayersSize: 0,
});
const plan = await DockerController.getInstance(1).buildPrunePlan(
['images', 'volumes', 'networks'], 'all', ['my-stack'], 1,
);
expect(plan.items.find((entry) => entry.target === 'images')).toMatchObject({
name: '<none>:<none>',
managed: true,
stackName: 'my-stack',
image: {
references: [],
digest: 'example/app@sha256:digest',
createdAt: 1_700_000_000,
},
});
expect(plan.items.find((entry) => entry.target === 'volumes')).toMatchObject({
managed: true,
stackName: 'my-stack',
volume: {
driver: 'local',
ownershipLabels: { 'com.docker.compose.project': 'my-stack' },
},
});
expect(plan.items.find((entry) => entry.target === 'networks')).toMatchObject({
managed: true,
stackName: 'my-stack',
network: {
driver: 'bridge',
scope: 'local',
ownershipLabels: {
'com.docker.compose.project': 'my-stack',
'com.docker.compose.network': 'default',
},
},
});
expect(JSON.stringify(plan.items)).not.toContain('do-not-return');
expect(plan.reclaimableBytes).toBe(
plan.items.reduce((sum, entry) => sum + (entry.sizeBytes ?? 0), 0),
);
});
it('uses Compose path ownership fallbacks for non-container resources', async () => {
const ownershipLabels = {
'com.docker.compose.project.working_dir': '/app/compose/my-stack',
'com.docker.compose.project.config_files': '/app/compose/my-stack/compose.yml',
};
mockDocker.listImages.mockResolvedValue([{
Id: 'sha256:path-owned', RepoTags: ['example/path:latest'], Size: 100, Containers: 0, Labels: ownershipLabels,
}]);
mockDocker.listVolumes.mockResolvedValue({ Volumes: [{ Name: 'path_data', Labels: ownershipLabels }] });
mockDocker.listNetworks.mockResolvedValue([{ Id: 'path-network', Name: 'path_default', Labels: ownershipLabels }]);
mockDocker.getNetwork.mockReturnValue({ inspect: vi.fn().mockResolvedValue({ Containers: {} }) });
mockDocker.df.mockResolvedValue({
Volumes: [{ Name: 'path_data', UsageData: { RefCount: 0, Size: 42 } }],
Images: [{ Id: 'sha256:path-owned', SharedSize: 0 }],
LayersSize: 0,
});
const plan = await DockerController.getInstance(1).buildPrunePlan(
['images', 'volumes', 'networks'], 'managed', ['my-stack'], 1,
);
expect(plan.items).toHaveLength(3);
expect(plan.items.every((entry) => entry.managed && entry.stackName === 'my-stack')).toBe(true);
});
it.each(['__proto__', 'constructor', 'toString', 'prototype'])(
'does not attribute inherited project key %s to a managed stack',
async (project) => {
const labels = { 'com.docker.compose.project': project };
mockDocker.listVolumes.mockResolvedValue({ Volumes: [{ Name: `${project}_data`, Labels: labels }] });
mockDocker.df.mockResolvedValue({
Volumes: [{ Name: `${project}_data`, UsageData: { RefCount: 0, Size: 42 } }],
Images: [],
LayersSize: 0,
});
const managedPlan = await DockerController.getInstance(1).buildPrunePlan(
['volumes'], 'managed', ['my-stack'], 1,
);
const allPlan = await DockerController.getInstance(1).buildPrunePlan(
['volumes'], 'all', ['my-stack'], 1,
);
expect(managedPlan.items).toEqual([]);
expect(allPlan.items).toEqual([
expect.objectContaining({ id: `${project}_data`, managed: false }),
]);
expect(allPlan.items[0].stackName).toBeUndefined();
},
);
it('does not plan an image referenced by a container when Docker reports Containers as unknown', async () => {
mockDocker.listContainers.mockResolvedValue([{ Id: 'container', ImageID: 'sha256:in-use' }]);
mockDocker.listImages.mockResolvedValue([{
Id: 'sha256:in-use', RepoTags: ['example/in-use:latest'], Size: 100, Containers: -1,
}]);
mockDocker.df.mockResolvedValue({
Volumes: [], Images: [{ Id: 'sha256:in-use', SharedSize: 0 }], LayersSize: 0,
});
const plan = await DockerController.getInstance(1).buildPrunePlan(['images'], 'all', [], 1);
expect(plan.items).toEqual([]);
});
it('preserves Compose path ownership fallback during volume and network execution', async () => {
const labels = { 'com.docker.compose.project.working_dir': '/app/compose/my-stack' };
const volumeRemove = vi.fn().mockResolvedValue(undefined);
const networkRemove = vi.fn().mockResolvedValue(undefined);
const networkInspect = vi.fn().mockResolvedValue({ Name: 'path_default', Labels: labels, Containers: {} });
mockDocker.listVolumes.mockResolvedValue({ Volumes: [{ Name: 'path_data', Labels: labels }] });
mockDocker.listNetworks.mockResolvedValue([{ Id: 'path-network', Name: 'path_default', Labels: labels }]);
mockDocker.df.mockResolvedValue({
Volumes: [{ Name: 'path_data', UsageData: { RefCount: 0, Size: 42 } }], Images: [], LayersSize: 0,
});
mockDocker.getVolume.mockReturnValue({ remove: volumeRemove });
mockDocker.getNetwork.mockReturnValue({ inspect: networkInspect, remove: networkRemove });
const controller = DockerController.getInstance(1);
const plan = await controller.buildPrunePlan(['volumes', 'networks'], 'managed', ['my-stack'], 1);
const result = await controller.executePrunePlan(plan, ['my-stack']);
expect(result.outcomes).toEqual(expect.arrayContaining([
expect.objectContaining({ target: 'volumes', status: 'removed' }),
expect.objectContaining({ target: 'networks', status: 'removed' }),
]));
expect(volumeRemove).toHaveBeenCalledWith({ force: false });
expect(networkRemove).toHaveBeenCalled();
});
it('enumerates managed stopped containers and never calls pruneSystem', async () => {
mockDocker.listContainers.mockResolvedValue([
{
@@ -16,12 +16,16 @@ let app: import('express').Express;
let authHeader: string;
let DockerController: typeof import('../services/DockerController').default;
let FileSystemService: typeof import('../services/FileSystemService').FileSystemService;
let CacheService: typeof import('../services/CacheService').CacheService;
let activeBulkActions: typeof import('../helpers/bulkActionLocks').activeBulkActions;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
({ default: DockerController } = await import('../services/DockerController'));
({ FileSystemService } = await import('../services/FileSystemService'));
({ CacheService } = await import('../services/CacheService'));
({ activeBulkActions } = await import('../helpers/bulkActionLocks'));
// 10-minute expiry survives the full file even when two timeout tests
// burn ~8.5s each in real-timer mode.
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '10m' });
@@ -31,6 +35,7 @@ beforeAll(async () => {
afterAll(() => cleanupTestDb(tmpDir));
afterEach(() => {
activeBulkActions.clear();
vi.restoreAllMocks();
});
@@ -150,6 +155,7 @@ describe('Prune plan routes', () => {
buildPrunePlan: vi.fn().mockResolvedValue(plan),
executePrunePlan,
} as unknown as ReturnType<typeof DockerController.getInstance>);
const invalidate = vi.spyOn(CacheService.getInstance(), 'invalidate');
const res = await request(app)
.post('/api/system/prune/system')
@@ -161,6 +167,45 @@ describe('Prune plan routes', () => {
expect(res.body.reclaimedBytes).toBe(42);
expect(res.body.outcomes).toHaveLength(1);
expect(executePrunePlan).toHaveBeenCalled();
expect(invalidate).toHaveBeenCalledWith('stats:1');
expect(invalidate).toHaveBeenCalledWith('stack-statuses:1');
});
it('rejects an overlapping destructive prune on the same node', async () => {
stubFsStacks();
const plan = samplePlan('fp-lock');
let releaseExecution!: () => void;
const executionBlocked = new Promise<void>((resolve) => {
releaseExecution = resolve;
});
const executePrunePlan = vi.fn().mockImplementation(async () => {
await executionBlocked;
return { outcomes: [], reclaimedBytes: 0, success: true };
});
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
buildPrunePlan: vi.fn().mockResolvedValue(plan),
executePrunePlan,
} as unknown as ReturnType<typeof DockerController.getInstance>);
const firstRequest = request(app)
.post('/api/system/prune/system')
.set('Authorization', authHeader)
.send({ target: 'volumes', scope: 'managed', planFingerprint: 'fp-lock' });
const firstResponse = firstRequest.then((response) => response);
await vi.waitFor(() => expect(executePrunePlan).toHaveBeenCalledTimes(1));
const overlapping = await request(app)
.post('/api/system/prune/system')
.set('Authorization', authHeader)
.send({ target: 'volumes', scope: 'managed', planFingerprint: 'fp-lock' });
expect(overlapping.status).toBe(409);
expect(overlapping.body.code).toBe('PRUNE_ALREADY_RUNNING');
expect(executePrunePlan).toHaveBeenCalledTimes(1);
releaseExecution();
expect((await firstResponse).status).toBe(200);
expect(activeBulkActions.size).toBe(0);
});
it('POST /api/system/prune/system returns 409 PRUNE_PLAN_STALE on fingerprint mismatch', async () => {