From 01ffbdbfc7f1b8886e6653300556b5a835f9b98f Mon Sep 17 00:00:00 2001 From: Anso Date: Thu, 20 Aug 2026 07:53:57 -0400 Subject: [PATCH] fix: untag reviewed image names during prune (#1836) --- .../fleet-action-card-endpoints.test.ts | 8 +- backend/src/__tests__/fleet-prune.test.ts | 28 ++ backend/src/__tests__/prune-plan.test.ts | 299 ++++++++++++++++-- .../system-maintenance-prune.test.ts | 78 +++++ backend/src/helpers/fleetPrune.ts | 2 +- backend/src/routes/systemMaintenance.ts | 13 +- backend/src/services/DockerController.ts | 197 ++++++++---- docs/features/resources.mdx | 7 + .../resources/resources-prune-confirm.png | Bin 0 -> 33890 bytes e2e/screenshots.spec.ts | 49 +++ frontend/src/components/ResourcesView.tsx | 29 +- .../__tests__/ResourcesView.test.tsx | 37 +++ 12 files changed, 650 insertions(+), 97 deletions(-) create mode 100644 docs/images/resources/resources-prune-confirm.png diff --git a/backend/src/__tests__/fleet-action-card-endpoints.test.ts b/backend/src/__tests__/fleet-action-card-endpoints.test.ts index 0f7ec1a0..26d2a016 100644 --- a/backend/src/__tests__/fleet-action-card-endpoints.test.ts +++ b/backend/src/__tests__/fleet-action-card-endpoints.test.ts @@ -1301,6 +1301,7 @@ describe('POST /api/fleet/labels/fleet-prune with dryRun: true', () => { executePrunePlan.mockResolvedValue({ success: true, reclaimedBytes: 0, + mutated: true, outcomes: [{ target: 'images', id: 'image', status: 'removed' }], }); const res = await request(app) @@ -1337,7 +1338,12 @@ describe('POST /api/fleet/labels/fleet-prune with dryRun: true', () => { nodeId: local.id, scope: 'managed', targets: ['images'], items: [...testCase.items], reclaimableBytes: 0, fingerprint, createdAt: 1, }); - executePrunePlan.mockResolvedValue({ success: true, reclaimedBytes: 0, outcomes: [...testCase.outcomes] }); + executePrunePlan.mockResolvedValue({ + success: true, + reclaimedBytes: 0, + mutated: false, + outcomes: [...testCase.outcomes], + }); invalidateNodeCaches.mockClear(); const res = await request(app) .post('/api/fleet/labels/fleet-prune') diff --git a/backend/src/__tests__/fleet-prune.test.ts b/backend/src/__tests__/fleet-prune.test.ts index 13a83f48..e1db5aed 100644 --- a/backend/src/__tests__/fleet-prune.test.ts +++ b/backend/src/__tests__/fleet-prune.test.ts @@ -3,6 +3,7 @@ import request from 'supertest'; import jwt from 'jsonwebtoken'; import { cleanupTestDb, setupTestDb, TEST_JWT_SECRET, TEST_USERNAME } from './helpers/setupTestDb'; import type { PruneItemOutcome, PrunePlan, PrunePlanItem } from '../services/prunePlan'; +import { CacheService } from '../services/CacheService'; let tmpDir: string; let app: import('express').Express; @@ -62,9 +63,11 @@ function mockLocal(planFactory: (nodeId: number) => PrunePlan = (nodeId) => plan success: boolean; reclaimedBytes: number; outcomes: PruneItemOutcome[]; + mutated: boolean; }> => ({ success: true, reclaimedBytes: reviewedPlan.reclaimableBytes, + mutated: reviewedPlan.items.length > 0, outcomes: reviewedPlan.items.map((entry) => ({ id: entry.id, target: entry.target, @@ -212,6 +215,30 @@ describe('POST /api/fleet/labels/fleet-prune', () => { expect(activeBulkActions.size).toBe(0); }); + it('invalidates local node caches when a failed image outcome is mutated', async () => { + const fake = mockLocal(); + fake.executePrunePlan.mockResolvedValue({ + success: false, + reclaimedBytes: 0, + mutated: true, + outcomes: [{ target: 'images', id: 'sha256:image', status: 'failed', error: 'tags remain' }], + }); + const invalidate = vi.spyOn(CacheService.getInstance(), 'invalidate'); + const local = DatabaseService.getInstance().getNodes().find((node) => node.type === 'local')!; + const response = await request(app) + .post('/api/fleet/labels/fleet-prune') + .set('Authorization', authHeader) + .send({ + targets: ['images'], scope: 'managed', dryRun: false, + reviewedNodes: [{ nodeId: local.id, reachable: true }], + plans: [{ nodeId: local.id, fingerprint: `fingerprint-${local.id}` }], + }); + expect(response.status).toBe(200); + expect(response.body.results[0].outcomes[0].status).toBe('failed'); + expect(invalidate).toHaveBeenCalledWith(`stats:${local.id}`); + expect(invalidate).toHaveBeenCalledWith(`stack-statuses:${local.id}`); + }); + it('fails closed when a local prune lock is active', async () => { const fake = mockLocal(); const { local, body } = localReview(`fingerprint-${DatabaseService.getInstance().getNodes()[0].id}`); @@ -440,6 +467,7 @@ describe('POST /api/fleet/labels/fleet-prune', () => { fake.executePrunePlan.mockResolvedValue({ success: false, reclaimedBytes: 100, + mutated: true, outcomes: [ { target: 'images', id: 'removed', status: 'removed', sizeBytes: 100 }, { target: 'images', id: 'skipped', status: 'skipped', reason: 'became active' }, diff --git a/backend/src/__tests__/prune-plan.test.ts b/backend/src/__tests__/prune-plan.test.ts index 54168fd3..517f766c 100644 --- a/backend/src/__tests__/prune-plan.test.ts +++ b/backend/src/__tests__/prune-plan.test.ts @@ -659,9 +659,77 @@ describe('DockerController.buildPrunePlan', () => { expect(plan.items.map((i) => i.id)).toEqual(['img-free']); }); + + it('excludes a fully synthetic sencho-rb hold image from the plan even without a DB hold', async () => { + mockDocker.listImages.mockResolvedValue([ + { + Id: 'img-orphan-hold', + RepoTags: ['sencho-rb/abc123456789/web:hold', 'sencho-rb/abc123456789/api:hold'], + Size: 50, + Containers: 0, + }, + { Id: 'img-free', RepoTags: ['app:2'], Size: 100, Containers: 0 }, + ]); + + const dc = DockerController.getInstance(1); + const plan = await dc.buildPrunePlan(['images'], 'all', [], 1, () => false); + + expect(plan.items.map((i) => i.id)).toEqual(['img-free']); + }); + + it('still plans a dual-tagged image that carries a registry tag and a sencho-rb hold tag', async () => { + mockDocker.listImages.mockResolvedValue([ + { + Id: 'img-dual', + RepoTags: ['myregistry/app:1.4', 'sencho-rb/abc123456789/app:hold'], + Size: 100, + Containers: 0, + }, + ]); + + const dc = DockerController.getInstance(1); + const plan = await dc.buildPrunePlan(['images'], 'all', [], 1); + + expect(plan.items.map((i) => i.id)).toEqual(['img-dual']); + expect(plan.items[0]?.image?.references).toEqual([ + 'myregistry/app:1.4', + 'sencho-rb/abc123456789/app:hold', + ]); + }); }); describe('DockerController.executePrunePlan', () => { + const multiTagImage = { + Id: 'img-multi', + RepoTags: ['qa1769/app:v0', 'qa1769-external/keep:v0'], + Size: 50, + Containers: 0, + }; + + function mockImageRemove( + onRemove?: (name: string, opts?: { force?: boolean }) => void | Promise, + ): Array<{ name: string; force?: boolean }> { + const removes: Array<{ name: string; force?: boolean }> = []; + mockDocker.getImage.mockImplementation((name: string) => ({ + remove: vi.fn().mockImplementation(async (opts?: { force?: boolean }) => { + removes.push({ name, force: opts?.force }); + await onRemove?.(name, opts); + }), + })); + return removes; + } + + async function executeForcedFreshImagePlan(listings: Array) { + for (const listing of listings) { + if (listing instanceof Error) mockDocker.listImages.mockRejectedValueOnce(listing); + else mockDocker.listImages.mockResolvedValueOnce(listing); + } + const dc = DockerController.getInstance(1); + const plan = await dc.buildPrunePlan(['images'], 'all', [], 1); + vi.spyOn(dc, 'assertPlanFresh').mockResolvedValue(plan); + return dc.executePrunePlan(plan, []); + } + it('throws PrunePlanStaleError when the fingerprint no longer matches', async () => { mockDocker.listContainers.mockResolvedValue([ { @@ -852,6 +920,49 @@ describe('DockerController.executePrunePlan', () => { ]); }); + it('skips a fully synthetic hold image even under a forced-fresh plan', async () => { + const holdImg = { + Id: 'img-orphan-hold', + RepoTags: ['sencho-rb/abc123456789/web:hold', 'sencho-rb/abc123456789/api:hold'], + Size: 50, + Containers: 0, + }; + mockDocker.listImages.mockResolvedValue([holdImg]); + const removes = mockImageRemove(); + + const dc = DockerController.getInstance(1); + const plan = { + scope: 'all' as const, + targets: ['images' as const], + items: [{ + target: 'images' as const, + id: holdImg.Id, + name: holdImg.RepoTags[0], + sizeBytes: 50, + managed: false, + reason: 'Image is not used by any container', + image: { references: holdImg.RepoTags }, + }], + reclaimableBytes: 50, + fingerprint: 'forced-hold', + createdAt: Date.now(), + nodeId: 1, + }; + vi.spyOn(dc, 'assertPlanFresh').mockResolvedValue(plan); + const result = await dc.executePrunePlan(plan, []); + + expect(removes).toEqual([]); + expect(result.mutated).toBe(false); + expect(result.outcomes).toEqual([ + expect.objectContaining({ + id: holdImg.Id, + target: 'images', + status: 'skipped', + reason: 'Sencho rollback-hold image', + }), + ]); + }); + it('marks the plan stale when a free image gains a new RepoTag between plan and rebuild', async () => { const img = { Id: 'img-retag', @@ -896,29 +1007,177 @@ describe('DockerController.executePrunePlan', () => { })); }); - it('surfaces multi-repository refuse without a silent partial untag report', async () => { - mockDocker.listImages.mockResolvedValue([ - { - Id: 'img-multi', - RepoTags: ['qa1769/app:v0', 'qa1769-external/keep:v0'], - Size: 50, - Containers: 0, - }, - ]); - const imageRemove = vi.fn().mockRejectedValue( - Object.assign(new Error('conflict: unable to delete (must be forced) - image is referenced in multiple repositories'), { statusCode: 409 }), - ); - mockDocker.getImage.mockReturnValue({ remove: imageRemove }); + it('untags each reviewed name and reports removed once the image is gone', async () => { + const removes = mockImageRemove(); + const result = await executeForcedFreshImagePlan([[multiTagImage], [multiTagImage], []]); + + expect(removes.map((entry) => entry.name)).toEqual(['qa1769/app:v0', 'qa1769-external/keep:v0']); + expect(result.outcomes[0]).toEqual(expect.objectContaining({ + id: 'img-multi', + status: 'removed', + })); + expect(result.mutated).toBe(true); + expect(result.success).toBe(true); + }); + + it('treats a first-ref 404 as absent and still removes when a later reviewed tag succeeds', async () => { + const removes = mockImageRemove(async (name) => { + if (name === 'qa1769/app:v0') { + throw Object.assign(new Error('No such image: qa1769/app:v0'), { statusCode: 404 }); + } + }); + const result = await executeForcedFreshImagePlan([[multiTagImage], [multiTagImage], []]); + + expect(removes).toEqual([ + { name: 'qa1769/app:v0', force: false }, + { name: 'qa1769-external/keep:v0', force: false }, + ]); + expect(result.outcomes[0]).toEqual(expect.objectContaining({ status: 'removed' })); + expect(result.mutated).toBe(true); + expect(result.success).toBe(true); + }); + + it('does not report removed when a later 404 leaves another reviewed tag present', async () => { + mockImageRemove(async (name) => { + if (name === 'qa1769-external/keep:v0') { + throw Object.assign(new Error('No such image'), { statusCode: 404 }); + } + }); + const result = await executeForcedFreshImagePlan([ + [multiTagImage], + [multiTagImage], + [{ ...multiTagImage, RepoTags: ['qa1769/app:v0'] }], + ]); - const dc = DockerController.getInstance(1); - const plan = await dc.buildPrunePlan(['images'], 'all', [], 1); - vi.spyOn(dc, 'assertPlanFresh').mockResolvedValue(plan); - const result = await dc.executePrunePlan(plan, []); - expect(imageRemove).toHaveBeenCalled(); expect(result.outcomes[0]).toEqual(expect.objectContaining({ status: 'failed', - error: expect.stringMatching(/multiple repositories/i), + error: expect.stringMatching(/qa1769\/app:v0/), })); - expect(String(result.outcomes[0] && 'error' in result.outcomes[0] ? result.outcomes[0].error : '')).toMatch(/qa1769\/app:v0/); + expect(result.outcomes[0]).not.toEqual(expect.objectContaining({ status: 'removed' })); + expect(result.mutated).toBe(true); + expect(result.success).toBe(false); + }); + + it('stops on a hard untag error, lists remaining tags, and marks mutated', async () => { + const removes = mockImageRemove(async (name) => { + if (name === 'qa1769-external/keep:v0') { + throw Object.assign(new Error('conflict: unable to delete (must be forced)'), { statusCode: 409 }); + } + }); + const result = await executeForcedFreshImagePlan([ + [multiTagImage], + [multiTagImage], + [{ ...multiTagImage, RepoTags: ['qa1769-external/keep:v0'] }], + ]); + + expect(removes.map((entry) => entry.name)).toEqual(['qa1769/app:v0', 'qa1769-external/keep:v0']); + expect(result.outcomes[0]).toEqual(expect.objectContaining({ + status: 'failed', + error: expect.stringMatching(/qa1769-external\/keep:v0/), + })); + expect(result.mutated).toBe(true); + expect(result.success).toBe(false); + }); + + it('never removes an unexpected tag that appears during execution', async () => { + const img = { Id: 'img-multi', RepoTags: ['qa1769/app:v0'], Size: 50, Containers: 0 }; + const removes = mockImageRemove(); + const result = await executeForcedFreshImagePlan([ + [img], + [img], + [{ ...img, RepoTags: ['qa1769-external/keep:v0'] }], + ]); + + expect(removes.map((entry) => entry.name)).toEqual(['qa1769/app:v0']); + expect(removes.map((entry) => entry.name)).not.toContain('qa1769-external/keep:v0'); + expect(result.outcomes[0]).toEqual(expect.objectContaining({ + status: 'failed', + error: expect.stringMatching(/not in the reviewed set/i), + })); + expect(result.mutated).toBe(true); + }); + + it('removes a dangling planned image by id with force false', async () => { + const img = { Id: 'img-dang', RepoTags: [':'], Size: 20, Containers: 0 }; + const removes = mockImageRemove(); + const result = await executeForcedFreshImagePlan([[img], [img], [img]]); + + expect(removes).toEqual([{ name: 'img-dang', force: false }]); + expect(result.outcomes[0]).toEqual(expect.objectContaining({ status: 'removed' })); + expect(result.mutated).toBe(true); + expect(result.success).toBe(true); + }); + + it('id-removes leftover none tags after reviewed names are gone', async () => { + const img = { Id: 'img-multi', RepoTags: ['qa1769/app:v0'], Size: 50, Containers: 0 }; + const removes = mockImageRemove(); + const result = await executeForcedFreshImagePlan([ + [img], + [img], + [{ ...img, RepoTags: [':'] }], + ]); + + expect(removes).toEqual([ + { name: 'qa1769/app:v0', force: false }, + { name: 'img-multi', force: false }, + ]); + expect(result.outcomes[0]).toEqual(expect.objectContaining({ status: 'removed' })); + expect(result.mutated).toBe(true); + expect(result.success).toBe(true); + }); + + it('does not treat a 409 as missing when the conflict text contains 404', async () => { + const img = { + Id: 'sha256:abc404def', + RepoTags: ['qa1769/app:v0', 'qa1769/app:latest'], + Size: 50, + Containers: 0, + }; + const removes = mockImageRemove(async () => { + throw Object.assign( + new Error('(HTTP code 409) conflict - image sha256:abc404def is being used by running container'), + { statusCode: 409 }, + ); + }); + const result = await executeForcedFreshImagePlan([[img], [img], [img]]); + + expect(removes).toEqual([{ name: 'qa1769/app:v0', force: false }]); + expect(result.outcomes[0]).toEqual(expect.objectContaining({ + status: 'failed', + error: expect.stringMatching(/qa1769\/app:v0/), + })); + expect(result.mutated).toBe(false); + expect(result.success).toBe(false); + }); + + it('does not report dangling id-remove 409 as removed when the message contains 404', async () => { + const img = { Id: 'sha256:abc404def', RepoTags: [':'], Size: 20, Containers: 0 }; + mockImageRemove(async () => { + throw Object.assign( + new Error('(HTTP code 409) conflict - unable to delete sha256:abc404def (must be forced)'), + { statusCode: 409 }, + ); + }); + const result = await executeForcedFreshImagePlan([[img], [img], [img]]); + + expect(result.outcomes[0]).toEqual(expect.objectContaining({ + status: 'failed', + error: expect.stringMatching(/dangling image could not be removed/i), + })); + expect(result.mutated).toBe(false); + expect(result.success).toBe(false); + }); + + it('fails honestly when completion re-list throws after a successful untag', async () => { + const img = { Id: 'img-multi', RepoTags: ['qa1769/app:v0'], Size: 50, Containers: 0 }; + mockImageRemove(); + const result = await executeForcedFreshImagePlan([[img], [img], new Error('daemon busy')]); + + expect(result.outcomes[0]).toEqual(expect.objectContaining({ + status: 'failed', + error: expect.stringMatching(/could not confirm remaining/i), + })); + expect(result.mutated).toBe(true); + expect(result.success).toBe(false); }); }); diff --git a/backend/src/__tests__/system-maintenance-prune.test.ts b/backend/src/__tests__/system-maintenance-prune.test.ts index cba48ddf..98c8f9c6 100644 --- a/backend/src/__tests__/system-maintenance-prune.test.ts +++ b/backend/src/__tests__/system-maintenance-prune.test.ts @@ -174,6 +174,7 @@ describe('Prune plan routes', () => { outcomes: [{ id: 'v1', target: 'volumes', status: 'removed', sizeBytes: 42 }], reclaimedBytes: 42, success: true, + mutated: true, }); vi.spyOn(DockerController, 'getInstance').mockReturnValue({ buildPrunePlan: vi.fn().mockResolvedValue(plan), @@ -190,6 +191,7 @@ describe('Prune plan routes', () => { expect(res.body.success).toBe(true); expect(res.body.reclaimedBytes).toBe(42); expect(res.body.outcomes).toHaveLength(1); + expect(res.body).not.toHaveProperty('mutated'); expect(executePrunePlan).toHaveBeenCalled(); expect(invalidate).toHaveBeenCalledWith('stats:1'); expect(invalidate).toHaveBeenCalledWith('stack-statuses:1'); @@ -268,4 +270,80 @@ describe('Prune plan routes', () => { expect(res.body.items).toHaveLength(1); expect(executePrunePlan).not.toHaveBeenCalled(); }); + + it('invalidates node caches when a failed image outcome is mutated', async () => { + stubFsStacks(); + const plan = samplePlan('fp-partial'); + vi.spyOn(DockerController, 'getInstance').mockReturnValue({ + buildPrunePlan: vi.fn().mockResolvedValue(plan), + executePrunePlan: vi.fn().mockResolvedValue({ + outcomes: [{ id: 'img-multi', target: 'images', status: 'failed', error: 'tags remain' }], + reclaimedBytes: 0, + success: false, + mutated: true, + }), + } as unknown as ReturnType); + const invalidate = vi.spyOn(CacheService.getInstance(), 'invalidate'); + + const res = await request(app) + .post('/api/system/prune/system') + .set('Authorization', authHeader) + .send({ target: 'volumes', scope: 'managed', planFingerprint: 'fp-partial' }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(false); + expect(res.body.outcomes[0].status).toBe('failed'); + expect(invalidate).toHaveBeenCalledWith('stats:1'); + expect(invalidate).toHaveBeenCalledWith('stack-statuses:1'); + }); + + it('does not invalidate node caches when a failed outcome is not mutated', async () => { + stubFsStacks(); + const plan = samplePlan('fp-nomut'); + vi.spyOn(DockerController, 'getInstance').mockReturnValue({ + buildPrunePlan: vi.fn().mockResolvedValue(plan), + executePrunePlan: vi.fn().mockResolvedValue({ + outcomes: [{ id: 'img-multi', target: 'images', status: 'failed', error: 'references changed' }], + reclaimedBytes: 0, + success: false, + mutated: false, + }), + } as unknown as ReturnType); + const invalidate = vi.spyOn(CacheService.getInstance(), 'invalidate'); + + const res = await request(app) + .post('/api/system/prune/system') + .set('Authorization', authHeader) + .send({ target: 'volumes', scope: 'managed', planFingerprint: 'fp-nomut' }); + + expect(res.status).toBe(200); + expect(invalidate).not.toHaveBeenCalled(); + }); + + it('legacy no-fingerprint containers prune omits mutated from the JSON body', async () => { + stubFsStacks(); + const executePrunePlan = vi.fn().mockResolvedValue({ + outcomes: [{ id: 'c1', target: 'containers', status: 'removed' }], + reclaimedBytes: 0, + success: true, + mutated: true, + }); + vi.spyOn(DockerController, 'getInstance').mockReturnValue({ + buildPrunePlan: vi.fn().mockResolvedValue(samplePlan('unused')), + executePrunePlan, + } as unknown as ReturnType); + + const res = await request(app) + .post('/api/system/prune/system') + .set('Authorization', authHeader) + .send({ target: 'containers', scope: 'managed' }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.reclaimedBytes).toBe(0); + expect(res.body.outcomes).toEqual([ + { id: 'c1', target: 'containers', status: 'removed' }, + ]); + expect(res.body).not.toHaveProperty('mutated'); + }); }); diff --git a/backend/src/helpers/fleetPrune.ts b/backend/src/helpers/fleetPrune.ts index 46bd4896..d814bbd1 100644 --- a/backend/src/helpers/fleetPrune.ts +++ b/backend/src/helpers/fleetPrune.ts @@ -427,7 +427,7 @@ async function executeLocal(entry: Preflight, targets: FleetPruneTarget[]): Prom const knownStacks = await FileSystemService.getInstance(entry.node.id).getStacks(); const isImageHeld = ServiceUpdateRecoveryService.getInstance().buildHeldImagePredicate(entry.node.id); const result = await DockerController.getInstance(entry.node.id).executePrunePlan(plan, knownStacks, isImageHeld); - if (result.outcomes.some((outcome) => outcome.status === 'removed')) { + if (result.mutated) { try { invalidateNodeCaches(entry.node.id); } catch (error) { diff --git a/backend/src/routes/systemMaintenance.ts b/backend/src/routes/systemMaintenance.ts index aafe9319..3263dd4f 100644 --- a/backend/src/routes/systemMaintenance.ts +++ b/backend/src/routes/systemMaintenance.ts @@ -6,7 +6,7 @@ import DockerController, { type PruneScope, type PruneTarget, } from '../services/DockerController'; -import { isPruneTarget } from '../services/prunePlan'; +import { isPruneTarget, type PruneItemOutcome } from '../services/prunePlan'; import { FileSystemService } from '../services/FileSystemService'; import { ServiceUpdateRecoveryService } from '../services/ServiceUpdateRecoveryService'; import { StackUpdateRecoveryService, shortGenerationId } from '../services/StackUpdateRecoveryService'; @@ -250,7 +250,7 @@ systemMaintenanceRouter.post('/prune/system', async (req: Request, res: Response success: result.success, }); } - if (result.outcomes.some((outcome) => outcome.status === 'removed')) { + if (result.mutated) { invalidateNodeCaches(req.nodeId); } res.json({ @@ -271,7 +271,7 @@ systemMaintenanceRouter.post('/prune/system', async (req: Request, res: Response const target = targets[0]; console.log(`[Resources] System prune: ${sanitizeForLog(target)} (scope: ${pruneScope})`); const pruneStartedAt = Date.now(); - let result: { success: boolean; reclaimedBytes: number }; + let result: { success: boolean; reclaimedBytes: number; outcomes?: PruneItemOutcome[] }; if (pruneScope === 'managed' && target !== 'containers') { result = await dockerController.pruneManagedOnly( target as 'images' | 'volumes' | 'networks', @@ -296,7 +296,12 @@ systemMaintenanceRouter.post('/prune/system', async (req: Request, res: Response if (target === 'containers') { invalidateNodeCaches(req.nodeId); } - res.json({ message: 'Prune completed', ...result }); + res.json({ + message: 'Prune completed', + success: result.success, + reclaimedBytes: result.reclaimedBytes, + ...(result.outcomes !== undefined ? { outcomes: result.outcomes } : {}), + }); } catch (error: unknown) { if (error instanceof TimeoutError) { console.warn('System prune: docker disk usage timed out'); diff --git a/backend/src/services/DockerController.ts b/backend/src/services/DockerController.ts index 506e861c..485d5276 100644 --- a/backend/src/services/DockerController.ts +++ b/backend/src/services/DockerController.ts @@ -12,6 +12,7 @@ import { FileSystemService } from './FileSystemService'; import SelfIdentityService from './SelfIdentityService'; import { fingerprintPrunePlan, + normalizePruneImageReferences, normalizePruneTargets, projectPruneOwnershipLabels, pruneImageReferencesEqual, @@ -1019,7 +1020,8 @@ class DockerController { /** * Build an itemized prune plan using the same eligibility predicates that - * `executePrunePlan` revalidates before each delete. Never calls remove APIs. + * `executePrunePlan` revalidates against live tags before mutation. Never + * calls remove APIs. */ public async buildPrunePlan( targets: PruneTarget[], @@ -1214,6 +1216,7 @@ class DockerController { for (const img of rawImages) { if (selfIdentity.isOwnImage(img.Id)) continue; if (isImageHeld?.(img.Id)) continue; + if (isFullySyntheticHoldImage(img.RepoTags ?? [])) continue; const refs = imageToContainerIds.get(img.Id) ?? []; const becomesFree = freeingImages && refs.length > 0 @@ -1311,14 +1314,16 @@ class DockerController { } /** - * Execute a previously previewed plan: revalidate fingerprint, then delete - * each planned item serially with force:false. Never deletes unplanned items. + * Execute a previously previewed plan: revalidate fingerprint, then apply each + * planned item serially with force:false. Images untag reviewed names, then + * re-list to decide removed, remaining-tag failure, or dangling ID-remove. + * Never mutates unplanned items. */ public async executePrunePlan( plan: PrunePlan, knownStackNames: string[], isImageHeld?: (imageId: string) => boolean, - ): Promise<{ outcomes: PruneItemOutcome[]; reclaimedBytes: number; success: boolean }> { + ): Promise<{ outcomes: PruneItemOutcome[]; reclaimedBytes: number; success: boolean; mutated: boolean }> { const fresh = await this.assertPlanFresh(plan, knownStackNames, isImageHeld); if (!fresh) throw new PrunePlanStaleError(); @@ -1330,6 +1335,7 @@ class DockerController { const outcomes: PruneItemOutcome[] = []; let reclaimedBytes = 0; + let planMutated = false; /** Image IDs whose planned container removal failed or was skipped. */ const blockedImageIds = new Set(); @@ -1415,10 +1421,11 @@ class DockerController { continue; } } - const outcome = await this.executePlannedImage( + const { outcome, mutated: imageMutated } = await this.executePlannedImage( item, selfIdentity, ); outcomes.push(outcome); + if (imageMutated) planMutated = true; if (outcome.status === 'removed') reclaimedBytes += outcome.sizeBytes ?? item.sizeBytes ?? 0; } } catch (e) { @@ -1433,7 +1440,8 @@ class DockerController { } const success = outcomes.every((o) => o.status !== 'failed'); - return { outcomes, reclaimedBytes, success }; + const mutated = planMutated || outcomes.some((outcome) => outcome.status === 'removed'); + return { outcomes, reclaimedBytes, success, mutated }; } private async lookupContainerImageId(containerId: string): Promise { @@ -1588,35 +1596,66 @@ class DockerController { return { id: item.id, target: 'networks', status: 'removed' }; } + private static dockerIsNotFound(error: unknown): boolean { + const statusCode = (error as { statusCode?: number }).statusCode; + if (statusCode === 404) return true; + if (typeof statusCode === 'number') return false; + const message = error instanceof Error ? error.message : String(error); + return /no such image/i.test(message); + } + + private static listedImageMatch(images: T[], id: string): T | undefined { + return images.find((image) => image.Id === id || image.Id.startsWith(id) || id.startsWith(image.Id)); + } + + private static plannedImageResult( + outcome: PruneItemOutcome, + mutated = false, + ): { outcome: PruneItemOutcome; mutated: boolean } { + return { outcome, mutated }; + } + + private async listedImageById(id: string): Promise<{ Id: string; RepoTags?: string[] | null } | undefined> { + const images = await this.docker.listImages({ all: false }) as Array<{ + Id: string; + RepoTags?: string[] | null; + }>; + return DockerController.listedImageMatch(images, id); + } + private async executePlannedImage( item: PrunePlanItem, selfIdentity: SelfIdentityService, - ): Promise { + ): Promise<{ outcome: PruneItemOutcome; mutated: boolean }> { if (selfIdentity.isOwnImage(item.id)) { - return { id: item.id, target: 'images', status: 'skipped', reason: 'Sencho self image' }; + return DockerController.plannedImageResult({ + id: item.id, target: 'images', status: 'skipped', reason: 'Sencho self image', + }); } if (item.target !== 'images') { - return { id: item.id, target: 'images', status: 'failed', error: 'Plan item is not an image target' }; + return DockerController.plannedImageResult({ + id: item.id, target: 'images', status: 'failed', error: 'Plan item is not an image target', + }); } const plannedRefs = item.image.references; - const rawImages = await this.docker.listImages({ all: false }) as Array<{ - Id: string; - Size?: number; - RepoTags?: string[] | null; - }>; - const img = rawImages.find((i) => i.Id === item.id || i.Id.startsWith(item.id) || item.id.startsWith(i.Id)); + const img = await this.listedImageById(item.id); if (!img) { - return { id: item.id, target: 'images', status: 'skipped', reason: 'Image no longer exists' }; + return DockerController.plannedImageResult({ + id: item.id, target: 'images', status: 'skipped', reason: 'Image no longer exists', + }); + } + if (isFullySyntheticHoldImage(img.RepoTags ?? [])) { + return DockerController.plannedImageResult({ + id: item.id, target: 'images', status: 'skipped', reason: 'Sencho rollback-hold image', + }); } - // Defense in depth when fleet preflight/assertPlanFresh already matched: - // refuse mutation if live tags drifted from the reviewed reference set. if (!pruneImageReferencesEqual(plannedRefs, img.RepoTags)) { - return { + return DockerController.plannedImageResult({ id: item.id, target: 'images', status: 'failed', error: 'Image references changed since the plan was built; refresh and confirm again', - }; + }); } const allContainers = await this.docker.listContainers({ all: true }) as Array<{ ImageID?: string; @@ -1626,51 +1665,87 @@ class DockerController { || Boolean(container.ImageID?.startsWith(img.Id)) || img.Id.startsWith(container.ImageID ?? '')); if (containerRefs.length > 0) { - return { id: item.id, target: 'images', status: 'skipped', reason: 'Image still has container references' }; + return DockerController.plannedImageResult({ + id: item.id, target: 'images', status: 'skipped', reason: 'Image still has container references', + }); + } + + let mutated = false; + for (const ref of plannedRefs) { + try { + await this.docker.getImage(ref).remove({ force: false }); + mutated = true; + } catch (error) { + if (!DockerController.dockerIsNotFound(error)) break; + } + } + return this.completePlannedImage(item, plannedRefs, mutated); + } + + private static remainingImageFailure( + item: PrunePlanItem, + plannedRefs: string[], + remaining: string[], + ): PruneItemOutcome { + const prefix = remaining.some((ref) => !plannedRefs.includes(ref)) + ? 'Image still has repository tags that were not in the reviewed set: ' + : 'Image still has repository tags after prune: '; + return { + id: item.id, + target: 'images', + status: 'failed', + error: prefix + remaining.join(', ') + + '. Refresh the plan and confirm remaining references before pruning again.', + }; + } + + private async completePlannedImage( + item: PrunePlanItem, + plannedRefs: string[], + mutated: boolean, + ): Promise<{ outcome: PruneItemOutcome; mutated: boolean }> { + const removed = DockerController.plannedImageResult( + { id: item.id, target: 'images', status: 'removed', sizeBytes: item.sizeBytes ?? 0 }, + true, + ); + let listed: { Id: string; RepoTags?: string[] | null } | undefined; + try { + listed = await this.listedImageById(item.id); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error( + `[executePrunePlan] Could not confirm remaining tags for ${sanitizeForLog(item.id)}:`, + sanitizeForLog(message), + ); + return DockerController.plannedImageResult({ + id: item.id, + target: 'images', + status: 'failed', + error: 'Could not confirm remaining image tags after prune. ' + + 'Refresh the plan and confirm remaining references before pruning again.', + }, mutated); + } + if (!listed) return removed; + const remaining = normalizePruneImageReferences(listed.RepoTags); + if (remaining.length > 0) { + return DockerController.plannedImageResult( + DockerController.remainingImageFailure(item, plannedRefs, remaining), + mutated, + ); } try { await this.docker.getImage(item.id).remove({ force: false }); - } catch (e) { - const message = e instanceof Error ? e.message : String(e); - // Re-read tags so a multi-repository conflict that partially untagged is not - // reported as a no-op when live names already diverged. - let afterTags: string[] | null | undefined; - try { - const afterList = await this.docker.listImages({ all: false }) as Array<{ - Id: string; - RepoTags?: string[] | null; - }>; - afterTags = afterList.find((i) => i.Id === img.Id || i.Id.startsWith(img.Id) || img.Id.startsWith(i.Id)) - ?.RepoTags; - } catch { - afterTags = undefined; - } - if (afterTags !== undefined && !pruneImageReferencesEqual(plannedRefs, afterTags)) { - return { - id: item.id, - target: 'images', - status: 'failed', - error: 'Image delete did not fully remove the reviewed image; some repository tags changed. ' - + 'Refresh the plan and confirm remaining references before pruning again. ' - + message, - }; - } - const lower = message.toLowerCase(); - if (lower.includes('multiple repositories') || lower.includes('must be forced') || lower.includes('conflict')) { - return { - id: item.id, - target: 'images', - status: 'failed', - error: 'Docker refused to delete this image because it is tagged in multiple repositories. ' - + 'Reviewed tags: ' + (plannedRefs.join(', ') || '(none)') + '. ' - + 'Resolve tags on the host or re-scope the plan, then dry-run again. ' - + message, - }; - } - throw e; + return removed; + } catch (error) { + if (DockerController.dockerIsNotFound(error)) return removed; + const message = error instanceof Error ? error.message : String(error); + return DockerController.plannedImageResult({ + id: item.id, + target: 'images', + status: 'failed', + error: 'Dangling image could not be removed after reviewed tags were cleared. ' + message, + }, mutated); } - // 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<{ diff --git a/docs/features/resources.mdx b/docs/features/resources.mdx index 153eafa6..b8831141 100644 --- a/docs/features/resources.mdx +++ b/docs/features/resources.mdx @@ -172,4 +172,11 @@ When no unmanaged containers are detected, the tab shows a success state with th The banner starts from what Docker reports as reclaimable on the node, subtracts images Sencho holds as rollback protection points, and counts only the resources a standard prune can actually remove. Held images are never part of that total and never appear in a prune plan, so the residue after a prune is normally the storage driver still reporting a few megabytes that the prune did not free. Held images are listed in the **Rollback** tab and are freed once the hold is released or superseded. To stop the banner drawing attention to a stubborn remainder, dismiss it with the **×** in its top-right corner; it stays hidden until the reclaimable total grows past that point. To turn it off for the node entirely, switch off **Show reclaimable-space banner** in **Settings → Monitoring → Docker & Storage**. + + Unused images often carry more than one name, such as a version tag and `latest`. Sencho includes every reviewed tag in the prune confirmation. Confirming removes those tags one by one, and Docker deletes the image once no names remain. + + + Prune confirmation dialog listing an unused image with both a version tag and latest in the reviewed items + + diff --git a/docs/images/resources/resources-prune-confirm.png b/docs/images/resources/resources-prune-confirm.png new file mode 100644 index 0000000000000000000000000000000000000000..b3f51025c2e57ae6c6c6bbc966c0b950e2290bc1 GIT binary patch literal 33890 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 { }, stackName); }); }); + +test.describe('resources prune confirm docs screenshot', () => { + test.use({ viewport: { width: 1920, height: 1080 } }); + + test('prune confirm lists extra repository tags', async ({ page }) => { + await page.route('**/system/prune/plan', async (route) => { + if (route.request().method() !== 'POST') { + await route.continue(); + return; + } + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + scope: 'managed', + targets: ['images'], + items: [{ + target: 'images', + id: 'sha256:docs-prune-multi-tag', + name: 'ghcr.io/example/app:1.5.2', + sizeBytes: 48234496, + managed: true, + reason: 'Image is not used by any container', + image: { + references: [ + 'ghcr.io/example/app:1.5.2', + 'ghcr.io/example/app:latest', + ], + }, + }], + reclaimableBytes: 48234496, + fingerprint: 'fp-docs-prune-confirm', + createdAt: 1_700_000_000_000, + nodeId: 1, + }), + }); + }); + + await loginAs(page); + await page.getByRole('button', { name: /resources/i }).click(); + await page.getByRole('button', { name: /Prune Unused Images/ }).click(); + const pruneDialog = page.getByRole('alertdialog').filter({ hasText: 'Prune Sencho-managed images' }); + await expect(pruneDialog).toBeVisible(); + await expect(pruneDialog.getByText('ghcr.io/example/app:latest')).toBeVisible(); + await pruneDialog.screenshot({ + path: path.join(DOCS_IMAGES, 'resources', 'resources-prune-confirm.png'), + }); + }); +}); diff --git a/frontend/src/components/ResourcesView.tsx b/frontend/src/components/ResourcesView.tsx index b74b47ab..8ef1e6dc 100644 --- a/frontend/src/components/ResourcesView.tsx +++ b/frontend/src/components/ResourcesView.tsx @@ -126,16 +126,25 @@ function PrunePlanPreview({ {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)}` : ''} -
  • - ))} -
