diff --git a/backend/src/__tests__/audit-log.test.ts b/backend/src/__tests__/audit-log.test.ts index 84921872..2ecfa57b 100644 --- a/backend/src/__tests__/audit-log.test.ts +++ b/backend/src/__tests__/audit-log.test.ts @@ -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 ---- diff --git a/backend/src/__tests__/docker-controller.test.ts b/backend/src/__tests__/docker-controller.test.ts index bfa8d3ca..62086ece 100644 --- a/backend/src/__tests__/docker-controller.test.ts +++ b/backend/src/__tests__/docker-controller.test.ts @@ -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([]); diff --git a/backend/src/__tests__/prune-plan.test.ts b/backend/src/__tests__/prune-plan.test.ts new file mode 100644 index 00000000..2f722f84 --- /dev/null +++ b/backend/src/__tests__/prune-plan.test.ts @@ -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 }); + }); +}); diff --git a/backend/src/__tests__/system-maintenance-prune.test.ts b/backend/src/__tests__/system-maintenance-prune.test.ts index 41ab77d3..d7d5e021 100644 --- a/backend/src/__tests__/system-maintenance-prune.test.ts +++ b/backend/src/__tests__/system-maintenance-prune.test.ts @@ -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); } +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); 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); + + 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); + + 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); + + 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); + + 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(); + }); +}); diff --git a/backend/src/middleware/authGate.ts b/backend/src/middleware/authGate.ts index b581ed94..a52fc965 100644 --- a/backend/src/middleware/authGate.ts +++ b/backend/src/middleware/authGate.ts @@ -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); diff --git a/backend/src/routes/systemMaintenance.ts b/backend/src/routes/systemMaintenance.ts index 137fd816..f4385560 100644 --- a/backend/src/routes/systemMaintenance.ts +++ b/backend/src/routes/systemMaintenance.ts @@ -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' }); } diff --git a/backend/src/services/DockerController.ts b/backend/src/services/DockerController.ts index 4c9ac26e..816869e7 100644 --- a/backend/src/services/DockerController.ts +++ b/backend/src/services/DockerController.ts @@ -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 { + const m = new Map(); + 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, + ): 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(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(); + const managedImageIds = new Set(); 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(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(); + const managedImageIds = new Set(); 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 { + 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; + SizeRw?: number; + NetworkSettings?: { Networks?: Record }; + }>; + + 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; + }> }>(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; + }>; + const networksInUse = new Set(); + 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; + }; + 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(); + const managedImageIds = new Set(); + const imageToContainerIds = new Map(); + 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; + 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] !== ':' + ? 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 { + 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(); + + 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 { + 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 { + 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, + projectToStack: Record, + absDirToStack: Record, + 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 }; + 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, + projectToStack: Record, + selfIdentity: SelfIdentityService, + ): Promise { + 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; + }> }>(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, + projectToStack: Record, + selfIdentity: SelfIdentityService, + ): Promise { + if (selfIdentity.isOwnNetwork(item.id)) { + return { id: item.id, target: 'networks', status: 'skipped', reason: 'Sencho self network' }; + } + let inspected: { + Name?: string; + Labels?: Record; + Containers?: Record; + }; + 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, + projectToStack: Record, + absDirToStack: Record, + resolvedBase: string, + selfIdentity: SelfIdentityService, + ): Promise { + 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; + }>; + 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; diff --git a/backend/src/services/prunePlan.ts b/backend/src/services/prunePlan.ts new file mode 100644 index 00000000..a3612884 --- /dev/null +++ b/backend/src/services/prunePlan.ts @@ -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[], +): 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'; + } +} diff --git a/backend/src/utils/audit-summaries.ts b/backend/src/utils/audit-summaries.ts index eebf44a0..2e9ef73a 100644 --- a/backend/src/utils/audit-summaries.ts +++ b/backend/src/utils/audit-summaries.ts @@ -35,6 +35,7 @@ export const AUDIT_ROUTE_SUMMARIES: Record = { // 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) { diff --git a/docs/features/resources.mdx b/docs/features/resources.mdx index 7ba40437..580d8bf0 100644 --- a/docs/features/resources.mdx +++ b/docs/features/resources.mdx @@ -11,7 +11,7 @@ The **Resources** tab shows everything Docker is storing on your host, broken do ## Reclaim hero -When there is reclaimable disk space (unused images, stopped containers, or dangling volumes), an amber banner leads the view with the total amount you can free and a `·`-separated breakdown of what contributes to it (for example, `16 unused images · 10 dangling volumes`). Click **Review & prune** to open a single confirmation dialog scoped to every reclaimable resource at once. +When there is reclaimable disk space (unused images, stopped containers, or dangling volumes), an amber banner leads the view with the total amount you can free and a `·`-separated breakdown of what contributes to it (for example, `16 unused images · 10 dangling volumes`). Click **Review & prune** to open a confirmation dialog that lists the exact items that will be removed (capped in the preview with an "and N more" note when the list is long). Confirm only after the plan is ready; Sencho rechecks the list at execute time and skips anything that is no longer eligible. The hero stays hidden when there is nothing to reclaim, keeping the view focused on the rest of your inventory. @@ -48,7 +48,7 @@ The right card under the hero exposes four prune actions. Each tile has a single The first three tiles also show a **More options** menu with a single destructive entry, **All Docker (includes external)**, that broadens the prune to every Docker resource on the host. Use it carefully, since it can affect other Compose projects sharing the same daemon. The **Purge Unmanaged Containers** tile has no menu and always targets unmanaged containers only. -A confirmation dialog appears before any destructive operation, with a summary of what will be removed. While the operation runs, a loading notification keeps you informed; on completion it is replaced with a success or error notification, including how much space was reclaimed. +A confirmation dialog appears before any destructive prune. Sencho first builds an itemized plan of exactly which resources will be removed, shows that list in the dialog, and only then enables confirm. The confirm action is bound to that plan: if the daemon state changes before you confirm, Sencho refreshes the plan instead of deleting a different set. While the operation runs, a loading notification keeps you informed; on completion it is replaced with a success or error notification, including how much space was reclaimed. Quick Clean is admin-only. The panel is hidden for users without admin permissions. diff --git a/frontend/src/components/ResourcesView.tsx b/frontend/src/components/ResourcesView.tsx index 5d2d175b..473518e4 100644 --- a/frontend/src/components/ResourcesView.tsx +++ b/frontend/src/components/ResourcesView.tsx @@ -103,6 +103,69 @@ type ResourceFilter = 'all' | 'managed' | 'unmanaged'; type PruneTarget = 'containers' | 'images' | 'networks' | 'volumes'; type PruneScope = 'managed' | 'all'; +interface PrunePlanItem { + target: PruneTarget; + id: string; + name: string; + sizeBytes?: number; +} + +interface PrunePlan { + scope: PruneScope; + targets: PruneTarget[]; + items: PrunePlanItem[]; + reclaimableBytes: number; + fingerprint: string; + createdAt: number; + nodeId: number; +} + +const PLAN_PREVIEW_CAP = 30; + +function PrunePlanPreview({ + plan, + loading, + error, +}: { + plan: PrunePlan | null; + loading: boolean; + error: string | null; +}) { + if (loading) { + return

Building prune plan...

; + } + if (error) { + return

{error}

; + } + if (!plan) return null; + if (plan.items.length === 0) { + return

Nothing eligible to prune right now.

; + } + const shown = plan.items.slice(0, PLAN_PREVIEW_CAP); + const remaining = plan.items.length - shown.length; + return ( +
+

+ {plan.items.length} {plan.items.length === 1 ? 'item' : 'items'} + {plan.reclaimableBytes > 0 ? ` · ${formatBytes(plan.reclaimableBytes)}` : ''} +

+
    + {shown.map((item) => ( +
  • + {item.target} + {' · '} + {item.name} + {item.sizeBytes != null && item.sizeBytes > 0 ? ` · ${formatBytes(item.sizeBytes)}` : ''} +
  • + ))} +
+ {remaining > 0 && ( +

and {remaining} more

+ )} +
+ ); +} + // Per-node, per-browser snooze for the reclaim banner. We store the reclaimable // byte total at the moment of dismissal; the banner returns only once the node's // reclaimable total grows past that snapshot, so a stable residue stays hidden. @@ -352,6 +415,10 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {} const [confirmPrune, setConfirmPrune] = useState<{ target: PruneTarget; scope: PruneScope } | null>(null); const [confirmDelete, setConfirmDelete] = useState<{ type: 'images' | 'volumes' | 'networks'; id: string; name?: string } | null>(null); const [confirmReclaim, setConfirmReclaim] = useState(false); + const [prunePlan, setPrunePlan] = useState(null); + const [planLoading, setPlanLoading] = useState(false); + const [planError, setPlanError] = useState(null); + const planFetchGenRef = useRef(0); // Reclaim banner visibility: the per-node opt-out setting (loaded in // fetchAllData) and the per-browser dismiss snapshot for the active node. @@ -447,34 +514,107 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {} // view is gone cannot run state setters or surface a load-error toast. useEffect(() => () => { fetchGenerationRef.current += 1; }, []); - const handlePrune = async () => { + type PrunePlanRequest = { target?: PruneTarget; targets?: PruneTarget[]; scope: PruneScope }; + + const fetchPrunePlan = async (body: PrunePlanRequest) => { + const generation = ++planFetchGenRef.current; + setPlanLoading(true); + setPlanError(null); + setPrunePlan(null); + try { + const res = await apiFetch('/system/prune/plan', { + method: 'POST', + body: JSON.stringify(body), + }); + const data = await res.json().catch(() => null); + if (planFetchGenRef.current !== generation) return null; + if (!res.ok) { + throw new Error(data?.error || 'Failed to build prune plan'); + } + setPrunePlan(data as PrunePlan); + return data as PrunePlan; + } catch (error) { + if (planFetchGenRef.current !== generation) return null; + const err = error as { message?: string }; + const message = err?.message || 'Failed to build prune plan'; + setPlanError(message); + console.error('Failed to build prune plan', error); + return null; + } finally { + if (planFetchGenRef.current === generation) setPlanLoading(false); + } + }; + + /** POST /prune/system with fingerprint. On stale 409, refresh the plan and + * require another confirm rather than executing a set the user never saw. */ + const executeFingerprintPrune = async (body: PrunePlanRequest, fingerprint: string) => { + const res = await apiFetch('/system/prune/system', { + method: 'POST', + body: JSON.stringify({ ...body, planFingerprint: fingerprint }), + }); + const data = await res.json().catch(() => null); + if (res.status === 409 && data?.code === 'PRUNE_PLAN_STALE') { + await fetchPrunePlan(body); + throw new Error('Prune plan changed; review the updated list and confirm again'); + } + return { res, data }; + }; + + // Fetch an itemized plan whenever a prune confirm dialog opens. + useEffect(() => { if (!confirmPrune) return; + void fetchPrunePlan({ target: confirmPrune.target, scope: confirmPrune.scope }); + }, [confirmPrune]); + + useEffect(() => { + if (!confirmReclaim) return; + void fetchPrunePlan({ targets: ['volumes', 'containers', 'images'], scope: 'all' }); + }, [confirmReclaim]); + + useEffect(() => { + if (confirmPrune || confirmReclaim) return; + planFetchGenRef.current += 1; + setPrunePlan(null); + setPlanLoading(false); + setPlanError(null); + }, [confirmPrune, confirmReclaim]); + + const handlePrune = async () => { + if (!confirmPrune || !prunePlan) return; setIsActioning(true); const loadingId = toast.loading(`Pruning ${confirmPrune.target}...`); try { - const res = await apiFetch('/system/prune/system', { - method: 'POST', - body: JSON.stringify({ target: confirmPrune.target, scope: confirmPrune.scope }) - }); - const data = await res.json().catch(() => null); + const { res, data } = await executeFingerprintPrune( + { target: confirmPrune.target, scope: confirmPrune.scope }, + prunePlan.fingerprint, + ); if (!res.ok) { throw new Error(data?.error || `Failed to prune ${confirmPrune.target}`); } const scopeLabel = confirmPrune.scope === 'managed' ? 'Sencho-managed' : 'all'; - toast.success( - data?.reclaimedBytes !== undefined - ? `Pruned ${scopeLabel} ${confirmPrune.target}. Reclaimed ${formatBytes(data.reclaimedBytes)}.` - : `Pruned ${scopeLabel} ${confirmPrune.target}.` - ); + const reclaimed = typeof data?.reclaimedBytes === 'number' ? data.reclaimedBytes : undefined; + const outcomes = Array.isArray(data?.outcomes) ? data.outcomes : []; + const failed = outcomes.filter((o: { status?: string }) => o.status === 'failed'); + const reclaimedLabel = reclaimed !== undefined + ? ` Reclaimed ${formatBytes(reclaimed)}.` + : ''; + if (failed.length > 0 && failed.length === outcomes.length) { + toast.error(`Failed to prune ${confirmPrune.target}.`); + } else if (failed.length > 0) { + toast.warning(`Some ${confirmPrune.target} could not be pruned.${reclaimedLabel}`); + } else { + toast.success(`Pruned ${scopeLabel} ${confirmPrune.target}.${reclaimedLabel}`); + } await fetchAllData(); + setConfirmPrune(null); } catch (error) { console.error('Failed to prune', error); const err = error as { message?: string }; toast.error(err?.message || `Failed to prune ${confirmPrune.target}`); + // Keep the dialog open on stale-plan so the operator can re-confirm. } finally { toast.dismiss(loadingId); setIsActioning(false); - setConfirmPrune(null); } }; @@ -684,39 +824,38 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {} // false success); the reclaimed figure is shown only when the daemon reports // one (the containerd image store returns 0). const handleReclaimAll = async () => { + if (!prunePlan) return; setIsActioning(true); const loadingId = toast.loading('Reclaiming disk space...'); - const order: PruneTarget[] = ['volumes', 'containers', 'images']; - let reclaimed = 0; - const failed: PruneTarget[] = []; try { - for (const target of order) { - try { - const res = await apiFetch('/system/prune/system', { - method: 'POST', - body: JSON.stringify({ target, scope: 'all' }), - }); - const data = await res.json().catch(() => null); - if (!res.ok) throw new Error(data?.error || `Failed to prune ${target}`); - if (typeof data?.reclaimedBytes === 'number') reclaimed += data.reclaimedBytes; - } catch (err) { - console.error(`Failed to prune ${target}`, err); - failed.push(target); - } + const { res, data } = await executeFingerprintPrune( + { targets: ['volumes', 'containers', 'images'], scope: 'all' }, + prunePlan.fingerprint, + ); + if (!res.ok) { + throw new Error(data?.error || 'Failed to reclaim disk space'); } + const reclaimed = typeof data?.reclaimedBytes === 'number' ? data.reclaimedBytes : 0; const reclaimedLabel = reclaimed > 0 ? ` Freed ${formatBytes(reclaimed)}.` : ''; - if (failed.length === order.length) { + const outcomes = Array.isArray(data?.outcomes) ? data.outcomes : []; + const failed = outcomes.filter((o: { status?: string }) => o.status === 'failed'); + if (failed.length > 0 && failed.length === outcomes.length) { toast.error('Failed to reclaim disk space.'); } else if (failed.length > 0) { - toast.warning(`Could not prune: ${failed.join(', ')}.${reclaimedLabel}`); + toast.warning(`Some items could not be pruned.${reclaimedLabel}`); } else { toast.success(`Reclaimed unused images, stopped containers, and dangling volumes.${reclaimedLabel}`); } await fetchAllData(); + setConfirmReclaim(false); + } catch (error) { + console.error('Failed to reclaim', error); + const err = error as { message?: string }; + toast.error(err?.message || 'Failed to reclaim disk space.'); + // Keep the dialog open on stale-plan so the operator can re-confirm. } finally { toast.dismiss(loadingId); setIsActioning(false); - setConfirmReclaim(false); } }; @@ -1398,23 +1537,35 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {} : `Prune Sencho-managed ${confirmPrune?.target}` } hint={confirmPrune?.scope === 'all' ? 'AFFECTS external Docker resources' : 'KEEPS external resources'} - confirmLabel={isActioning ? 'Pruning...' : (confirmPrune?.scope === 'all' ? 'Prune all' : 'Prune')} + confirmLabel={ + isActioning + ? 'Pruning...' + : planLoading + ? 'Preparing...' + : prunePlan && prunePlan.reclaimableBytes > 0 + ? `Prune · ${formatBytes(prunePlan.reclaimableBytes)}` + : (confirmPrune?.scope === 'all' ? 'Prune all' : 'Prune') + } confirming={isActioning} + confirmDisabled={planLoading || !prunePlan || !!planError || (prunePlan?.items.length ?? 0) === 0} onConfirm={handlePrune} > -

