mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
feat(resources): bind prune to fingerprinted itemized plans (#1611)
* feat(resources): bind prune to fingerprinted itemized plans * fix(resources): repair prune plan volume usage and preview list Source volume RefCount from docker df, keep preview rows from flex-shrinking, tighten managed image attribution and becomesFree, and stop audit summaries from claiming success on rejected prunes.
This commit is contained in:
@@ -138,6 +138,13 @@ describe('getAuditSummary()', () => {
|
||||
expect(getAuditSummary('POST', '/users/42/mfa/reset')).toBe('Reset two-factor authentication: 42');
|
||||
expect(getAuditSummary('POST', '/users/42/mfa/reset')).not.toBe('Created user: 42');
|
||||
});
|
||||
|
||||
it('does not claim prune success on 409/400 responses', () => {
|
||||
expect(getAuditSummary('POST', '/system/prune/system', 409)).toBe('Prune blocked: plan stale');
|
||||
expect(getAuditSummary('POST', '/system/prune/system', 400)).toBe('Prune request rejected');
|
||||
expect(getAuditSummary('POST', '/system/prune/system', 500)).toBe('Prune failed (500)');
|
||||
expect(getAuditSummary('POST', '/system/prune/system', 200)).toBe('Pruned system resources');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- DatabaseService audit methods ----
|
||||
|
||||
@@ -511,8 +511,8 @@ describe('DockerController - managed image prune accounting', () => {
|
||||
],
|
||||
};
|
||||
const unusedManagedListMock = [
|
||||
{ Id: 'img-a', Containers: 0, Size: 1000 },
|
||||
{ Id: 'img-b', Containers: 0, Size: 1000 },
|
||||
{ Id: 'img-a', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
|
||||
{ Id: 'img-b', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
|
||||
{ Id: 'img-c', Containers: 1, Size: 800 }, // in-use; filtered by Containers===0
|
||||
];
|
||||
|
||||
@@ -543,8 +543,8 @@ describe('DockerController - managed image prune accounting', () => {
|
||||
})
|
||||
.mockResolvedValueOnce({ LayersSize: 3400, Images: [] });
|
||||
mockDocker.listImages.mockResolvedValue([
|
||||
{ Id: 'img-a', Containers: 0, Size: 1000 },
|
||||
{ Id: 'img-b', Containers: 0, Size: 1000 },
|
||||
{ Id: 'img-a', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
|
||||
{ Id: 'img-b', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
|
||||
]);
|
||||
mockDocker.listContainers.mockResolvedValue([]);
|
||||
const removeFn = vi.fn().mockResolvedValue(undefined);
|
||||
@@ -571,8 +571,8 @@ describe('DockerController - managed image prune accounting', () => {
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('df unavailable after prune'));
|
||||
mockDocker.listImages.mockResolvedValue([
|
||||
{ Id: 'img-a', Containers: 0, Size: 1000 },
|
||||
{ Id: 'img-b', Containers: 0, Size: 1000 },
|
||||
{ Id: 'img-a', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
|
||||
{ Id: 'img-b', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
|
||||
]);
|
||||
mockDocker.listContainers.mockResolvedValue([]);
|
||||
mockDocker.getImage.mockReturnValue({ remove: vi.fn().mockResolvedValue(undefined) });
|
||||
@@ -593,7 +593,7 @@ describe('DockerController - managed image prune accounting', () => {
|
||||
.mockRejectedValueOnce(new Error('df unavailable before'))
|
||||
.mockRejectedValueOnce(new Error('df unavailable after'));
|
||||
mockDocker.listImages.mockResolvedValue([
|
||||
{ Id: 'img-a', Containers: 0, Size: 1000 },
|
||||
{ Id: 'img-a', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
|
||||
]);
|
||||
mockDocker.listContainers.mockResolvedValue([]);
|
||||
const removeFn = vi.fn().mockResolvedValue(undefined);
|
||||
@@ -619,8 +619,8 @@ describe('DockerController - managed image prune accounting', () => {
|
||||
],
|
||||
});
|
||||
mockDocker.listImages.mockResolvedValue([
|
||||
{ Id: 'img-a', Containers: 0, Size: 1000 },
|
||||
{ Id: 'img-b', Containers: 0, Size: 1000 },
|
||||
{ Id: 'img-a', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
|
||||
{ Id: 'img-b', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
|
||||
]);
|
||||
mockDocker.listContainers.mockResolvedValue([]);
|
||||
|
||||
@@ -644,8 +644,8 @@ describe('DockerController - managed image prune accounting', () => {
|
||||
};
|
||||
const dfAfter = { LayersSize: 3400, Images: [] };
|
||||
const listImages = [
|
||||
{ Id: 'img-a', Containers: 0, Size: 1000 },
|
||||
{ Id: 'img-b', Containers: 0, Size: 1000 },
|
||||
{ Id: 'img-a', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
|
||||
{ Id: 'img-b', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
|
||||
];
|
||||
|
||||
// Estimate path: df called once.
|
||||
@@ -669,8 +669,8 @@ describe('DockerController - managed image prune accounting', () => {
|
||||
// The accounting should still process img-b, defaulting its SharedSize to 0.
|
||||
mockDocker.df.mockResolvedValue({ Images: [{ Id: 'img-a', SharedSize: 400 }] });
|
||||
mockDocker.listImages.mockResolvedValue([
|
||||
{ Id: 'img-a', Containers: 0, Size: 1000 },
|
||||
{ Id: 'img-b', Containers: 0, Size: 1000 },
|
||||
{ Id: 'img-a', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
|
||||
{ Id: 'img-b', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
|
||||
]);
|
||||
mockDocker.listContainers.mockResolvedValue([]);
|
||||
|
||||
@@ -687,8 +687,8 @@ describe('DockerController - managed image prune accounting', () => {
|
||||
// rather than failing the prune.
|
||||
mockDocker.df.mockRejectedValue(new Error('daemon df failed'));
|
||||
mockDocker.listImages.mockResolvedValue([
|
||||
{ Id: 'img-a', Containers: 0, Size: 1000 },
|
||||
{ Id: 'img-b', Containers: 0, Size: 1000 },
|
||||
{ Id: 'img-a', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
|
||||
{ Id: 'img-b', Containers: 0, Size: 1000, Labels: { 'com.docker.compose.project': 'any-stack' } },
|
||||
]);
|
||||
mockDocker.listContainers.mockResolvedValue([]);
|
||||
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* Unit tests for fingerprinted prune plans: fingerprint stability, managed
|
||||
* container enumeration, stale detection, per-item skip on race, and no
|
||||
* unplanned deletes.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
fingerprintPrunePlan,
|
||||
normalizePruneTargets,
|
||||
PrunePlanStaleError,
|
||||
} from '../services/prunePlan';
|
||||
|
||||
const { mockDocker } = vi.hoisted(() => {
|
||||
const mockDocker = {
|
||||
df: vi.fn(),
|
||||
listImages: vi.fn().mockResolvedValue([]),
|
||||
listVolumes: vi.fn().mockResolvedValue({ Volumes: [] }),
|
||||
listNetworks: vi.fn().mockResolvedValue([]),
|
||||
listContainers: vi.fn().mockResolvedValue([]),
|
||||
getContainer: vi.fn(),
|
||||
getImage: vi.fn(),
|
||||
getVolume: vi.fn(),
|
||||
getNetwork: vi.fn(),
|
||||
pruneContainers: vi.fn(),
|
||||
pruneImages: vi.fn(),
|
||||
pruneNetworks: vi.fn(),
|
||||
pruneVolumes: vi.fn(),
|
||||
};
|
||||
return { mockDocker };
|
||||
});
|
||||
|
||||
vi.mock('../services/NodeRegistry', () => ({
|
||||
NodeRegistry: {
|
||||
getInstance: () => ({
|
||||
getDocker: () => mockDocker,
|
||||
getDefaultNodeId: () => 1,
|
||||
getComposeDir: () => '/test/compose',
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../services/SelfIdentityService', () => ({
|
||||
default: {
|
||||
getInstance: () => ({
|
||||
isOwnContainer: () => false,
|
||||
isOwnImage: () => false,
|
||||
isOwnVolume: () => false,
|
||||
isOwnNetwork: () => false,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('child_process', () => ({
|
||||
exec: vi.fn(),
|
||||
execFile: vi.fn(),
|
||||
}));
|
||||
vi.mock('util', () => ({
|
||||
promisify: () => vi.fn(),
|
||||
}));
|
||||
|
||||
import DockerController from '../services/DockerController';
|
||||
import { CacheService } from '../services/CacheService';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
CacheService.getInstance().invalidate('project-name-map');
|
||||
mockDocker.listImages.mockResolvedValue([]);
|
||||
mockDocker.listVolumes.mockResolvedValue({ Volumes: [] });
|
||||
mockDocker.listNetworks.mockResolvedValue([]);
|
||||
mockDocker.listContainers.mockResolvedValue([]);
|
||||
// Real Docker listVolumes returns UsageData: null; RefCount lives on df.
|
||||
mockDocker.df.mockResolvedValue({ Volumes: [], Images: [], LayersSize: 0 });
|
||||
});
|
||||
|
||||
describe('fingerprintPrunePlan', () => {
|
||||
it('is stable for the same sorted target:id pairs regardless of input order', () => {
|
||||
const a = fingerprintPrunePlan(1, 'managed', ['volumes', 'images'], [
|
||||
{ target: 'images', id: 'img-b' },
|
||||
{ target: 'volumes', id: 'vol-a' },
|
||||
]);
|
||||
const b = fingerprintPrunePlan(1, 'managed', ['volumes', 'images'], [
|
||||
{ target: 'volumes', id: 'vol-a' },
|
||||
{ target: 'images', id: 'img-b' },
|
||||
]);
|
||||
expect(a).toBe(b);
|
||||
expect(a).toMatch(/^[a-f0-9]{64}$/);
|
||||
});
|
||||
|
||||
it('changes when scope, targets, nodeId, or items change', () => {
|
||||
const base = fingerprintPrunePlan(1, 'managed', ['volumes'], [{ target: 'volumes', id: 'v1' }]);
|
||||
expect(fingerprintPrunePlan(2, 'managed', ['volumes'], [{ target: 'volumes', id: 'v1' }])).not.toBe(base);
|
||||
expect(fingerprintPrunePlan(1, 'all', ['volumes'], [{ target: 'volumes', id: 'v1' }])).not.toBe(base);
|
||||
expect(fingerprintPrunePlan(1, 'managed', ['images'], [{ target: 'volumes', id: 'v1' }])).not.toBe(base);
|
||||
expect(fingerprintPrunePlan(1, 'managed', ['volumes'], [{ target: 'volumes', id: 'v2' }])).not.toBe(base);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizePruneTargets', () => {
|
||||
it('keeps single-target order and sorts multi-target into dependency order', () => {
|
||||
expect(normalizePruneTargets(['images'])).toEqual(['images']);
|
||||
expect(normalizePruneTargets(['images', 'volumes', 'containers'])).toEqual([
|
||||
'volumes', 'containers', 'images',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DockerController.buildPrunePlan', () => {
|
||||
it('enumerates managed stopped containers and never calls pruneSystem', async () => {
|
||||
mockDocker.listContainers.mockResolvedValue([
|
||||
{
|
||||
Id: 'c-managed-exited',
|
||||
Names: ['/stack_web_1'],
|
||||
State: 'exited',
|
||||
ImageID: 'img-managed',
|
||||
Labels: { 'com.docker.compose.project': 'my-stack' },
|
||||
SizeRw: 100,
|
||||
},
|
||||
{
|
||||
Id: 'c-unmanaged',
|
||||
Names: ['/other'],
|
||||
State: 'exited',
|
||||
ImageID: 'img-other',
|
||||
Labels: { 'com.docker.compose.project': 'foreign' },
|
||||
},
|
||||
{
|
||||
Id: 'c-running',
|
||||
Names: ['/stack_db_1'],
|
||||
State: 'running',
|
||||
ImageID: 'img-managed',
|
||||
Labels: { 'com.docker.compose.project': 'my-stack' },
|
||||
},
|
||||
]);
|
||||
|
||||
const dc = DockerController.getInstance(1);
|
||||
const plan = await dc.buildPrunePlan(['containers'], 'managed', ['my-stack'], 1);
|
||||
|
||||
expect(plan.items).toHaveLength(1);
|
||||
expect(plan.items[0]).toMatchObject({
|
||||
target: 'containers',
|
||||
id: 'c-managed-exited',
|
||||
name: 'stack_web_1',
|
||||
});
|
||||
expect(mockDocker.pruneContainers).not.toHaveBeenCalled();
|
||||
expect(mockDocker.getContainer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call any remove APIs while building a plan', async () => {
|
||||
mockDocker.listVolumes.mockResolvedValue({
|
||||
Volumes: [{
|
||||
Name: 'my-stack_data',
|
||||
Labels: { 'com.docker.compose.project': 'my-stack' },
|
||||
UsageData: null,
|
||||
}],
|
||||
});
|
||||
mockDocker.df.mockResolvedValue({
|
||||
Volumes: [{ Name: 'my-stack_data', UsageData: { RefCount: 0, Size: 50 } }],
|
||||
Images: [],
|
||||
LayersSize: 0,
|
||||
});
|
||||
const remove = vi.fn();
|
||||
mockDocker.getVolume.mockReturnValue({ remove });
|
||||
|
||||
const dc = DockerController.getInstance(1);
|
||||
await dc.buildPrunePlan(['volumes'], 'managed', ['my-stack'], 1);
|
||||
|
||||
expect(remove).not.toHaveBeenCalled();
|
||||
expect(mockDocker.getVolume).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('plans dangling volumes using df RefCount when listVolumes UsageData is null', async () => {
|
||||
mockDocker.listVolumes.mockResolvedValue({
|
||||
Volumes: [{
|
||||
Name: 'my-stack_data',
|
||||
Labels: { 'com.docker.compose.project': 'my-stack' },
|
||||
UsageData: null,
|
||||
}],
|
||||
});
|
||||
mockDocker.df.mockResolvedValue({
|
||||
Volumes: [{ Name: 'my-stack_data', UsageData: { RefCount: 0, Size: 42 } }],
|
||||
Images: [],
|
||||
LayersSize: 0,
|
||||
});
|
||||
|
||||
const dc = DockerController.getInstance(1);
|
||||
const plan = await dc.buildPrunePlan(['volumes'], 'managed', ['my-stack'], 1);
|
||||
|
||||
expect(plan.items).toEqual([
|
||||
expect.objectContaining({
|
||||
target: 'volumes',
|
||||
id: 'my-stack_data',
|
||||
sizeBytes: 42,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('excludes unattributed unused images from managed scope', async () => {
|
||||
mockDocker.listImages.mockResolvedValue([
|
||||
{ Id: 'img-external', RepoTags: ['saelix/sencho:pr-1610'], Size: 100, Containers: 0 },
|
||||
{
|
||||
Id: 'img-labeled',
|
||||
RepoTags: ['my-stack-web:latest'],
|
||||
Size: 50,
|
||||
Containers: 0,
|
||||
Labels: { 'com.docker.compose.project': 'my-stack' },
|
||||
},
|
||||
]);
|
||||
|
||||
const dc = DockerController.getInstance(1);
|
||||
const plan = await dc.buildPrunePlan(['images'], 'managed', ['my-stack'], 1);
|
||||
|
||||
expect(plan.items.map((i) => i.id)).toEqual(['img-labeled']);
|
||||
});
|
||||
|
||||
it('does not mark an image free when only some referencing containers are planned', async () => {
|
||||
mockDocker.listContainers.mockResolvedValue([
|
||||
{
|
||||
Id: 'c-exited',
|
||||
Names: ['/exited'],
|
||||
State: 'exited',
|
||||
ImageID: 'img-shared',
|
||||
Labels: { 'com.docker.compose.project': 'my-stack' },
|
||||
},
|
||||
{
|
||||
Id: 'c-running',
|
||||
Names: ['/running'],
|
||||
State: 'running',
|
||||
ImageID: 'img-shared',
|
||||
Labels: { 'com.docker.compose.project': 'my-stack' },
|
||||
},
|
||||
]);
|
||||
mockDocker.listImages.mockResolvedValue([
|
||||
{ Id: 'img-shared', RepoTags: ['alpine:3.19'], Size: 7_000_000, Containers: 2 },
|
||||
]);
|
||||
|
||||
const dc = DockerController.getInstance(1);
|
||||
const plan = await dc.buildPrunePlan(['containers', 'images'], 'managed', ['my-stack'], 1);
|
||||
|
||||
expect(plan.items.some((i) => i.target === 'containers' && i.id === 'c-exited')).toBe(true);
|
||||
expect(plan.items.some((i) => i.target === 'images' && i.id === 'img-shared')).toBe(false);
|
||||
});
|
||||
|
||||
it('uses SharedSize when estimating reclaimable image bytes', async () => {
|
||||
mockDocker.listImages.mockResolvedValue([
|
||||
{ Id: 'img-a', RepoTags: ['a:1'], Size: 1_000_000_000, Containers: 0 },
|
||||
{ Id: 'img-b', RepoTags: ['b:1'], Size: 1_000_000_000, Containers: 0 },
|
||||
]);
|
||||
mockDocker.df.mockResolvedValue({
|
||||
Volumes: [],
|
||||
Images: [
|
||||
{ Id: 'img-a', SharedSize: 900_000_000, Size: 1_000_000_000 },
|
||||
{ Id: 'img-b', SharedSize: 900_000_000, Size: 1_000_000_000 },
|
||||
],
|
||||
LayersSize: 1_100_000_000,
|
||||
});
|
||||
|
||||
const dc = DockerController.getInstance(1);
|
||||
const plan = await dc.buildPrunePlan(['images'], 'all', [], 1);
|
||||
|
||||
// Unique bytes per image = Size - SharedSize = 100MB each, not full 1GB each.
|
||||
expect(plan.reclaimableBytes).toBe(200_000_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DockerController.executePrunePlan', () => {
|
||||
it('throws PrunePlanStaleError when the fingerprint no longer matches', async () => {
|
||||
mockDocker.listContainers.mockResolvedValue([
|
||||
{
|
||||
Id: 'c1',
|
||||
Names: ['/c1'],
|
||||
State: 'exited',
|
||||
ImageID: 'img1',
|
||||
Labels: { 'com.docker.compose.project': 'my-stack' },
|
||||
},
|
||||
]);
|
||||
const dc = DockerController.getInstance(1);
|
||||
const plan = await dc.buildPrunePlan(['containers'], 'managed', ['my-stack'], 1);
|
||||
const stale = { ...plan, fingerprint: 'deadbeef' };
|
||||
|
||||
await expect(dc.executePrunePlan(stale, ['my-stack'])).rejects.toBeInstanceOf(PrunePlanStaleError);
|
||||
});
|
||||
|
||||
it('skips a planned container that becomes running before delete', async () => {
|
||||
mockDocker.listContainers.mockResolvedValue([
|
||||
{
|
||||
Id: 'c1',
|
||||
Names: ['/c1'],
|
||||
State: 'exited',
|
||||
ImageID: 'img1',
|
||||
Labels: { 'com.docker.compose.project': 'my-stack' },
|
||||
},
|
||||
]);
|
||||
const remove = vi.fn();
|
||||
mockDocker.getContainer.mockReturnValue({
|
||||
inspect: vi.fn().mockResolvedValue({
|
||||
State: { Status: 'running' },
|
||||
Config: { Labels: { 'com.docker.compose.project': 'my-stack' } },
|
||||
Image: 'img1',
|
||||
}),
|
||||
remove,
|
||||
});
|
||||
|
||||
const dc = DockerController.getInstance(1);
|
||||
const plan = await dc.buildPrunePlan(['containers'], 'managed', ['my-stack'], 1);
|
||||
const result = await dc.executePrunePlan(plan, ['my-stack']);
|
||||
|
||||
expect(remove).not.toHaveBeenCalled();
|
||||
expect(result.outcomes).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'c1',
|
||||
target: 'containers',
|
||||
status: 'skipped',
|
||||
reason: expect.stringMatching(/running/i),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips a planned image when its container removal failed and the image is still referenced', async () => {
|
||||
mockDocker.listContainers.mockResolvedValue([
|
||||
{
|
||||
Id: 'c1',
|
||||
Names: ['/c1'],
|
||||
State: 'exited',
|
||||
ImageID: 'img1',
|
||||
Labels: { 'com.docker.compose.project': 'my-stack' },
|
||||
},
|
||||
]);
|
||||
mockDocker.listImages.mockResolvedValue([
|
||||
{ Id: 'img1', RepoTags: ['app:latest'], Size: 1000, Containers: 1 },
|
||||
]);
|
||||
mockDocker.listNetworks.mockResolvedValue([]);
|
||||
mockDocker.listVolumes.mockResolvedValue({ Volumes: [] });
|
||||
|
||||
const containerRemove = vi.fn().mockRejectedValue(new Error('busy'));
|
||||
mockDocker.getContainer.mockReturnValue({
|
||||
inspect: vi.fn().mockResolvedValue({
|
||||
State: { Status: 'exited' },
|
||||
Config: { Labels: { 'com.docker.compose.project': 'my-stack' } },
|
||||
Image: 'img1',
|
||||
}),
|
||||
remove: containerRemove,
|
||||
});
|
||||
const imageRemove = vi.fn();
|
||||
mockDocker.getImage.mockReturnValue({ remove: imageRemove });
|
||||
|
||||
const dc = DockerController.getInstance(1);
|
||||
const plan = await dc.buildPrunePlan(['containers', 'images'], 'managed', ['my-stack'], 1);
|
||||
expect(plan.items.some((i) => i.target === 'images' && i.id === 'img1')).toBe(true);
|
||||
|
||||
// After failed container remove, image list still reports Containers > 0.
|
||||
mockDocker.listImages.mockResolvedValue([
|
||||
{ Id: 'img1', RepoTags: ['app:latest'], Size: 1000, Containers: 1 },
|
||||
]);
|
||||
|
||||
const result = await dc.executePrunePlan(plan, ['my-stack']);
|
||||
const imageOutcome = result.outcomes.find((o) => o.target === 'images' && o.id === 'img1');
|
||||
expect(imageOutcome).toMatchObject({ status: 'skipped' });
|
||||
expect(imageRemove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never deletes items that are not in the plan', async () => {
|
||||
mockDocker.listVolumes.mockResolvedValue({
|
||||
Volumes: [
|
||||
{
|
||||
Name: 'planned-vol',
|
||||
Labels: { 'com.docker.compose.project': 'my-stack' },
|
||||
UsageData: null,
|
||||
},
|
||||
{
|
||||
Name: 'other-vol',
|
||||
Labels: { 'com.docker.compose.project': 'my-stack' },
|
||||
UsageData: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
mockDocker.df.mockResolvedValue({
|
||||
Volumes: [
|
||||
{ Name: 'planned-vol', UsageData: { RefCount: 0, Size: 10 } },
|
||||
{ Name: 'other-vol', UsageData: { RefCount: 0, Size: 20 } },
|
||||
],
|
||||
Images: [],
|
||||
LayersSize: 0,
|
||||
});
|
||||
|
||||
const removed: string[] = [];
|
||||
mockDocker.getVolume.mockImplementation((name: string) => ({
|
||||
remove: vi.fn().mockImplementation(async () => { removed.push(name); }),
|
||||
}));
|
||||
|
||||
const dc = DockerController.getInstance(1);
|
||||
const plan = await dc.buildPrunePlan(['volumes'], 'managed', ['my-stack'], 1);
|
||||
// Execute a surgically narrowed plan that only includes planned-vol.
|
||||
const narrow = {
|
||||
...plan,
|
||||
items: plan.items.filter((i) => i.id === 'planned-vol'),
|
||||
fingerprint: fingerprintPrunePlan(1, 'managed', ['volumes'], [{ target: 'volumes', id: 'planned-vol' }]),
|
||||
reclaimableBytes: 10,
|
||||
};
|
||||
|
||||
// assertPlanFresh rebuilds from Docker and will see both volumes, so the
|
||||
// fingerprint will mismatch. Instead spy assertPlanFresh to accept the
|
||||
// narrowed plan as fresh so we can assert execute only touches plan items.
|
||||
vi.spyOn(dc, 'assertPlanFresh').mockResolvedValue(narrow);
|
||||
await dc.executePrunePlan(narrow, ['my-stack']);
|
||||
|
||||
expect(removed).toEqual(['planned-vol']);
|
||||
expect(removed).not.toContain('other-vol');
|
||||
});
|
||||
|
||||
it('removes with force:false', async () => {
|
||||
mockDocker.listVolumes.mockResolvedValue({
|
||||
Volumes: [{
|
||||
Name: 'v1',
|
||||
Labels: { 'com.docker.compose.project': 'my-stack' },
|
||||
UsageData: null,
|
||||
}],
|
||||
});
|
||||
mockDocker.df.mockResolvedValue({
|
||||
Volumes: [{ Name: 'v1', UsageData: { RefCount: 0, Size: 5 } }],
|
||||
Images: [],
|
||||
LayersSize: 0,
|
||||
});
|
||||
const remove = vi.fn().mockResolvedValue(undefined);
|
||||
mockDocker.getVolume.mockReturnValue({ remove });
|
||||
|
||||
const dc = DockerController.getInstance(1);
|
||||
const plan = await dc.buildPrunePlan(['volumes'], 'managed', ['my-stack'], 1);
|
||||
await dc.executePrunePlan(plan, ['my-stack']);
|
||||
|
||||
expect(remove).toHaveBeenCalledWith({ force: false });
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,10 @@
|
||||
/**
|
||||
* Route-level test for F-6: when `docker system df` is slow, the prune
|
||||
* estimate endpoints must return 503 with code `docker_df_slow` instead
|
||||
* of hanging the admin's tab. Mirrors the pattern from
|
||||
* system-maintenance-self-protect.test.ts.
|
||||
* Route-level tests for prune estimate timeouts (F-6) and the fingerprinted
|
||||
* prune plan / stale-409 path used by Resources.
|
||||
*
|
||||
* Uses real timers because supertest dispatches lazily and vi.useFakeTimers
|
||||
* does not compose cleanly with that pattern. Each timeout test waits the
|
||||
* full 8s `withTimeout` budget, so two such tests add ~17s to the file.
|
||||
* full 8s withTimeout budget, so two such tests add ~17s to the file.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
@@ -48,6 +46,18 @@ function stubEstimate(impl: () => Promise<{ reclaimableBytes: number }>) {
|
||||
} as unknown as ReturnType<typeof DockerController.getInstance>);
|
||||
}
|
||||
|
||||
function samplePlan(fingerprint = 'abc123') {
|
||||
return {
|
||||
scope: 'managed' as const,
|
||||
targets: ['volumes' as const],
|
||||
items: [{ target: 'volumes' as const, id: 'v1', name: 'v1', sizeBytes: 42 }],
|
||||
reclaimableBytes: 42,
|
||||
fingerprint,
|
||||
createdAt: Date.now(),
|
||||
nodeId: 1,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Prune estimate endpoints return 503 on slow docker df (F-6)', () => {
|
||||
it('POST /api/system/prune/estimate returns 503 docker_df_slow when estimateSystemReclaim never settles', async () => {
|
||||
stubFsStacks();
|
||||
@@ -63,15 +73,15 @@ describe('Prune estimate endpoints return 503 on slow docker df (F-6)', () => {
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.body.code).toBe('docker_df_slow');
|
||||
expect(res.body.error).toMatch(/Docker daemon is busy/);
|
||||
// Confirm the timeout actually fired (~8s), not an unrelated early
|
||||
// 5xx that happened to look right.
|
||||
expect(elapsed).toBeGreaterThanOrEqual(7_500);
|
||||
expect(elapsed).toBeLessThan(15_000);
|
||||
}, 20_000);
|
||||
|
||||
it('POST /api/system/prune/system dry-run returns 503 docker_df_slow when estimateSystemReclaim never settles', async () => {
|
||||
it('POST /api/system/prune/system dry-run returns 503 docker_df_slow when buildPrunePlan never settles', async () => {
|
||||
stubFsStacks();
|
||||
stubEstimate(() => new Promise(() => { /* never resolves */ }));
|
||||
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
|
||||
buildPrunePlan: vi.fn().mockImplementation(() => new Promise(() => { /* never resolves */ })),
|
||||
} as unknown as ReturnType<typeof DockerController.getInstance>);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/system/prune/system')
|
||||
@@ -108,3 +118,85 @@ describe('Prune estimate endpoints return 503 on slow docker df (F-6)', () => {
|
||||
expect(res.body.code).not.toBe('docker_df_slow');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Prune plan routes', () => {
|
||||
it('POST /api/system/prune/plan returns an itemized plan', async () => {
|
||||
stubFsStacks();
|
||||
const plan = samplePlan('fp-plan');
|
||||
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
|
||||
buildPrunePlan: vi.fn().mockResolvedValue(plan),
|
||||
} as unknown as ReturnType<typeof DockerController.getInstance>);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/system/prune/plan')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ target: 'volumes', scope: 'managed' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.fingerprint).toBe('fp-plan');
|
||||
expect(res.body.items).toHaveLength(1);
|
||||
expect(res.body.reclaimableBytes).toBe(42);
|
||||
});
|
||||
|
||||
it('POST /api/system/prune/system with a matching fingerprint executes the plan', async () => {
|
||||
stubFsStacks();
|
||||
const plan = samplePlan('fp-ok');
|
||||
const executePrunePlan = vi.fn().mockResolvedValue({
|
||||
outcomes: [{ id: 'v1', target: 'volumes', status: 'removed', sizeBytes: 42 }],
|
||||
reclaimedBytes: 42,
|
||||
success: true,
|
||||
});
|
||||
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
|
||||
buildPrunePlan: vi.fn().mockResolvedValue(plan),
|
||||
executePrunePlan,
|
||||
} as unknown as ReturnType<typeof DockerController.getInstance>);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/system/prune/system')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ target: 'volumes', scope: 'managed', planFingerprint: 'fp-ok' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.reclaimedBytes).toBe(42);
|
||||
expect(res.body.outcomes).toHaveLength(1);
|
||||
expect(executePrunePlan).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('POST /api/system/prune/system returns 409 PRUNE_PLAN_STALE on fingerprint mismatch', async () => {
|
||||
stubFsStacks();
|
||||
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
|
||||
buildPrunePlan: vi.fn().mockResolvedValue(samplePlan('fp-current')),
|
||||
executePrunePlan: vi.fn(),
|
||||
} as unknown as ReturnType<typeof DockerController.getInstance>);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/system/prune/system')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ target: 'volumes', scope: 'managed', planFingerprint: 'fp-stale' });
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.code).toBe('PRUNE_PLAN_STALE');
|
||||
});
|
||||
|
||||
it('dry-run returns plan fields without executing deletes', async () => {
|
||||
stubFsStacks();
|
||||
const plan = samplePlan('fp-dry');
|
||||
const executePrunePlan = vi.fn();
|
||||
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
|
||||
buildPrunePlan: vi.fn().mockResolvedValue(plan),
|
||||
executePrunePlan,
|
||||
} as unknown as ReturnType<typeof DockerController.getInstance>);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/system/prune/system')
|
||||
.set('Authorization', authHeader)
|
||||
.send({ target: 'volumes', scope: 'managed', dryRun: true });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.dryRun).toBe(true);
|
||||
expect(res.body.fingerprint).toBe('fp-dry');
|
||||
expect(res.body.items).toHaveLength(1);
|
||||
expect(executePrunePlan).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,7 +51,7 @@ export const auditLog: RequestHandler = (req: Request, res: Response, next: Next
|
||||
status_code: res.statusCode,
|
||||
node_id: nodeId,
|
||||
ip_address: ip,
|
||||
summary: getAuditSummary(req.method, apiPath),
|
||||
summary: getAuditSummary(req.method, apiPath, res.statusCode),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Audit] Failed to write audit log:', err);
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import DockerController, { type CreateNetworkOptions, type NetworkDriver } from '../services/DockerController';
|
||||
import DockerController, {
|
||||
PrunePlanStaleError,
|
||||
type CreateNetworkOptions,
|
||||
type NetworkDriver,
|
||||
type PruneScope,
|
||||
type PruneTarget,
|
||||
} from '../services/DockerController';
|
||||
import { isPruneTarget } from '../services/prunePlan';
|
||||
import { FileSystemService } from '../services/FileSystemService';
|
||||
import SelfIdentityService from '../services/SelfIdentityService';
|
||||
import { requireAdmin } from '../middleware/tierGates';
|
||||
@@ -88,61 +95,170 @@ systemMaintenanceRouter.post('/prune/orphans', async (req: Request, res: Respons
|
||||
}
|
||||
});
|
||||
|
||||
function parsePruneTargets(body: {
|
||||
target?: unknown;
|
||||
targets?: unknown;
|
||||
}): PruneTarget[] | null {
|
||||
if (Array.isArray(body.targets)) {
|
||||
if (body.targets.length === 0) return null;
|
||||
if (!body.targets.every(isPruneTarget)) return null;
|
||||
return body.targets;
|
||||
}
|
||||
if (isPruneTarget(body.target)) return [body.target];
|
||||
return null;
|
||||
}
|
||||
|
||||
function parsePruneScope(scope: unknown): PruneScope {
|
||||
return scope === 'managed' ? 'managed' : 'all';
|
||||
}
|
||||
|
||||
// Preview an itemized prune plan (no deletes). Resources confirm dialogs call
|
||||
// this before enabling the destructive confirm button.
|
||||
systemMaintenanceRouter.post('/prune/plan', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const targets = parsePruneTargets(req.body as { target?: unknown; targets?: unknown });
|
||||
if (!targets) {
|
||||
return res.status(400).json({ error: 'Invalid prune target(s)' });
|
||||
}
|
||||
const pruneScope = parsePruneScope((req.body as { scope?: unknown }).scope);
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
|
||||
const plan = await withTimeout(
|
||||
dockerController.buildPrunePlan(targets, pruneScope, knownStacks, req.nodeId),
|
||||
PRUNE_ESTIMATE_TIMEOUT_MS,
|
||||
'docker prune plan',
|
||||
);
|
||||
if (isDebugEnabled()) {
|
||||
console.debug('[Resources:debug] Prune plan', {
|
||||
scope: pruneScope,
|
||||
targets: plan.targets,
|
||||
items: plan.items.length,
|
||||
reclaimableBytes: plan.reclaimableBytes,
|
||||
});
|
||||
}
|
||||
res.json(plan);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof TimeoutError) {
|
||||
console.warn('Prune plan: docker enumeration timed out');
|
||||
return respondDfSlow(res);
|
||||
}
|
||||
console.error('Prune plan error:', error);
|
||||
res.status(500).json({ error: 'Failed to build prune plan' });
|
||||
}
|
||||
});
|
||||
|
||||
systemMaintenanceRouter.post('/prune/system', async (req: Request, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const { target, scope, dryRun } = req.body as { target: string; scope?: string; dryRun?: boolean };
|
||||
if (!['containers', 'images', 'networks', 'volumes'].includes(target)) {
|
||||
return res.status(400).json({ error: 'Invalid prune target' });
|
||||
const body = req.body as {
|
||||
target?: unknown;
|
||||
targets?: unknown;
|
||||
scope?: unknown;
|
||||
dryRun?: unknown;
|
||||
planFingerprint?: unknown;
|
||||
};
|
||||
const targets = parsePruneTargets(body);
|
||||
if (!targets) {
|
||||
return res.status(400).json({ error: 'Invalid prune target(s)' });
|
||||
}
|
||||
|
||||
const pruneScope = scope === 'managed' ? 'managed' : 'all';
|
||||
const isDryRun = dryRun === true;
|
||||
const pruneScope = parsePruneScope(body.scope);
|
||||
const isDryRun = body.dryRun === true;
|
||||
const planFingerprint = typeof body.planFingerprint === 'string' ? body.planFingerprint : null;
|
||||
const dockerController = DockerController.getInstance(req.nodeId);
|
||||
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
|
||||
|
||||
if (isDryRun) {
|
||||
// Rehearse the destructive path: same scope resolution, same Docker
|
||||
// enumeration, no remove calls. Containers have no managed estimate
|
||||
// helper because pruneManagedOnly does not handle them.
|
||||
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
|
||||
let estimate: { reclaimableBytes: number };
|
||||
if (pruneScope === 'managed' && target !== 'containers') {
|
||||
estimate = await dockerController.estimateManagedReclaim(
|
||||
target as 'images' | 'volumes' | 'networks',
|
||||
knownStacks,
|
||||
);
|
||||
} else {
|
||||
// estimateSystemReclaim calls `docker system df`; bound it so a slow
|
||||
// daemon doesn't hang the admin's tab (F-6).
|
||||
estimate = await withTimeout(
|
||||
dockerController.estimateSystemReclaim(
|
||||
target as 'containers' | 'images' | 'networks' | 'volumes',
|
||||
knownStacks,
|
||||
),
|
||||
PRUNE_ESTIMATE_TIMEOUT_MS,
|
||||
'docker disk usage',
|
||||
);
|
||||
}
|
||||
// Dry-run returns the same itemized plan shape Resources uses for preview.
|
||||
const plan = await withTimeout(
|
||||
dockerController.buildPrunePlan(targets, pruneScope, knownStacks, req.nodeId),
|
||||
PRUNE_ESTIMATE_TIMEOUT_MS,
|
||||
'docker prune plan',
|
||||
);
|
||||
if (isDebugEnabled()) {
|
||||
console.debug('[Resources:debug] Prune dry-run', {
|
||||
target, scope: pruneScope, reclaimableBytes: estimate.reclaimableBytes,
|
||||
targets: plan.targets,
|
||||
scope: pruneScope,
|
||||
reclaimableBytes: plan.reclaimableBytes,
|
||||
items: plan.items.length,
|
||||
});
|
||||
}
|
||||
res.json({ message: 'Dry run', success: true, dryRun: true, reclaimedBytes: estimate.reclaimableBytes });
|
||||
res.json({
|
||||
message: 'Dry run',
|
||||
success: true,
|
||||
dryRun: true,
|
||||
reclaimedBytes: plan.reclaimableBytes,
|
||||
...plan,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Resources path: fingerprint-bound execute. Fleet still calls without a
|
||||
// fingerprint and keeps the legacy pruneManagedOnly / pruneSystem path.
|
||||
if (planFingerprint) {
|
||||
const built = await withTimeout(
|
||||
dockerController.buildPrunePlan(targets, pruneScope, knownStacks, req.nodeId),
|
||||
PRUNE_ESTIMATE_TIMEOUT_MS,
|
||||
'docker prune plan',
|
||||
);
|
||||
if (built.fingerprint !== planFingerprint) {
|
||||
return res.status(409).json({
|
||||
error: 'Prune plan is stale; refresh and confirm again',
|
||||
code: 'PRUNE_PLAN_STALE',
|
||||
});
|
||||
}
|
||||
console.log(
|
||||
`[Resources] System prune (plan): ${built.targets.join(',')} (scope: ${pruneScope}, items: ${built.items.length})`,
|
||||
);
|
||||
const pruneStartedAt = Date.now();
|
||||
const result = await dockerController.executePrunePlan(built, knownStacks);
|
||||
console.log(
|
||||
`[Resources] System prune completed: reclaimed ${result.reclaimedBytes} bytes, outcomes=${result.outcomes.length}`,
|
||||
);
|
||||
if (isDebugEnabled()) {
|
||||
console.debug('[Resources:debug] System prune (plan)', {
|
||||
targets: built.targets,
|
||||
scope: pruneScope,
|
||||
ms: Date.now() - pruneStartedAt,
|
||||
reclaimedBytes: result.reclaimedBytes,
|
||||
success: result.success,
|
||||
});
|
||||
}
|
||||
if (built.targets.includes('containers')) {
|
||||
invalidateNodeCaches(req.nodeId);
|
||||
}
|
||||
res.json({
|
||||
message: 'Prune completed',
|
||||
success: result.success,
|
||||
reclaimedBytes: result.reclaimedBytes,
|
||||
outcomes: result.outcomes,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Legacy single-target path for Fleet (and any caller without a plan).
|
||||
if (targets.length > 1) {
|
||||
return res.status(400).json({
|
||||
error: 'Multi-target prune requires a planFingerprint from POST /system/prune/plan',
|
||||
});
|
||||
}
|
||||
const target = targets[0];
|
||||
console.log(`[Resources] System prune: ${target} (scope: ${pruneScope})`);
|
||||
const pruneStartedAt = Date.now();
|
||||
let result: { success: boolean; reclaimedBytes: number };
|
||||
if (pruneScope === 'managed' && target !== 'containers') {
|
||||
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
|
||||
result = await dockerController.pruneManagedOnly(
|
||||
target as 'images' | 'volumes' | 'networks',
|
||||
knownStacks
|
||||
knownStacks,
|
||||
);
|
||||
} else if (pruneScope === 'managed' && target === 'containers') {
|
||||
// Managed containers must never fall through to system prune. Build and
|
||||
// execute an itemized plan for this single target instead.
|
||||
const plan = await dockerController.buildPrunePlan(['containers'], 'managed', knownStacks, req.nodeId);
|
||||
result = await dockerController.executePrunePlan(plan, knownStacks);
|
||||
} else {
|
||||
result = await dockerController.pruneSystem(target as 'containers' | 'images' | 'networks' | 'volumes');
|
||||
result = await dockerController.pruneSystem(target);
|
||||
}
|
||||
|
||||
console.log(`[Resources] System prune completed: ${target}, reclaimed ${result.reclaimedBytes} bytes`);
|
||||
@@ -160,6 +276,9 @@ systemMaintenanceRouter.post('/prune/system', async (req: Request, res: Response
|
||||
console.warn('System prune: docker disk usage timed out');
|
||||
return respondDfSlow(res);
|
||||
}
|
||||
if (error instanceof PrunePlanStaleError) {
|
||||
return res.status(409).json({ error: error.message, code: error.code });
|
||||
}
|
||||
console.error('System prune error:', error);
|
||||
res.status(500).json({ error: 'System prune failed' });
|
||||
}
|
||||
|
||||
@@ -10,12 +10,32 @@ import { NodeRegistry } from './NodeRegistry';
|
||||
import { CacheService } from './CacheService';
|
||||
import { FileSystemService } from './FileSystemService';
|
||||
import SelfIdentityService from './SelfIdentityService';
|
||||
import {
|
||||
fingerprintPrunePlan,
|
||||
normalizePruneTargets,
|
||||
PRUNEABLE_CONTAINER_STATES,
|
||||
PrunePlanStaleError,
|
||||
type PruneItemOutcome,
|
||||
type PrunePlan,
|
||||
type PrunePlanItem,
|
||||
type PruneScope,
|
||||
type PruneTarget,
|
||||
} from './prunePlan';
|
||||
import { isPathWithinBase } from '../utils/validation';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
import { describeSpawnError } from '../utils/spawnErrors';
|
||||
import { authoredComposeFileArgs, authoredComposeEnvFileArgs } from '../utils/authoredComposeArgs';
|
||||
|
||||
export type {
|
||||
PruneItemOutcome,
|
||||
PrunePlan,
|
||||
PrunePlanItem,
|
||||
PruneScope,
|
||||
PruneTarget,
|
||||
} from './prunePlan';
|
||||
export { PrunePlanStaleError } from './prunePlan';
|
||||
|
||||
/** Parsed row from `docker compose ps --format json`. */
|
||||
interface ComposePsContainer {
|
||||
ID?: string;
|
||||
@@ -537,7 +557,8 @@ class DockerController {
|
||||
*/
|
||||
private async safeDfSnapshot(): Promise<{
|
||||
LayersSize?: number;
|
||||
Images?: Array<{ Id?: string; SharedSize?: number }>;
|
||||
Images?: Array<{ Id?: string; Size?: number; VirtualSize?: number; SharedSize?: number; Containers?: number }>;
|
||||
Volumes?: Array<{ Name?: string; UsageData?: { RefCount?: number; Size?: number } }>;
|
||||
} | null> {
|
||||
try {
|
||||
return await this.docker.df();
|
||||
@@ -546,6 +567,29 @@ class DockerController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Volume RefCount/Size come from `docker system df`, not `GET /volumes`.
|
||||
* listVolumes returns UsageData: null on real daemons, so treating a missing
|
||||
* RefCount as "in use" (?? 1) would make every volume look non-prunable.
|
||||
*/
|
||||
private static mapVolumeUsageFromDf(
|
||||
df: { Volumes?: Array<{ Name?: string; UsageData?: { RefCount?: number; Size?: number } }> } | null,
|
||||
): Map<string, { refCount: number; size: number }> {
|
||||
const m = new Map<string, { refCount: number; size: number }>();
|
||||
if (!df?.Volumes) return m;
|
||||
for (const vol of df.Volumes) {
|
||||
if (!vol?.Name) continue;
|
||||
const refCount = vol.UsageData?.RefCount;
|
||||
// Missing RefCount is unknown usage; omit so callers treat as non-prunable.
|
||||
if (typeof refCount !== 'number') continue;
|
||||
m.set(vol.Name, {
|
||||
refCount,
|
||||
size: typeof vol.UsageData?.Size === 'number' ? vol.UsageData.Size : 0,
|
||||
});
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts `Id -> SharedSize` from a df snapshot. Treats missing or
|
||||
* negative (Docker's "unknown" sentinel) SharedSize as 0 so the caller
|
||||
@@ -565,6 +609,18 @@ class DockerController {
|
||||
return m;
|
||||
}
|
||||
|
||||
/** Unique-ish reclaim estimate for one image: Size/VirtualSize minus SharedSize. */
|
||||
private static imageUniqueBytes(
|
||||
img: { Id: string; Size?: number; VirtualSize?: number },
|
||||
sharedSizes: Map<string, number>,
|
||||
): number {
|
||||
let virt = -1;
|
||||
if (typeof img.VirtualSize === 'number' && img.VirtualSize >= 0) virt = img.VirtualSize;
|
||||
else if (typeof img.Size === 'number' && img.Size >= 0) virt = img.Size;
|
||||
if (virt < 0) return 0;
|
||||
return Math.max(0, virt - (sharedSizes.get(img.Id) ?? 0));
|
||||
}
|
||||
|
||||
public async pruneManagedOnly(
|
||||
target: 'images' | 'volumes' | 'networks',
|
||||
knownStackNames: string[]
|
||||
@@ -577,9 +633,12 @@ class DockerController {
|
||||
if (target === 'volumes') {
|
||||
const rawVolumeData = await this.docker.listVolumes();
|
||||
const rawVolumes: any[] = (this.validateApiData<any>(rawVolumeData)).Volumes || [];
|
||||
const volumeUsage = DockerController.mapVolumeUsageFromDf(await this.safeDfSnapshot());
|
||||
const prunable = rawVolumes.filter((v: any) => {
|
||||
const usage = volumeUsage.get(v.Name);
|
||||
// Missing from df: unknown usage; do not prune.
|
||||
if (!usage || usage.refCount !== 0) return false;
|
||||
return !!DockerController.resolveProjectLabel(v.Labels?.['com.docker.compose.project'], knownSet, projectToStack)
|
||||
&& (v.UsageData?.RefCount ?? 1) === 0
|
||||
&& !selfIdentity.isOwnVolume(v.Name);
|
||||
});
|
||||
// Removals are independent and Docker handles concurrent volume
|
||||
@@ -588,7 +647,7 @@ class DockerController {
|
||||
await Promise.all(prunable.map(async (vol) => {
|
||||
try {
|
||||
await this.docker.getVolume(vol.Name).remove({ force: true });
|
||||
reclaimedBytes += vol.UsageData?.Size ?? 0;
|
||||
reclaimedBytes += volumeUsage.get(vol.Name)?.size ?? 0;
|
||||
} catch (e) {
|
||||
console.error(`[pruneManagedOnly] Failed to remove volume ${vol.Name}:`, e);
|
||||
}
|
||||
@@ -612,18 +671,24 @@ class DockerController {
|
||||
const resolvedBase = path.resolve(COMPOSE_DIR);
|
||||
const absDirToStack = DockerController.buildAbsDirMap(knownStackNames);
|
||||
const unmanagedImageIds = new Set<string>();
|
||||
const managedImageIds = new Set<string>();
|
||||
for (const c of allContainers as any[]) {
|
||||
const stack = DockerController.resolveContainerStack(
|
||||
c.Labels, projectToStack, knownSet, absDirToStack, resolvedBase,
|
||||
);
|
||||
if (!stack) unmanagedImageIds.add(c.ImageID);
|
||||
if (!c.ImageID) continue;
|
||||
if (stack) managedImageIds.add(c.ImageID);
|
||||
else unmanagedImageIds.add(c.ImageID);
|
||||
}
|
||||
const rawImages = await this.docker.listImages({ all: false });
|
||||
const prunable = (rawImages as any[]).filter((img: any) =>
|
||||
img.Containers === 0
|
||||
&& !unmanagedImageIds.has(img.Id)
|
||||
&& !selfIdentity.isOwnImage(img.Id)
|
||||
);
|
||||
const prunable = (rawImages as any[]).filter((img: any) => {
|
||||
if (img.Containers !== 0 || selfIdentity.isOwnImage(img.Id)) return false;
|
||||
if (unmanagedImageIds.has(img.Id)) return false;
|
||||
const labeled = DockerController.resolveProjectLabel(
|
||||
img.Labels?.['com.docker.compose.project'], knownSet, projectToStack,
|
||||
);
|
||||
return !!labeled || managedImageIds.has(img.Id);
|
||||
});
|
||||
// df-before / df-after delta is the only honest measurement of bytes
|
||||
// actually freed. Per-image (Size - SharedSize) undercounts layers
|
||||
// shared exclusively between prunable images (Docker frees the layer
|
||||
@@ -685,12 +750,14 @@ class DockerController {
|
||||
if (target === 'volumes') {
|
||||
const rawVolumeData = await this.docker.listVolumes();
|
||||
const rawVolumes: any[] = (this.validateApiData<any>(rawVolumeData)).Volumes || [];
|
||||
const volumeUsage = DockerController.mapVolumeUsageFromDf(await this.safeDfSnapshot());
|
||||
const prunable = rawVolumes.filter((v: any) => {
|
||||
const usage = volumeUsage.get(v.Name);
|
||||
if (!usage || usage.refCount !== 0) return false;
|
||||
return !!DockerController.resolveProjectLabel(v.Labels?.['com.docker.compose.project'], knownSet, projectToStack)
|
||||
&& (v.UsageData?.RefCount ?? 1) === 0
|
||||
&& !selfIdentity.isOwnVolume(v.Name);
|
||||
});
|
||||
for (const vol of prunable) reclaimableBytes += vol.UsageData?.Size ?? 0;
|
||||
for (const vol of prunable) reclaimableBytes += volumeUsage.get(vol.Name)?.size ?? 0;
|
||||
} else if (target === 'networks') {
|
||||
// Networks have no on-disk size; the dry-run still reports 0 so the
|
||||
// shape matches the destructive path.
|
||||
@@ -699,18 +766,25 @@ class DockerController {
|
||||
const resolvedBase = path.resolve(COMPOSE_DIR);
|
||||
const absDirToStack = DockerController.buildAbsDirMap(knownStackNames);
|
||||
const unmanagedImageIds = new Set<string>();
|
||||
const managedImageIds = new Set<string>();
|
||||
for (const c of allContainers as any[]) {
|
||||
const stack = DockerController.resolveContainerStack(
|
||||
c.Labels, projectToStack, knownSet, absDirToStack, resolvedBase,
|
||||
);
|
||||
if (!stack) unmanagedImageIds.add(c.ImageID);
|
||||
if (!c.ImageID) continue;
|
||||
if (stack) managedImageIds.add(c.ImageID);
|
||||
else unmanagedImageIds.add(c.ImageID);
|
||||
}
|
||||
const rawImages = await this.docker.listImages({ all: false });
|
||||
const prunable = (rawImages as any[]).filter((img: any) =>
|
||||
img.Containers === 0
|
||||
&& !unmanagedImageIds.has(img.Id)
|
||||
&& !selfIdentity.isOwnImage(img.Id),
|
||||
);
|
||||
const prunable = (rawImages as any[]).filter((img: any) => {
|
||||
if (img.Containers !== 0 || selfIdentity.isOwnImage(img.Id)) return false;
|
||||
if (unmanagedImageIds.has(img.Id)) return false;
|
||||
// Unused images with no container attribution are not Sencho-managed.
|
||||
const labeled = DockerController.resolveProjectLabel(
|
||||
img.Labels?.['com.docker.compose.project'], knownSet, projectToStack,
|
||||
);
|
||||
return !!labeled || managedImageIds.has(img.Id);
|
||||
});
|
||||
const sharedSizes = DockerController.mapSharedSizesFromDf(await this.safeDfSnapshot());
|
||||
for (const img of prunable) {
|
||||
reclaimableBytes += Math.max(0, (img.Size ?? 0) - (sharedSizes.get(img.Id) ?? 0));
|
||||
@@ -737,6 +811,530 @@ class DockerController {
|
||||
return { reclaimableBytes: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an itemized prune plan using the same eligibility predicates that
|
||||
* `executePrunePlan` revalidates before each delete. Never calls remove APIs.
|
||||
*/
|
||||
public async buildPrunePlan(
|
||||
targets: PruneTarget[],
|
||||
scope: PruneScope,
|
||||
knownStackNames: string[],
|
||||
nodeId: number = this.nodeId,
|
||||
): Promise<PrunePlan> {
|
||||
const ordered = normalizePruneTargets(targets);
|
||||
const knownSet = new Set(knownStackNames);
|
||||
const projectToStack = await DockerController.resolveProjectNameMap(knownStackNames);
|
||||
const selfIdentity = SelfIdentityService.getInstance();
|
||||
const absDirToStack = DockerController.buildAbsDirMap(knownStackNames);
|
||||
const resolvedBase = path.resolve(COMPOSE_DIR);
|
||||
|
||||
const allContainers = await this.docker.listContainers({ all: true, size: true }) as Array<{
|
||||
Id: string;
|
||||
Names?: string[];
|
||||
State?: string;
|
||||
Status?: string;
|
||||
Image?: string;
|
||||
ImageID?: string;
|
||||
Labels?: Record<string, string>;
|
||||
SizeRw?: number;
|
||||
NetworkSettings?: { Networks?: Record<string, unknown> };
|
||||
}>;
|
||||
|
||||
const items: PrunePlanItem[] = [];
|
||||
|
||||
const containerName = (c: { Id: string; Names?: string[] }) =>
|
||||
(c.Names?.[0] ?? c.Id).replace(/^\//, '');
|
||||
|
||||
if (ordered.includes('containers')) {
|
||||
for (const c of allContainers) {
|
||||
if (selfIdentity.isOwnContainer(c.Id)) continue;
|
||||
const state = String(c.State ?? '').toLowerCase();
|
||||
if (!PRUNEABLE_CONTAINER_STATES.has(state)) continue;
|
||||
if (scope === 'managed') {
|
||||
const stack = DockerController.resolveContainerStack(
|
||||
c.Labels, projectToStack, knownSet, absDirToStack, resolvedBase,
|
||||
);
|
||||
if (!stack) continue;
|
||||
}
|
||||
items.push({
|
||||
target: 'containers',
|
||||
id: c.Id,
|
||||
name: containerName(c),
|
||||
sizeBytes: typeof c.SizeRw === 'number' && c.SizeRw > 0 ? c.SizeRw : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Volume usage (RefCount/Size) must come from docker.df(); listVolumes
|
||||
// returns UsageData: null on real daemons.
|
||||
const dfSnapshot = await this.safeDfSnapshot();
|
||||
const volumeUsage = DockerController.mapVolumeUsageFromDf(dfSnapshot);
|
||||
const sharedSizes = DockerController.mapSharedSizesFromDf(dfSnapshot);
|
||||
|
||||
if (ordered.includes('volumes')) {
|
||||
const rawVolumeData = await this.docker.listVolumes();
|
||||
const rawVolumes = (this.validateApiData<{ Volumes?: Array<{
|
||||
Name: string;
|
||||
Labels?: Record<string, string>;
|
||||
}> }>(rawVolumeData)).Volumes || [];
|
||||
for (const vol of rawVolumes) {
|
||||
if (selfIdentity.isOwnVolume(vol.Name)) continue;
|
||||
const usage = volumeUsage.get(vol.Name);
|
||||
if (!usage || usage.refCount !== 0) continue;
|
||||
if (scope === 'managed') {
|
||||
const stack = DockerController.resolveProjectLabel(
|
||||
vol.Labels?.['com.docker.compose.project'], knownSet, projectToStack,
|
||||
);
|
||||
if (!stack) continue;
|
||||
}
|
||||
items.push({
|
||||
target: 'volumes',
|
||||
id: vol.Name,
|
||||
name: vol.Name,
|
||||
sizeBytes: usage.size > 0 ? usage.size : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (ordered.includes('networks')) {
|
||||
const rawNetworks = await this.docker.listNetworks() as Array<{
|
||||
Id: string;
|
||||
Name: string;
|
||||
Labels?: Record<string, string>;
|
||||
}>;
|
||||
const networksInUse = new Set<string>();
|
||||
for (const c of allContainers) {
|
||||
const nets = c.NetworkSettings?.Networks;
|
||||
if (!nets) continue;
|
||||
for (const netName of Object.keys(nets)) {
|
||||
networksInUse.add(netName);
|
||||
}
|
||||
}
|
||||
for (const net of rawNetworks) {
|
||||
if (DockerController.SYSTEM_NETWORKS.has(net.Name)) continue;
|
||||
if (selfIdentity.isOwnNetwork(net.Id) || selfIdentity.isOwnNetwork(net.Name)) continue;
|
||||
if (networksInUse.has(net.Name) || networksInUse.has(net.Id)) continue;
|
||||
// Belt-and-braces: inspect when list summary did not expose attachments.
|
||||
try {
|
||||
const inspected = await this.docker.getNetwork(net.Id).inspect() as {
|
||||
Containers?: Record<string, unknown>;
|
||||
};
|
||||
if (inspected.Containers && Object.keys(inspected.Containers).length > 0) continue;
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
`[buildPrunePlan] Skipping network ${sanitizeForLog(net.Name)}: inspect failed:`,
|
||||
sanitizeForLog(e instanceof Error ? e.message : String(e)),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (scope === 'managed') {
|
||||
const stack = DockerController.resolveProjectLabel(
|
||||
net.Labels?.['com.docker.compose.project'], knownSet, projectToStack,
|
||||
);
|
||||
if (!stack) continue;
|
||||
}
|
||||
items.push({ target: 'networks', id: net.Id, name: net.Name });
|
||||
}
|
||||
}
|
||||
|
||||
if (ordered.includes('images')) {
|
||||
const unmanagedImageIds = new Set<string>();
|
||||
const managedImageIds = new Set<string>();
|
||||
const imageToContainerIds = new Map<string, string[]>();
|
||||
for (const c of allContainers) {
|
||||
if (!c.ImageID) continue;
|
||||
const refs = imageToContainerIds.get(c.ImageID) ?? [];
|
||||
refs.push(c.Id);
|
||||
imageToContainerIds.set(c.ImageID, refs);
|
||||
const stack = DockerController.resolveContainerStack(
|
||||
c.Labels, projectToStack, knownSet, absDirToStack, resolvedBase,
|
||||
);
|
||||
if (stack) managedImageIds.add(c.ImageID);
|
||||
else unmanagedImageIds.add(c.ImageID);
|
||||
}
|
||||
const plannedContainerIds = new Set(
|
||||
items.filter((i) => i.target === 'containers').map((i) => i.id),
|
||||
);
|
||||
const rawImages = await this.docker.listImages({ all: false }) as Array<{
|
||||
Id: string;
|
||||
RepoTags?: string[] | null;
|
||||
Labels?: Record<string, string>;
|
||||
Size?: number;
|
||||
VirtualSize?: number;
|
||||
Containers?: number;
|
||||
}>;
|
||||
// An image becomes free only when every container that references it is
|
||||
// also in this plan (not merely when any planned container uses it).
|
||||
const freeingImages = ordered.includes('containers');
|
||||
for (const img of rawImages) {
|
||||
if (selfIdentity.isOwnImage(img.Id)) continue;
|
||||
const containers = img.Containers ?? 0;
|
||||
const refs = imageToContainerIds.get(img.Id) ?? [];
|
||||
const becomesFree = freeingImages
|
||||
&& refs.length > 0
|
||||
&& refs.length >= containers
|
||||
&& refs.every((id) => plannedContainerIds.has(id));
|
||||
if (containers > 0 && !becomesFree) continue;
|
||||
if (scope === 'managed') {
|
||||
if (unmanagedImageIds.has(img.Id)) continue;
|
||||
const labeled = DockerController.resolveProjectLabel(
|
||||
img.Labels?.['com.docker.compose.project'], knownSet, projectToStack,
|
||||
);
|
||||
// Unattributed unused images (no managed container, no compose label)
|
||||
// are not Sencho-managed; keep them out of managed prune.
|
||||
if (!becomesFree && !labeled && !managedImageIds.has(img.Id)) continue;
|
||||
}
|
||||
const name = img.RepoTags?.[0] && img.RepoTags[0] !== '<none>:<none>'
|
||||
? img.RepoTags[0]
|
||||
: img.Id.slice(0, 12);
|
||||
const unique = DockerController.imageUniqueBytes(img, sharedSizes);
|
||||
items.push({
|
||||
target: 'images',
|
||||
id: img.Id,
|
||||
name,
|
||||
sizeBytes: unique > 0 ? unique : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve target execution order in the item list for stable previews.
|
||||
const targetRank = new Map(ordered.map((t, i) => [t, i]));
|
||||
items.sort((a, b) => {
|
||||
const tr = (targetRank.get(a.target) ?? 99) - (targetRank.get(b.target) ?? 99);
|
||||
if (tr !== 0) return tr;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
|
||||
const reclaimableBytes = items.reduce((acc, item) => acc + (item.sizeBytes ?? 0), 0);
|
||||
const fingerprint = fingerprintPrunePlan(nodeId, scope, ordered, items);
|
||||
|
||||
return {
|
||||
scope,
|
||||
targets: ordered,
|
||||
items,
|
||||
reclaimableBytes,
|
||||
fingerprint,
|
||||
createdAt: Date.now(),
|
||||
nodeId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the plan with the same targets/scope. Returns the fresh plan when
|
||||
* the fingerprint still matches, otherwise null (caller maps to 409).
|
||||
*/
|
||||
public async assertPlanFresh(
|
||||
plan: PrunePlan,
|
||||
knownStackNames: string[],
|
||||
): Promise<PrunePlan | null> {
|
||||
const rebuilt = await this.buildPrunePlan(plan.targets, plan.scope, knownStackNames, plan.nodeId);
|
||||
if (rebuilt.fingerprint !== plan.fingerprint) return null;
|
||||
return rebuilt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a previously previewed plan: revalidate fingerprint, then delete
|
||||
* each planned item serially with force:false. Never deletes unplanned items.
|
||||
*/
|
||||
public async executePrunePlan(
|
||||
plan: PrunePlan,
|
||||
knownStackNames: string[],
|
||||
): Promise<{ outcomes: PruneItemOutcome[]; reclaimedBytes: number; success: boolean }> {
|
||||
const fresh = await this.assertPlanFresh(plan, knownStackNames);
|
||||
if (!fresh) throw new PrunePlanStaleError();
|
||||
|
||||
const knownSet = new Set(knownStackNames);
|
||||
const projectToStack = await DockerController.resolveProjectNameMap(knownStackNames);
|
||||
const selfIdentity = SelfIdentityService.getInstance();
|
||||
const absDirToStack = DockerController.buildAbsDirMap(knownStackNames);
|
||||
const resolvedBase = path.resolve(COMPOSE_DIR);
|
||||
|
||||
const outcomes: PruneItemOutcome[] = [];
|
||||
let reclaimedBytes = 0;
|
||||
/** Image IDs whose planned container removal failed or was skipped. */
|
||||
const blockedImageIds = new Set<string>();
|
||||
|
||||
const imageIdBlocked = (imageId: string): boolean => {
|
||||
for (const id of blockedImageIds) {
|
||||
if (imageId === id || imageId.startsWith(id) || id.startsWith(imageId)) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Items are already sorted in dependency-safe target order by buildPrunePlan.
|
||||
for (const item of fresh.items) {
|
||||
const { target } = item;
|
||||
try {
|
||||
if (target === 'containers') {
|
||||
const outcome = await this.executePlannedContainer(
|
||||
item, fresh.scope, knownSet, projectToStack, absDirToStack, resolvedBase, selfIdentity,
|
||||
);
|
||||
if (outcome.status !== 'removed' && outcome.imageId) {
|
||||
blockedImageIds.add(outcome.imageId);
|
||||
}
|
||||
if (outcome.status === 'removed') {
|
||||
outcomes.push({
|
||||
id: outcome.id,
|
||||
target: 'containers',
|
||||
status: 'removed',
|
||||
sizeBytes: outcome.sizeBytes,
|
||||
});
|
||||
reclaimedBytes += outcome.sizeBytes ?? item.sizeBytes ?? 0;
|
||||
} else if (outcome.status === 'skipped') {
|
||||
outcomes.push({
|
||||
id: outcome.id,
|
||||
target: 'containers',
|
||||
status: 'skipped',
|
||||
reason: outcome.reason,
|
||||
});
|
||||
} else {
|
||||
outcomes.push({
|
||||
id: outcome.id,
|
||||
target: 'containers',
|
||||
status: 'failed',
|
||||
error: outcome.error,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (target === 'volumes') {
|
||||
const outcome = await this.executePlannedVolume(item, fresh.scope, knownSet, projectToStack, selfIdentity);
|
||||
outcomes.push(outcome);
|
||||
if (outcome.status === 'removed') reclaimedBytes += outcome.sizeBytes ?? item.sizeBytes ?? 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (target === 'networks') {
|
||||
outcomes.push(await this.executePlannedNetwork(item, fresh.scope, knownSet, projectToStack, selfIdentity));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (target === 'images') {
|
||||
if (imageIdBlocked(item.id)) {
|
||||
const stillReferenced = await this.imageStillReferenced(item.id);
|
||||
if (stillReferenced) {
|
||||
outcomes.push({
|
||||
id: item.id,
|
||||
target: 'images',
|
||||
status: 'skipped',
|
||||
reason: 'Still referenced after container prune skipped or failed',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const outcome = await this.executePlannedImage(
|
||||
item, fresh.scope, knownSet, projectToStack, absDirToStack, resolvedBase, selfIdentity,
|
||||
);
|
||||
outcomes.push(outcome);
|
||||
if (outcome.status === 'removed') reclaimedBytes += outcome.sizeBytes ?? item.sizeBytes ?? 0;
|
||||
}
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
console.error(`[executePrunePlan] Failed ${target} ${sanitizeForLog(item.id)}:`, sanitizeForLog(message));
|
||||
outcomes.push({ id: item.id, target, status: 'failed', error: message });
|
||||
if (target === 'containers') {
|
||||
const imageId = await this.lookupContainerImageId(item.id);
|
||||
if (imageId) blockedImageIds.add(imageId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const success = outcomes.every((o) => o.status !== 'failed');
|
||||
return { outcomes, reclaimedBytes, success };
|
||||
}
|
||||
|
||||
private async lookupContainerImageId(containerId: string): Promise<string | null> {
|
||||
try {
|
||||
const inspected = await this.docker.getContainer(containerId).inspect() as { Image?: string };
|
||||
return inspected.Image ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async imageStillReferenced(imageId: string): Promise<boolean> {
|
||||
try {
|
||||
const images = await this.docker.listImages({ all: false }) as Array<{ Id: string; Containers?: number }>;
|
||||
const match = images.find((img) => img.Id === imageId || img.Id.startsWith(imageId) || imageId.startsWith(img.Id));
|
||||
return (match?.Containers ?? 0) > 0;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private async executePlannedContainer(
|
||||
item: PrunePlanItem,
|
||||
scope: PruneScope,
|
||||
knownSet: Set<string>,
|
||||
projectToStack: Record<string, string>,
|
||||
absDirToStack: Record<string, string>,
|
||||
resolvedBase: string,
|
||||
selfIdentity: SelfIdentityService,
|
||||
): Promise<
|
||||
| { id: string; target: 'containers'; status: 'removed'; sizeBytes?: number; imageId?: string }
|
||||
| { id: string; target: 'containers'; status: 'skipped'; reason: string; imageId?: string }
|
||||
| { id: string; target: 'containers'; status: 'failed'; error: string; imageId?: string }
|
||||
> {
|
||||
if (selfIdentity.isOwnContainer(item.id)) {
|
||||
return { id: item.id, target: 'containers', status: 'skipped', reason: 'Sencho self container' };
|
||||
}
|
||||
let inspected: {
|
||||
State?: { Status?: string };
|
||||
Config?: { Labels?: Record<string, string> };
|
||||
Image?: string;
|
||||
};
|
||||
try {
|
||||
inspected = await this.docker.getContainer(item.id).inspect();
|
||||
} catch {
|
||||
return { id: item.id, target: 'containers', status: 'skipped', reason: 'Container no longer exists' };
|
||||
}
|
||||
const imageId = inspected.Image;
|
||||
const state = String(inspected.State?.Status ?? '').toLowerCase();
|
||||
if (!PRUNEABLE_CONTAINER_STATES.has(state)) {
|
||||
return {
|
||||
id: item.id,
|
||||
target: 'containers',
|
||||
status: 'skipped',
|
||||
reason: `Container state is ${state || 'unknown'}`,
|
||||
imageId,
|
||||
};
|
||||
}
|
||||
if (scope === 'managed') {
|
||||
const stack = DockerController.resolveContainerStack(
|
||||
inspected.Config?.Labels, projectToStack, knownSet, absDirToStack, resolvedBase,
|
||||
);
|
||||
if (!stack) {
|
||||
return {
|
||||
id: item.id,
|
||||
target: 'containers',
|
||||
status: 'skipped',
|
||||
reason: 'No longer a managed stack container',
|
||||
imageId,
|
||||
};
|
||||
}
|
||||
}
|
||||
await this.docker.getContainer(item.id).remove({ force: false });
|
||||
return { id: item.id, target: 'containers', status: 'removed', sizeBytes: item.sizeBytes, imageId };
|
||||
}
|
||||
|
||||
private async executePlannedVolume(
|
||||
item: PrunePlanItem,
|
||||
scope: PruneScope,
|
||||
knownSet: Set<string>,
|
||||
projectToStack: Record<string, string>,
|
||||
selfIdentity: SelfIdentityService,
|
||||
): Promise<PruneItemOutcome> {
|
||||
if (selfIdentity.isOwnVolume(item.id)) {
|
||||
return { id: item.id, target: 'volumes', status: 'skipped', reason: 'Sencho self volume' };
|
||||
}
|
||||
const rawVolumeData = await this.docker.listVolumes();
|
||||
const rawVolumes = (this.validateApiData<{ Volumes?: Array<{
|
||||
Name: string;
|
||||
Labels?: Record<string, string>;
|
||||
}> }>(rawVolumeData)).Volumes || [];
|
||||
const vol = rawVolumes.find((v) => v.Name === item.id);
|
||||
if (!vol) {
|
||||
return { id: item.id, target: 'volumes', status: 'skipped', reason: 'Volume no longer exists' };
|
||||
}
|
||||
const usage = DockerController.mapVolumeUsageFromDf(await this.safeDfSnapshot()).get(item.id);
|
||||
if (!usage || usage.refCount !== 0) {
|
||||
return { id: item.id, target: 'volumes', status: 'skipped', reason: 'Volume is in use' };
|
||||
}
|
||||
if (scope === 'managed') {
|
||||
const stack = DockerController.resolveProjectLabel(
|
||||
vol.Labels?.['com.docker.compose.project'], knownSet, projectToStack,
|
||||
);
|
||||
if (!stack) {
|
||||
return { id: item.id, target: 'volumes', status: 'skipped', reason: 'No longer a managed volume' };
|
||||
}
|
||||
}
|
||||
await this.docker.getVolume(item.id).remove({ force: false });
|
||||
return { id: item.id, target: 'volumes', status: 'removed', sizeBytes: item.sizeBytes ?? usage.size };
|
||||
}
|
||||
|
||||
private async executePlannedNetwork(
|
||||
item: PrunePlanItem,
|
||||
scope: PruneScope,
|
||||
knownSet: Set<string>,
|
||||
projectToStack: Record<string, string>,
|
||||
selfIdentity: SelfIdentityService,
|
||||
): Promise<PruneItemOutcome> {
|
||||
if (selfIdentity.isOwnNetwork(item.id)) {
|
||||
return { id: item.id, target: 'networks', status: 'skipped', reason: 'Sencho self network' };
|
||||
}
|
||||
let inspected: {
|
||||
Name?: string;
|
||||
Labels?: Record<string, string>;
|
||||
Containers?: Record<string, unknown>;
|
||||
};
|
||||
try {
|
||||
inspected = await this.docker.getNetwork(item.id).inspect();
|
||||
} catch {
|
||||
return { id: item.id, target: 'networks', status: 'skipped', reason: 'Network no longer exists' };
|
||||
}
|
||||
if (DockerController.SYSTEM_NETWORKS.has(inspected.Name ?? '')) {
|
||||
return { id: item.id, target: 'networks', status: 'skipped', reason: 'System network' };
|
||||
}
|
||||
if (inspected.Containers && Object.keys(inspected.Containers).length > 0) {
|
||||
return { id: item.id, target: 'networks', status: 'skipped', reason: 'Network is in use' };
|
||||
}
|
||||
if (scope === 'managed') {
|
||||
const stack = DockerController.resolveProjectLabel(
|
||||
inspected.Labels?.['com.docker.compose.project'], knownSet, projectToStack,
|
||||
);
|
||||
if (!stack) {
|
||||
return { id: item.id, target: 'networks', status: 'skipped', reason: 'No longer a managed network' };
|
||||
}
|
||||
}
|
||||
await this.docker.getNetwork(item.id).remove();
|
||||
return { id: item.id, target: 'networks', status: 'removed' };
|
||||
}
|
||||
|
||||
private async executePlannedImage(
|
||||
item: PrunePlanItem,
|
||||
scope: PruneScope,
|
||||
knownSet: Set<string>,
|
||||
projectToStack: Record<string, string>,
|
||||
absDirToStack: Record<string, string>,
|
||||
resolvedBase: string,
|
||||
selfIdentity: SelfIdentityService,
|
||||
): Promise<PruneItemOutcome> {
|
||||
if (selfIdentity.isOwnImage(item.id)) {
|
||||
return { id: item.id, target: 'images', status: 'skipped', reason: 'Sencho self image' };
|
||||
}
|
||||
const rawImages = await this.docker.listImages({ all: false }) as Array<{
|
||||
Id: string;
|
||||
Size?: number;
|
||||
Containers?: number;
|
||||
}>;
|
||||
const img = rawImages.find((i) => i.Id === item.id || i.Id.startsWith(item.id) || item.id.startsWith(i.Id));
|
||||
if (!img) {
|
||||
return { id: item.id, target: 'images', status: 'skipped', reason: 'Image no longer exists' };
|
||||
}
|
||||
if ((img.Containers ?? 0) > 0) {
|
||||
return { id: item.id, target: 'images', status: 'skipped', reason: 'Image still has container references' };
|
||||
}
|
||||
if (scope === 'managed') {
|
||||
const allContainers = await this.docker.listContainers({ all: true }) as Array<{
|
||||
ImageID?: string;
|
||||
Labels?: Record<string, string>;
|
||||
}>;
|
||||
for (const c of allContainers) {
|
||||
if (c.ImageID !== img.Id) continue;
|
||||
const stack = DockerController.resolveContainerStack(
|
||||
c.Labels, projectToStack, knownSet, absDirToStack, resolvedBase,
|
||||
);
|
||||
if (!stack) {
|
||||
return { id: item.id, target: 'images', status: 'skipped', reason: 'Image referenced by unmanaged container' };
|
||||
}
|
||||
}
|
||||
}
|
||||
await this.docker.getImage(item.id).remove({ force: false });
|
||||
// Prefer plan unique-bytes; do not fall back to full Size (shared layers).
|
||||
return { id: item.id, target: 'images', status: 'removed', sizeBytes: item.sizeBytes ?? 0 };
|
||||
}
|
||||
|
||||
public async getDiskUsageClassified(knownStackNames: string[]): Promise<{
|
||||
reclaimableImages: number;
|
||||
reclaimableContainers: number;
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
export type PruneTarget = 'images' | 'volumes' | 'networks' | 'containers';
|
||||
export type PruneScope = 'managed' | 'all';
|
||||
|
||||
export interface PrunePlanItem {
|
||||
target: PruneTarget;
|
||||
id: string;
|
||||
name: string;
|
||||
sizeBytes?: number;
|
||||
}
|
||||
|
||||
export interface PrunePlan {
|
||||
scope: PruneScope;
|
||||
/** Ordered execution sequence (dependency-safe when multi-target). */
|
||||
targets: PruneTarget[];
|
||||
items: PrunePlanItem[];
|
||||
reclaimableBytes: number;
|
||||
/** sha256 of sorted `target:id` pairs + scope + targets + nodeId. */
|
||||
fingerprint: string;
|
||||
createdAt: number;
|
||||
nodeId: number;
|
||||
}
|
||||
|
||||
export type PruneItemOutcome =
|
||||
| { id: string; target: PruneTarget; status: 'removed'; sizeBytes?: number }
|
||||
| { id: string; target: PruneTarget; status: 'skipped'; reason: string }
|
||||
| { id: string; target: PruneTarget; status: 'failed'; error: string };
|
||||
|
||||
export const PRUNE_TARGETS: readonly PruneTarget[] = ['images', 'volumes', 'networks', 'containers'];
|
||||
|
||||
/** Safe multi-target order: volumes while containers still hold refs, then containers, then images. */
|
||||
export const PRUNE_EXECUTION_ORDER: readonly PruneTarget[] = ['volumes', 'containers', 'images', 'networks'];
|
||||
|
||||
export const PRUNEABLE_CONTAINER_STATES = new Set(['created', 'exited', 'dead']);
|
||||
|
||||
export function isPruneTarget(value: unknown): value is PruneTarget {
|
||||
return typeof value === 'string' && (PRUNE_TARGETS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-target plans keep caller order. Multi-target plans normalize to the
|
||||
* dependency-safe sequence so reclaim never deletes a volume still held by a
|
||||
* planned container, and images become free after planned container removals.
|
||||
*/
|
||||
export function normalizePruneTargets(targets: PruneTarget[]): PruneTarget[] {
|
||||
const unique = [...new Set(targets)];
|
||||
if (unique.length <= 1) return unique;
|
||||
const rank = new Map(PRUNE_EXECUTION_ORDER.map((t, i) => [t, i]));
|
||||
return unique.sort((a, b) => (rank.get(a) ?? 99) - (rank.get(b) ?? 99));
|
||||
}
|
||||
|
||||
export function fingerprintPrunePlan(
|
||||
nodeId: number,
|
||||
scope: PruneScope,
|
||||
targets: PruneTarget[],
|
||||
items: Pick<PrunePlanItem, 'target' | 'id'>[],
|
||||
): string {
|
||||
const lines = items
|
||||
.map((item) => `${item.target}:${item.id}`)
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
const canonical = `${nodeId}|${scope}|${targets.join(',')}|${lines.join('\n')}`;
|
||||
return createHash('sha256').update(canonical).digest('hex');
|
||||
}
|
||||
|
||||
export class PrunePlanStaleError extends Error {
|
||||
readonly code = 'PRUNE_PLAN_STALE' as const;
|
||||
|
||||
constructor(message = 'Prune plan is stale; refresh and confirm again') {
|
||||
super(message);
|
||||
this.name = 'PrunePlanStaleError';
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ export const AUDIT_ROUTE_SUMMARIES: Record<string, string> = {
|
||||
// System operations
|
||||
'POST /system/prune': 'Pruned system resources',
|
||||
'POST /system/prune/orphans': 'Pruned orphan containers',
|
||||
'POST /system/prune/plan': 'Built prune plan',
|
||||
'POST /system/prune/system': 'Pruned system resources',
|
||||
'POST /system/images/delete': 'Deleted images',
|
||||
'POST /system/volumes/delete': 'Deleted volumes',
|
||||
@@ -158,8 +159,19 @@ const SORTED_PATTERNS = Object.entries(AUDIT_ROUTE_SUMMARIES)
|
||||
* falls back to prefix matching. Returns a generic method+path string
|
||||
* if no pattern matches.
|
||||
*/
|
||||
export function getAuditSummary(method: string, apiPath: string): string {
|
||||
export function getAuditSummary(method: string, apiPath: string, statusCode?: number): string {
|
||||
const normalized = apiPath.replace(/^\//, '');
|
||||
// Do not claim a successful prune when the request was rejected or blocked.
|
||||
if (typeof statusCode === 'number' && statusCode >= 400) {
|
||||
if (method === 'POST' && normalized.startsWith('system/prune/system')) {
|
||||
if (statusCode === 409) return 'Prune blocked: plan stale';
|
||||
if (statusCode === 400) return 'Prune request rejected';
|
||||
return `Prune failed (${statusCode})`;
|
||||
}
|
||||
if (method === 'POST' && normalized.startsWith('system/prune/plan')) {
|
||||
return `Prune plan failed (${statusCode})`;
|
||||
}
|
||||
}
|
||||
const normalizedSegments = normalized.split('/');
|
||||
|
||||
for (const [pattern, summary] of SORTED_PATTERNS) {
|
||||
|
||||
Reference in New Issue
Block a user