+ +
    + {shown.map((item) => ( +
  • + + {item.target} + {' · '} + {item.name} + {item.sizeBytes != null && item.sizeBytes > 0 ? ` · ${formatBytes(item.sizeBytes)}` : ''} + + {item.target === 'images' && item.image.references + .filter((ref) => ref !== item.name) + .map((ref) => ( + {ref} + ))} +
  • + ))} +
+
{remaining > 0 && (

and {remaining} more

)} diff --git a/frontend/src/components/__tests__/ResourcesView.test.tsx b/frontend/src/components/__tests__/ResourcesView.test.tsx index 980fe545..fb1de730 100644 --- a/frontend/src/components/__tests__/ResourcesView.test.tsx +++ b/frontend/src/components/__tests__/ResourcesView.test.tsx @@ -241,6 +241,43 @@ describe('ResourcesView', () => { expect(body.planFingerprint).toBe('fp-test'); }); + it('discloses extra repository tags in the prune confirm list', async () => { + mockedFetch.mockImplementation((url: string, opts?: RequestInit) => { + if (url === '/system/prune/plan' && opts?.method === 'POST') { + return Promise.resolve(jsonResponse(samplePrunePlan({ + items: [{ + target: 'images', + id: 'img-multi', + name: 'ghcr.io/example/very-long-app-name:1.5.2-build.1844', + sizeBytes: 1000, + managed: false, + reason: 'Image is not used by any container', + image: { + references: [ + 'ghcr.io/example/very-long-app-name:1.5.2-build.1844', + 'ghcr.io/example/very-long-app-name:latest', + ], + }, + }], + }))); + } + 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/ })); + + const extraRef = await screen.findByText('ghcr.io/example/very-long-app-name:latest'); + expect(extraRef).toHaveClass('break-all'); + expect(extraRef).not.toHaveClass('truncate'); + expect(screen.getByText(/1\.5\.2-build\.1844/)).toBeInTheDocument(); + }); + 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') {