- {confirmPrune?.scope === 'all' ? ( - <> - Prunes all unused {confirmPrune?.target} from the Docker daemon, including those from{' '} - external projects not managed by Sencho. - - ) : ( - <> - Removes only unused {confirmPrune?.target} belonging to your Sencho stacks. External Docker resources are{' '} - not affected. - - )} -

+
+

+ {confirmPrune?.scope === 'all' ? ( + <> + Prunes all unused {confirmPrune?.target} from the Docker daemon, including those from{' '} + external projects not managed by Sencho. + + ) : ( + <> + Removes only unused {confirmPrune?.target} belonging to your Sencho stacks. External Docker resources are{' '} + not affected. + + )} +

+ +
{/* Reclaim Confirm (banner "Review & prune") */} @@ -1425,8 +1576,15 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {} kicker="RESOURCES · PRUNE · IRREVERSIBLE" title="Reclaim disk space" hint="AFFECTS external Docker resources" - confirmLabel={isActioning ? 'Reclaiming...' : `Reclaim ${formatBytes(totalReclaimableBytes)}`} + confirmLabel={ + isActioning + ? 'Reclaiming...' + : planLoading + ? 'Preparing...' + : `Reclaim ${formatBytes(prunePlan?.reclaimableBytes ?? totalReclaimableBytes)}` + } confirming={isActioning} + confirmDisabled={planLoading || !prunePlan || !!planError || (prunePlan?.items.length ?? 0) === 0} onConfirm={handleReclaimAll} >
@@ -1434,19 +1592,7 @@ export default function ResourcesView({ headerActions }: ResourcesViewProps = {} Removes every unused image, stopped container, and dangling volume on this node, including those from{' '} external projects not managed by Sencho.

- {usage && ( -
    - {usage.reclaimableImageCount > 0 && ( -
  • {usage.reclaimableImageCount} {usage.reclaimableImageCount === 1 ? 'unused image' : 'unused images'} · {formatBytes(usage.reclaimableImages)}
  • - )} - {usage.reclaimableContainerCount > 0 && ( -
  • {usage.reclaimableContainerCount} {usage.reclaimableContainerCount === 1 ? 'stopped container' : 'stopped containers'} · {formatBytes(usage.reclaimableContainers)}
  • - )} - {usage.reclaimableVolumeCount > 0 && ( -
  • {usage.reclaimableVolumeCount} {usage.reclaimableVolumeCount === 1 ? 'dangling volume' : 'dangling volumes'} · {formatBytes(usage.reclaimableVolumes)}
  • - )} -
- )} +
diff --git a/frontend/src/components/__tests__/ResourcesView.test.tsx b/frontend/src/components/__tests__/ResourcesView.test.tsx index 51706a11..8bdb7016 100644 --- a/frontend/src/components/__tests__/ResourcesView.test.tsx +++ b/frontend/src/components/__tests__/ResourcesView.test.tsx @@ -115,6 +115,33 @@ function reclaimableUsage(images: number, volumes: number) { }; } + +function samplePrunePlan(overrides: Record = {}) { + return { + scope: 'managed', + targets: ['images'], + items: [{ target: 'images', id: 'img1', name: 'old:v1', sizeBytes: 1000 }], + reclaimableBytes: 1000, + fingerprint: 'fp-test', + createdAt: Date.now(), + nodeId: 1, + ...overrides, + }; +} + +function reclaimPlan() { + return samplePrunePlan({ + scope: 'all', + targets: ['volumes', 'containers', 'images'], + items: [ + { target: 'volumes', id: 'v1', name: 'v1', sizeBytes: 500 }, + { target: 'images', id: 'img1', name: 'old:v1', sizeBytes: 1000 }, + ], + reclaimableBytes: 1500, + fingerprint: 'fp-reclaim', + }); +} + afterEach(() => vi.clearAllMocks()); describe('ResourcesView', () => { @@ -170,8 +197,46 @@ describe('ResourcesView', () => { expect(toast.error).not.toHaveBeenCalled(); }); + it('fetches a prune plan before enabling confirm, then sends the fingerprint', async () => { + mockedFetch.mockImplementation((url: string, opts?: RequestInit) => { + if (url === '/system/prune/plan' && opts?.method === 'POST') { + return Promise.resolve(jsonResponse(samplePrunePlan())); + } + if (url === '/system/prune/system' && opts?.method === 'POST') { + return Promise.resolve(jsonResponse({ success: true, reclaimedBytes: 1000, outcomes: [] })); + } + if (url === '/system/resources') { + return Promise.resolve(jsonResponse({ images: [], volumes: [], networks: [] })); + } + return Promise.resolve(jsonResponse({})); + }); + + const user = userEvent.setup(); + render(); + await waitFor(() => expect(mockedFetch).toHaveBeenCalledWith('/system/resources')); + + await user.click(screen.getByRole('button', { name: /Prune Unused Images/ })); + await waitFor(() => expect(mockedFetch).toHaveBeenCalledWith( + '/system/prune/plan', + expect.objectContaining({ method: 'POST' }), + )); + expect(await screen.findByText(/old:v1/)).toBeInTheDocument(); + + await user.click(await screen.findByRole('button', { name: /Prune/ })); + await waitFor(() => expect(toast.success).toHaveBeenCalled()); + const pruneCall = mockedFetch.mock.calls.find( + ([u, o]) => u === '/system/prune/system' && (o as RequestInit)?.method === 'POST', + ); + expect(pruneCall).toBeTruthy(); + const body = JSON.parse(String((pruneCall![1] as RequestInit).body)); + expect(body.planFingerprint).toBe('fp-test'); + }); + it('surfaces the server error on a failed prune instead of a false success (M-2)', async () => { mockedFetch.mockImplementation((url: string, opts?: RequestInit) => { + if (url === '/system/prune/plan' && opts?.method === 'POST') { + return Promise.resolve(jsonResponse(samplePrunePlan())); + } if (url === '/system/prune/system' && opts?.method === 'POST') { return Promise.resolve(jsonResponse({ error: 'Prune blew up' }, { ok: false, status: 500 })); } @@ -186,7 +251,8 @@ describe('ResourcesView', () => { await waitFor(() => expect(mockedFetch).toHaveBeenCalledWith('/system/resources')); await user.click(screen.getByRole('button', { name: /Prune Unused Images/ })); - await user.click(await screen.findByRole('button', { name: /^Prune$/ })); + await waitFor(() => expect(screen.getByText(/old:v1/)).toBeInTheDocument()); + await user.click(await screen.findByRole('button', { name: /Prune/ })); await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Prune blew up')); expect(toast.success).not.toHaveBeenCalled(); @@ -194,12 +260,11 @@ describe('ResourcesView', () => { it('reports partial failure from "Review & prune" without a false success', async () => { mockedFetch.mockImplementation((url: string, opts?: RequestInit) => { + if (url === '/system/prune/plan' && opts?.method === 'POST') { + return Promise.resolve(jsonResponse(reclaimPlan())); + } if (url === '/system/prune/system' && opts?.method === 'POST') { - const target = (JSON.parse(String(opts.body)) as { target: string }).target; - if (target === 'volumes') { - return Promise.resolve(jsonResponse({ error: 'volume prune failed' }, { ok: false, status: 500 })); - } - return Promise.resolve(jsonResponse({ reclaimedBytes: 100 })); + return Promise.resolve(jsonResponse({ error: 'volume prune failed' }, { ok: false, status: 500 })); } if (url === '/system/docker-df') return Promise.resolve(jsonResponse(reclaimableUsage(1000, 500))); if (url === '/system/resources') return Promise.resolve(jsonResponse({ images: [], volumes: [], networks: [] })); @@ -210,22 +275,24 @@ describe('ResourcesView', () => { render(); await user.click(await screen.findByRole('button', { name: /Review & prune/ })); + await waitFor(() => expect(screen.getByText(/2 items/)).toBeInTheDocument()); await user.click(await screen.findByRole('button', { name: /^Reclaim/ })); - await waitFor(() => expect(toast.warning).toHaveBeenCalled()); - const warningMsg = (toast.warning as ReturnType).mock.calls[0][0] as string; - expect(warningMsg).toMatch(/volumes/); + await waitFor(() => expect(toast.error).toHaveBeenCalled()); expect(toast.success).not.toHaveBeenCalled(); - // Volumes are pruned first (while stopped containers still protect their - // named volumes), then containers, then images. const pruned = mockedFetch.mock.calls - .filter(([u, o]) => u === '/system/prune/system' && (o as RequestInit)?.method === 'POST') - .map(([, o]) => (JSON.parse(String((o as RequestInit).body)) as { target: string }).target); - expect(pruned).toEqual(['volumes', 'containers', 'images']); + .filter(([u, o]) => u === '/system/prune/system' && (o as RequestInit)?.method === 'POST'); + expect(pruned).toHaveLength(1); + const body = JSON.parse(String((pruned[0][1] as RequestInit).body)); + expect(body.targets).toEqual(['volumes', 'containers', 'images']); + expect(body.planFingerprint).toBe('fp-reclaim'); }); it('reports an error when every prune fails, with no success or warning', async () => { mockedFetch.mockImplementation((url: string, opts?: RequestInit) => { + if (url === '/system/prune/plan' && opts?.method === 'POST') { + return Promise.resolve(jsonResponse(reclaimPlan())); + } if (url === '/system/prune/system' && opts?.method === 'POST') { return Promise.resolve(jsonResponse({ error: 'daemon down' }, { ok: false, status: 500 })); } @@ -237,17 +304,21 @@ describe('ResourcesView', () => { const user = userEvent.setup(); render(); await user.click(await screen.findByRole('button', { name: /Review & prune/ })); + await waitFor(() => expect(screen.getByText(/2 items/)).toBeInTheDocument()); await user.click(await screen.findByRole('button', { name: /^Reclaim/ })); - await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Failed to reclaim disk space.')); + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('daemon down')); expect(toast.success).not.toHaveBeenCalled(); expect(toast.warning).not.toHaveBeenCalled(); }); it('omits the reclaimed figure on full success when the daemon reports zero bytes', async () => { mockedFetch.mockImplementation((url: string, opts?: RequestInit) => { + if (url === '/system/prune/plan' && opts?.method === 'POST') { + return Promise.resolve(jsonResponse(reclaimPlan())); + } if (url === '/system/prune/system' && opts?.method === 'POST') { - return Promise.resolve(jsonResponse({ reclaimedBytes: 0 })); + return Promise.resolve(jsonResponse({ reclaimedBytes: 0, outcomes: [] })); } if (url === '/system/docker-df') return Promise.resolve(jsonResponse(reclaimableUsage(1000, 500))); if (url === '/system/resources') return Promise.resolve(jsonResponse({ images: [], volumes: [], networks: [] })); @@ -257,6 +328,7 @@ describe('ResourcesView', () => { const user = userEvent.setup(); render(); await user.click(await screen.findByRole('button', { name: /Review & prune/ })); + await waitFor(() => expect(screen.getByText(/2 items/)).toBeInTheDocument()); await user.click(await screen.findByRole('button', { name: /^Reclaim/ })); await waitFor(() => expect(toast.success).toHaveBeenCalled()); @@ -268,8 +340,11 @@ describe('ResourcesView', () => { it('shows the reclaimed figure on full success when the daemon reports bytes', async () => { mockedFetch.mockImplementation((url: string, opts?: RequestInit) => { + if (url === '/system/prune/plan' && opts?.method === 'POST') { + return Promise.resolve(jsonResponse(reclaimPlan())); + } if (url === '/system/prune/system' && opts?.method === 'POST') { - return Promise.resolve(jsonResponse({ reclaimedBytes: 1048576 })); + return Promise.resolve(jsonResponse({ reclaimedBytes: 1048576, outcomes: [] })); } if (url === '/system/docker-df') return Promise.resolve(jsonResponse(reclaimableUsage(1000, 500))); if (url === '/system/resources') return Promise.resolve(jsonResponse({ images: [], volumes: [], networks: [] })); @@ -279,6 +354,7 @@ describe('ResourcesView', () => { const user = userEvent.setup(); render(); await user.click(await screen.findByRole('button', { name: /Review & prune/ })); + await waitFor(() => expect(screen.getByText(/2 items/)).toBeInTheDocument()); await user.click(await screen.findByRole('button', { name: /^Reclaim/ })); await waitFor(() => expect(toast.success).toHaveBeenCalled()); diff --git a/frontend/src/components/ui/modal.tsx b/frontend/src/components/ui/modal.tsx index fcbcf162..17116af8 100644 --- a/frontend/src/components/ui/modal.tsx +++ b/frontend/src/components/ui/modal.tsx @@ -208,6 +208,8 @@ interface ConfirmModalProps { confirmLabel: React.ReactNode; cancelLabel?: React.ReactNode; confirming?: boolean; + /** When true, the confirm action stays disabled (e.g. plan still loading). */ + confirmDisabled?: boolean; onConfirm: () => void | Promise; onCancel?: () => void; children?: React.ReactNode; @@ -225,6 +227,7 @@ export function ConfirmModal({ confirmLabel, cancelLabel = 'Cancel', confirming = false, + confirmDisabled = false, onConfirm, onCancel, children, @@ -235,6 +238,7 @@ export function ConfirmModal({ variant: variant === 'destructive' ? 'destructive' : 'default', size: 'sm', }); + const actionDisabled = confirming || confirmDisabled; return ( @@ -255,7 +259,7 @@ export function ConfirmModal({ primary={ { const result = onConfirm(); // Async confirms keep the dialog open so the caller can render