diff --git a/backend/src/__tests__/docker-controller.test.ts b/backend/src/__tests__/docker-controller.test.ts index bb9fdd79..b01eff85 100644 --- a/backend/src/__tests__/docker-controller.test.ts +++ b/backend/src/__tests__/docker-controller.test.ts @@ -913,6 +913,72 @@ describe('DockerController - managed image prune accounting', () => { expect(result.reclaimableBytes).toBe(2000); }); + + it('returns the same free image ID set from plan, estimate, and pruneManagedOnly including working-dir ownership', async () => { + CacheService.getInstance().invalidate('project-name-map'); + const listImages = [ + { + Id: 'img-label', + Containers: 0, + Size: 100, + RepoTags: ['svc:1'], + Labels: { 'com.docker.compose.project': 'any-stack' }, + }, + { + Id: 'img-workdir', + Containers: 0, + Size: 100, + RepoTags: ['svc2:1'], + Labels: { 'com.docker.compose.project.working_dir': '/app/compose/any-stack' }, + }, + { + Id: 'img-repo', + Containers: 0, + Size: 100, + RepoTags: ['running-app:1'], + }, + { + Id: 'img-current', + Containers: 1, + Size: 100, + RepoTags: ['running-app:2'], + }, + { + Id: 'img-foreign', + Containers: 0, + Size: 100, + RepoTags: ['running-app:0'], + Labels: { 'com.docker.compose.project': 'other-project' }, + }, + ]; + mockDocker.listImages.mockResolvedValue(listImages); + mockDocker.listContainers.mockResolvedValue([ + { + Id: 'c-run', + Image: 'running-app:2', + ImageID: 'img-current', + Labels: { 'com.docker.compose.project': 'any-stack' }, + }, + ]); + mockDocker.df + .mockResolvedValue({ + Volumes: [], + Images: listImages.map((img) => ({ Id: img.Id, SharedSize: 0, Size: img.Size })), + LayersSize: 500, + }); + mockDocker.getImage.mockReturnValue({ remove: vi.fn().mockResolvedValue(undefined) }); + + const dc = DockerController.getInstance(1); + const plan = await dc.buildPrunePlan(['images'], 'managed', ['any-stack'], 1); + const estimate = await dc.estimateManagedReclaim('images', ['any-stack']); + await dc.pruneManagedOnly('images', ['any-stack']); + + const planIds = plan.items.map((i) => i.id).sort(); + expect(planIds).toEqual(['img-label', 'img-repo', 'img-workdir']); + expect(estimate.reclaimableBytes).toBe(300); + const removeCalls = mockDocker.getImage.mock.calls.map((c: unknown[]) => c[0]).sort(); + expect(removeCalls).toEqual(planIds); + }); }); // ── getOrphanContainers ──────────────────────────────────────────────── diff --git a/backend/src/__tests__/managed-image-attribution.test.ts b/backend/src/__tests__/managed-image-attribution.test.ts new file mode 100644 index 00000000..248f9512 --- /dev/null +++ b/backend/src/__tests__/managed-image-attribution.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import { + buildManagedImageRepoSet, + classifyManagedImageCandidate, + imageRepositoryKey, +} from '../helpers/managedImageAttribution'; + +describe('imageRepositoryKey', () => { + it('strips tags and normalizes Docker Hub library refs', () => { + expect(imageRepositoryKey('nginx:1.27')).toBe('registry-1.docker.io/library/nginx'); + expect(imageRepositoryKey('docker.io/library/nginx:alpine')).toBe('registry-1.docker.io/library/nginx'); + expect(imageRepositoryKey('ghcr.io/acme/api:v1')).toBe('ghcr.io/acme/api'); + }); + + it('returns null for digests and dangling placeholders', () => { + expect(imageRepositoryKey('sha256:' + 'a'.repeat(64))).toBeNull(); + expect(imageRepositoryKey(':')).toBeNull(); + expect(imageRepositoryKey('')).toBeNull(); + }); + + it('keeps host:port registry in the repository key', () => { + expect(imageRepositoryKey('myhost:5000/foo:1')).toBe('myhost:5000/foo'); + }); +}); + +describe('buildManagedImageRepoSet', () => { + it('collects container Image tags and listed RepoTags for managed ImageIDs', () => { + const { repoKeys, repoToStack } = buildManagedImageRepoSet( + [{ Image: 'sha256:' + 'b'.repeat(64), ImageID: 'img-cur', stack: 'my-stack' }], + [ + { Id: 'img-cur', RepoTags: ['myapp:2'] }, + { Id: 'img-old', RepoTags: ['myapp:1'] }, + ], + ); + expect(repoKeys).toEqual(new Set(['registry-1.docker.io/library/myapp'])); + expect(repoToStack.get('registry-1.docker.io/library/myapp')).toBe('my-stack'); + }); + + it('normalizes private registry host:port refs as repository keys', () => { + const { repoKeys, repoToStack } = buildManagedImageRepoSet( + [{ Image: 'myhost:5000/foo:1', ImageID: 'img-private', stack: 'private-stack' }], + [{ Id: 'img-private', RepoTags: ['myhost:5000/foo:1'] }], + ); + expect(repoKeys).toEqual(new Set(['myhost:5000/foo'])); + expect(repoToStack.get('myhost:5000/foo')).toBe('private-stack'); + }); +}); + +describe('classifyManagedImageCandidate', () => { + const emptySets = { + managedImageIds: new Set(), + unmanagedImageIds: new Set(), + repoKeys: new Set(), + resolveStack: () => null as string | null, + }; + + it('rejects present foreign project labels before becomesFree and repo-match', () => { + const result = classifyManagedImageCandidate({ + ...emptySets, + imageId: 'img1', + labels: { 'com.docker.compose.project': 'other' }, + repoTags: ['nginx:1'], + becomesFree: true, + repoKeys: new Set(['registry-1.docker.io/library/nginx']), + resolveStack: () => null, + }); + expect(result).toEqual({ eligible: false }); + }); + + it('accepts becomesFree when no foreign project bar applies', () => { + const result = classifyManagedImageCandidate({ + ...emptySets, + imageId: 'img1', + labels: undefined, + repoTags: ['orphan:1'], + becomesFree: true, + }); + expect(result).toEqual({ eligible: true, reason: 'becomes-free' }); + }); + + it('does not attach stackName on repo-match (repository sharing is not ownership)', () => { + const result = classifyManagedImageCandidate({ + imageId: 'img-free', + labels: undefined, + repoTags: ['nginx:1.14'], + becomesFree: false, + managedImageIds: new Set(), + unmanagedImageIds: new Set(), + repoKeys: new Set(['registry-1.docker.io/library/nginx']), + resolveStack: () => null, + }); + expect(result).toEqual({ eligible: true, reason: 'repo-match' }); + }); +}); diff --git a/backend/src/__tests__/prune-plan.test.ts b/backend/src/__tests__/prune-plan.test.ts index 0a504a1a..54168fd3 100644 --- a/backend/src/__tests__/prune-plan.test.ts +++ b/backend/src/__tests__/prune-plan.test.ts @@ -75,12 +75,12 @@ beforeEach(() => { 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: 'images', id: 'img-b', image: { references: ['app:1'] } }, { target: 'volumes', id: 'vol-a' }, ]); const b = fingerprintPrunePlan(1, 'managed', ['volumes', 'images'], [ { target: 'volumes', id: 'vol-a' }, - { target: 'images', id: 'img-b' }, + { target: 'images', id: 'img-b', image: { references: ['app:1'] } }, ]); expect(a).toBe(b); expect(a).toMatch(/^[a-f0-9]{64}$/); @@ -93,6 +93,31 @@ describe('fingerprintPrunePlan', () => { expect(fingerprintPrunePlan(1, 'managed', ['images'], [{ target: 'volumes', id: 'v1' }])).not.toBe(base); expect(fingerprintPrunePlan(1, 'managed', ['volumes'], [{ target: 'volumes', id: 'v2' }])).not.toBe(base); }); + + it('changes when an image gains or loses RepoTags without changing the image id', () => { + const sameId = 'sha256:abc'; + const before = fingerprintPrunePlan(1, 'managed', ['images'], [ + { target: 'images', id: sameId, image: { references: ['app:v0'] } }, + ]); + const afterAddTag = fingerprintPrunePlan(1, 'managed', ['images'], [ + { target: 'images', id: sameId, image: { references: ['app:v0', 'extra:v0'] } }, + ]); + const afterReorder = fingerprintPrunePlan(1, 'managed', ['images'], [ + { target: 'images', id: sameId, image: { references: ['extra:v0', 'app:v0'] } }, + ]); + expect(afterAddTag).not.toBe(before); + expect(afterReorder).toBe(afterAddTag); + }); + + it('changes when the image digest differs for the same id and tags', () => { + const base = fingerprintPrunePlan(1, 'managed', ['images'], [ + { target: 'images', id: 'img1', image: { references: ['app:1'], digest: 'd1' } }, + ]); + const other = fingerprintPrunePlan(1, 'managed', ['images'], [ + { target: 'images', id: 'img1', image: { references: ['app:1'], digest: 'd2' } }, + ]); + expect(other).not.toBe(base); + }); }); describe('normalizePruneTargets', () => { @@ -355,7 +380,7 @@ describe('DockerController.buildPrunePlan', () => { ]); }); - it('excludes unattributed unused images from managed scope', async () => { + it('includes labeled free images under managed and excludes foreign repos without attribution', async () => { mockDocker.listImages.mockResolvedValue([ { Id: 'img-external', RepoTags: ['saelix/sencho:pr-1610'], Size: 100, Containers: 0 }, { @@ -373,6 +398,185 @@ describe('DockerController.buildPrunePlan', () => { expect(plan.items.map((i) => i.id)).toEqual(['img-labeled']); }); + it('includes previous free tags of a repo still used by a managed container', async () => { + mockDocker.listContainers.mockResolvedValue([ + { + Id: 'c-run', + Names: ['/web'], + State: 'running', + Image: 'myapp:1.1', + ImageID: 'img-current', + Labels: { 'com.docker.compose.project': 'my-stack' }, + }, + ]); + mockDocker.listImages.mockResolvedValue([ + { Id: 'img-current', RepoTags: ['myapp:1.1'], Size: 200, Containers: 1 }, + { Id: 'img-old', RepoTags: ['myapp:1.0'], Size: 180, Containers: 0 }, + ]); + + const dc = DockerController.getInstance(1); + const plan = await dc.buildPrunePlan(['images'], 'managed', ['my-stack'], 1); + + expect(plan.items).toEqual([ + expect.objectContaining({ + id: 'img-old', + managed: true, + reason: 'Unused image whose repository is used by a Sencho stack', + }), + ]); + // Repository sharing is not ownership: confirm surface must not name a stack. + expect(plan.items[0]?.stackName).toBeUndefined(); + }); + + it('attributes stackName for label-owned free images but not for repo-matched free tags', async () => { + mockDocker.listContainers.mockResolvedValue([ + { + Id: 'c-run', + Names: ['/web'], + State: 'running', + Image: 'myapp:1.1', + ImageID: 'img-current', + Labels: { 'com.docker.compose.project': 'my-stack' }, + }, + ]); + mockDocker.listImages.mockResolvedValue([ + { Id: 'img-current', RepoTags: ['myapp:1.1'], Size: 200, Containers: 1 }, + { Id: 'img-old', RepoTags: ['myapp:1.0'], Size: 180, Containers: 0 }, + { + Id: 'img-labeled', + RepoTags: ['other:1'], + 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); + const byId = Object.fromEntries(plan.items.map((item) => [item.id, item])); + + expect(byId['img-old']).toEqual( + expect.objectContaining({ + reason: 'Unused image whose repository is used by a Sencho stack', + }), + ); + expect(byId['img-old']?.stackName).toBeUndefined(); + expect(byId['img-labeled']).toEqual( + expect.objectContaining({ + managed: true, + stackName: 'my-stack', + }), + ); + }); + + it('excludes a foreign Compose project image even when its repo matches a managed stack', async () => { + mockDocker.listContainers.mockResolvedValue([ + { + Id: 'c-run', + Names: ['/web'], + State: 'running', + Image: 'nginx:1.27', + ImageID: 'img-current', + Labels: { 'com.docker.compose.project': 'my-stack' }, + }, + ]); + mockDocker.listImages.mockResolvedValue([ + { Id: 'img-current', RepoTags: ['nginx:1.27'], Size: 200, Containers: 1 }, + { + Id: 'img-foreign', + RepoTags: ['nginx:1.24'], + Size: 180, + Containers: 0, + Labels: { 'com.docker.compose.project': 'other-project' }, + }, + ]); + + const dc = DockerController.getInstance(1); + const managedPlan = await dc.buildPrunePlan(['images'], 'managed', ['my-stack'], 1); + const allPlan = await dc.buildPrunePlan(['images'], 'all', ['my-stack'], 1); + + expect(managedPlan.items.map((i) => i.id)).toEqual([]); + expect(allPlan.items.map((i) => i.id)).toContain('img-foreign'); + }); + + it('excludes untagged dangling images from managed scope and includes them under all', async () => { + mockDocker.listContainers.mockResolvedValue([ + { + Id: 'c-run', + Names: ['/web'], + State: 'running', + Image: 'myapp:1.1', + ImageID: 'img-current', + Labels: { 'com.docker.compose.project': 'my-stack' }, + }, + ]); + mockDocker.listImages.mockResolvedValue([ + { Id: 'img-current', RepoTags: ['myapp:1.1'], Size: 200, Containers: 1 }, + { Id: 'img-dangling', RepoTags: null, Size: 90, Containers: 0 }, + { Id: 'img-none', RepoTags: [':'], Size: 80, Containers: 0 }, + ]); + + const dc = DockerController.getInstance(1); + const managedPlan = await dc.buildPrunePlan(['images'], 'managed', ['my-stack'], 1); + const allPlan = await dc.buildPrunePlan(['images'], 'all', ['my-stack'], 1); + + expect(managedPlan.items.map((i) => i.id)).toEqual([]); + expect(allPlan.items.map((i) => i.id).sort()).toEqual(['img-dangling', 'img-none']); + }); + + it('includes a becomesFree image under managed with the free-after-containers reason', async () => { + mockDocker.listContainers.mockResolvedValue([ + { + Id: 'c-exited', + Names: ['/exited'], + State: 'exited', + ImageID: 'img-only', + Labels: { 'com.docker.compose.project': 'my-stack' }, + }, + ]); + mockDocker.listImages.mockResolvedValue([ + { Id: 'img-only', RepoTags: ['scratch:old'], Size: 50, Containers: 1 }, + ]); + + 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.find((i) => i.target === 'images' && i.id === 'img-only')).toEqual( + expect.objectContaining({ + reason: 'Image becomes unused after planned container removal', + managed: true, + }), + ); + }); + + it('excludes a becomesFree image labeled for a foreign Compose project under managed', async () => { + mockDocker.listContainers.mockResolvedValue([ + { + Id: 'c-exited', + Names: ['/exited'], + State: 'exited', + ImageID: 'img-foreign', + Labels: { 'com.docker.compose.project': 'my-stack' }, + }, + ]); + mockDocker.listImages.mockResolvedValue([ + { + Id: 'img-foreign', + RepoTags: ['shared:1'], + Size: 50, + Containers: 1, + Labels: { 'com.docker.compose.project': 'other-project' }, + }, + ]); + + const dc = DockerController.getInstance(1); + const plan = await dc.buildPrunePlan(['containers', 'images'], 'managed', ['my-stack'], 1); + + expect(plan.items.some((i) => i.target === 'containers')).toBe(true); + expect(plan.items.some((i) => i.target === 'images' && i.id === 'img-foreign')).toBe(false); + }); + it('does not mark an image free when only some referencing containers are planned', async () => { mockDocker.listContainers.mockResolvedValue([ { @@ -401,6 +605,28 @@ describe('DockerController.buildPrunePlan', () => { expect(plan.items.some((i) => i.target === 'images' && i.id === 'img-shared')).toBe(false); }); + it('attributes via listed image RepoTags when the managed container Image is a bare digest', async () => { + mockDocker.listContainers.mockResolvedValue([ + { + Id: 'c-run', + Names: ['/web'], + State: 'running', + Image: 'sha256:' + 'a'.repeat(64), + ImageID: 'img-current', + Labels: { 'com.docker.compose.project': 'my-stack' }, + }, + ]); + mockDocker.listImages.mockResolvedValue([ + { Id: 'img-current', RepoTags: ['ghcr.io/acme/api:v2'], Size: 200, Containers: 1 }, + { Id: 'img-old', RepoTags: ['ghcr.io/acme/api:v1'], Size: 180, Containers: 0 }, + ]); + + const dc = DockerController.getInstance(1); + const plan = await dc.buildPrunePlan(['images'], 'managed', ['my-stack'], 1); + + expect(plan.items.map((i) => i.id)).toEqual(['img-old']); + }); + 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 }, @@ -625,4 +851,74 @@ describe('DockerController.executePrunePlan', () => { expect.objectContaining({ id: 'img1', target: 'images', status: 'skipped', reason: expect.stringMatching(/held/i) }), ]); }); + + it('marks the plan stale when a free image gains a new RepoTag between plan and rebuild', async () => { + const img = { + Id: 'img-retag', + RepoTags: ['qa1769/app:v0'], + Size: 100, + Containers: 0, + }; + mockDocker.listImages.mockResolvedValue([img]); + mockDocker.listContainers.mockResolvedValue([]); + + const dc = DockerController.getInstance(1); + const plan = await dc.buildPrunePlan(['images'], 'all', [], 1); + expect(plan.items).toHaveLength(1); + + mockDocker.listImages.mockResolvedValue([{ + ...img, + RepoTags: ['qa1769/app:v0', 'qa1769-external/keep:v0'], + }]); + await expect(dc.assertPlanFresh(plan, [])).resolves.toBeNull(); + await expect(dc.executePrunePlan(plan, [])).rejects.toBeInstanceOf(PrunePlanStaleError); + }); + + it('refuses image remove and does not call Docker delete when tags differ under a forced-fresh plan', async () => { + mockDocker.listImages.mockResolvedValue([ + { Id: 'img-multi', RepoTags: ['qa1769/app:v0'], Size: 50, Containers: 0 }, + ]); + const imageRemove = vi.fn().mockResolvedValue(undefined); + mockDocker.getImage.mockReturnValue({ remove: imageRemove }); + + const dc = DockerController.getInstance(1); + const plan = await dc.buildPrunePlan(['images'], 'all', [], 1); + mockDocker.listImages.mockResolvedValue([ + { Id: 'img-multi', RepoTags: ['qa1769/app:v0', 'qa1769-external/keep:v0'], Size: 50, Containers: 0 }, + ]); + vi.spyOn(dc, 'assertPlanFresh').mockResolvedValue(plan); + const result = await dc.executePrunePlan(plan, []); + expect(imageRemove).not.toHaveBeenCalled(); + expect(result.outcomes[0]).toEqual(expect.objectContaining({ + id: 'img-multi', + status: 'failed', + error: expect.stringMatching(/references changed/i), + })); + }); + + 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 }); + + 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), + })); + expect(String(result.outcomes[0] && 'error' in result.outcomes[0] ? result.outcomes[0].error : '')).toMatch(/qa1769\/app:v0/); + }); }); diff --git a/backend/src/helpers/managedImageAttribution.ts b/backend/src/helpers/managedImageAttribution.ts new file mode 100644 index 00000000..0c4d8fa1 --- /dev/null +++ b/backend/src/helpers/managedImageAttribution.ts @@ -0,0 +1,180 @@ +/** + * Shared managed-scope image attribution for prune plan, estimate, and + * pruneManagedOnly. Container-derived repository keys only (no compose spawn). + * + * Repo-key parsing mirrors parseImageRef (registry-api) without importing that + * module, so DockerController prune paths stay free of its HTTP/Cache deps. + */ + +export type ManagedImageReason = 'label' | 'becomes-free' | 'container-id' | 'repo-match'; + +export type ManagedImageClassification = + | { eligible: true; reason: ManagedImageReason; stackName?: string } + | { eligible: false }; + +export interface ManagedContainerForAttribution { + Image?: string; + ImageID?: string; + stack: string; +} + +export interface ListedImageForAttribution { + Id: string; + RepoTags?: string[] | null; +} + +/** + * Repository key for prune attribution: registry/repo without tag. + * Digests and empty refs return null. + * Aligns with parseImageRef host/library normalization so nginx:1 and + * docker.io/library/nginx compare equal. + */ +export function imageRepositoryKey(ref: string): string | null { + let s = ref.trim(); + if (!s || s === ':' || s.startsWith('sha256:')) return null; + + const atIdx = s.indexOf('@'); + if (atIdx !== -1) s = s.slice(0, atIdx); + + let registry = 'registry-1.docker.io'; + let rest = s; + + const slashIdx = s.indexOf('/'); + if (slashIdx !== -1) { + const firstPart = s.slice(0, slashIdx); + if (firstPart.includes('.') || firstPart.includes(':') || firstPart === 'localhost') { + registry = (firstPart === 'docker.io' || firstPart === 'index.docker.io') + ? 'registry-1.docker.io' + : firstPart; + rest = s.slice(slashIdx + 1); + } + } + + const colonIdx = rest.lastIndexOf(':'); + if (colonIdx > 0) { + rest = rest.slice(0, colonIdx); + } + + if (!rest) return null; + if (registry === 'registry-1.docker.io' && !rest.includes('/')) { + rest = `library/${rest}`; + } + return `${registry}/${rest}`; +} + +function usableTags(repoTags: string[] | null | undefined): string[] { + if (!repoTags) return []; + return repoTags.filter((tag) => Boolean(tag) && tag !== ':'); +} + +/** + * Repo keys currently used by managed containers, for matching free previous tags. + * Sources: (a) container.Image string; (b) RepoTags of listed image Id === ImageID. + */ +export function buildManagedImageRepoSet( + managedContainers: ManagedContainerForAttribution[], + images: ListedImageForAttribution[], +): { repoKeys: Set; repoToStack: Map } { + const byId = new Map(images.map((img) => [img.Id, img])); + const repoKeys = new Set(); + const repoToStack = new Map(); + const multiOwner = new Set(); + + const addKey = (key: string | null, stack: string) => { + if (!key) return; + repoKeys.add(key); + if (multiOwner.has(key)) return; + const existing = repoToStack.get(key); + if (!existing) repoToStack.set(key, stack); + else if (existing !== stack) { + repoToStack.delete(key); + multiOwner.add(key); + } + }; + + for (const c of managedContainers) { + addKey(imageRepositoryKey(c.Image ?? ''), c.stack); + if (!c.ImageID) continue; + const listed = byId.get(c.ImageID); + for (const tag of usableTags(listed?.RepoTags)) { + addKey(imageRepositoryKey(tag), c.stack); + } + } + + return { repoKeys, repoToStack }; +} + +function imageRepoKeys(repoTags: string[] | null | undefined): string[] { + return usableTags(repoTags) + .map((tag) => imageRepositoryKey(tag)) + .filter((key): key is string => key != null); +} + +/** + * Classify a free or becomes-free image under managed scope. + * Caller must already filter self, held, and non-free (except becomesFree path). + * + * resolveStack is resolveContainerStack-equivalent for the image's labels. + */ +export function classifyManagedImageCandidate(input: { + imageId: string; + labels: Record | undefined; + repoTags: string[] | null | undefined; + becomesFree: boolean; + managedImageIds: Set; + unmanagedImageIds: Set; + repoKeys: Set; + resolveStack: (labels: Record | undefined) => string | null; +}): ManagedImageClassification { + const { + imageId, + labels, + repoTags, + becomesFree, + managedImageIds, + unmanagedImageIds, + repoKeys, + resolveStack, + } = input; + + if (unmanagedImageIds.has(imageId)) return { eligible: false }; + + const projectPresent = Boolean(labels?.['com.docker.compose.project']); + const stackFromLabels = resolveStack(labels); + + // R1: present project label that does not resolve to a known stack: never managed. + if (projectPresent && !stackFromLabels) return { eligible: false }; + + if (stackFromLabels) { + return { eligible: true, reason: 'label', stackName: stackFromLabels }; + } + + if (becomesFree) { + return { eligible: true, reason: 'becomes-free' }; + } + + if (managedImageIds.has(imageId)) { + return { eligible: true, reason: 'container-id' }; + } + + for (const key of imageRepoKeys(repoTags)) { + if (repoKeys.has(key)) { + // No stackName: repository sharing is not ownership. Naming a stack here + // would surface as "· stack X" on the destructive confirm list for a free + // image that never belonged to that stack (coincidental Hub library overlap). + return { eligible: true, reason: 'repo-match' }; + } + } + + return { eligible: false }; +} + +export function managedImagePlanReason(reason: ManagedImageReason): string { + if (reason === 'becomes-free') { + return 'Image becomes unused after planned container removal'; + } + if (reason === 'repo-match') { + return 'Unused image whose repository is used by a Sencho stack'; + } + return 'Image is not used by any container'; +} diff --git a/backend/src/services/DockerController.ts b/backend/src/services/DockerController.ts index 919d1237..0776bcce 100644 --- a/backend/src/services/DockerController.ts +++ b/backend/src/services/DockerController.ts @@ -14,6 +14,7 @@ import { fingerprintPrunePlan, normalizePruneTargets, projectPruneOwnershipLabels, + pruneImageReferencesEqual, PRUNEABLE_CONTAINER_STATES, PrunePlanStaleError, type PruneItemOutcome, @@ -39,6 +40,12 @@ export type { PruneTarget, } from './prunePlan'; export { PrunePlanStaleError } from './prunePlan'; +import { + buildManagedImageRepoSet, + classifyManagedImageCandidate, + managedImagePlanReason, + type ManagedContainerForAttribution, +} from '../helpers/managedImageAttribution'; /** Parsed row from `docker compose ps --format json`. */ interface ComposePsContainer { @@ -772,24 +779,55 @@ class DockerController { const allContainers = await this.docker.listContainers({ all: true }); const resolvedBase = path.resolve(COMPOSE_DIR); const absDirToStack = DockerController.buildAbsDirMap(knownStackNames); + const resolveStack = (labels: Record | undefined) => { + const viaPath = DockerController.resolveContainerStack( + labels, projectToStack, knownSet, absDirToStack, resolvedBase, + ); + if (viaPath) return viaPath; + // Match resolveProjectLabel: stack directory names in knownSet resolve + // even when the project-name map cache was built for a different list. + return DockerController.resolveProjectLabel( + labels?.['com.docker.compose.project'], knownSet, projectToStack, + ); + }; 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, - ); + const managedContainers: ManagedContainerForAttribution[] = []; + for (const c of allContainers as Array<{ + Image?: string; + ImageID?: string; + Labels?: Record; + }>) { + const stack = resolveStack(c.Labels); if (!c.ImageID) continue; - if (stack) managedImageIds.add(c.ImageID); - else unmanagedImageIds.add(c.ImageID); + if (stack) { + managedImageIds.add(c.ImageID); + managedContainers.push({ Image: c.Image, ImageID: c.ImageID, stack }); + } else { + unmanagedImageIds.add(c.ImageID); + } } - const rawImages = await this.docker.listImages({ all: false }); - const prunable = (rawImages as any[]).filter((img: any) => { + const rawImages = await this.docker.listImages({ all: false }) as Array<{ + Id: string; + RepoTags?: string[] | null; + Labels?: Record; + Size?: number; + Containers?: number; + }>; + const { repoKeys } = buildManagedImageRepoSet(managedContainers, rawImages); + const prunable = rawImages.filter((img) => { 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); + if (isImageHeld?.(img.Id)) return false; + return classifyManagedImageCandidate({ + imageId: img.Id, + labels: img.Labels, + repoTags: img.RepoTags, + becomesFree: false, + managedImageIds, + unmanagedImageIds, + repoKeys, + resolveStack, + }).eligible; }); // df-before / df-after delta is the only honest measurement of bytes // actually freed. Per-image (Size - SharedSize) undercounts layers @@ -797,7 +835,6 @@ class DockerController { // once, but the per-image formula subtracts it from every referrer). const beforeDf = await this.safeDfSnapshot(); await Promise.all(prunable.map(async (img) => { - if (isImageHeld?.(img.Id)) return; try { await this.docker.getImage(img.Id).remove({ force: true }); } catch (e) { @@ -868,25 +905,54 @@ class DockerController { const allContainers = await this.docker.listContainers({ all: true }); const resolvedBase = path.resolve(COMPOSE_DIR); const absDirToStack = DockerController.buildAbsDirMap(knownStackNames); + const resolveStack = (labels: Record | undefined) => { + const viaPath = DockerController.resolveContainerStack( + labels, projectToStack, knownSet, absDirToStack, resolvedBase, + ); + if (viaPath) return viaPath; + // Match resolveProjectLabel: stack directory names in knownSet resolve + // even when the project-name map cache was built for a different list. + return DockerController.resolveProjectLabel( + labels?.['com.docker.compose.project'], knownSet, projectToStack, + ); + }; 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, - ); + const managedContainers: ManagedContainerForAttribution[] = []; + for (const c of allContainers as Array<{ + Image?: string; + ImageID?: string; + Labels?: Record; + }>) { + const stack = resolveStack(c.Labels); if (!c.ImageID) continue; - if (stack) managedImageIds.add(c.ImageID); - else unmanagedImageIds.add(c.ImageID); + if (stack) { + managedImageIds.add(c.ImageID); + managedContainers.push({ Image: c.Image, ImageID: c.ImageID, stack }); + } else { + unmanagedImageIds.add(c.ImageID); + } } - const rawImages = await this.docker.listImages({ all: false }); - const prunable = (rawImages as any[]).filter((img: any) => { + const rawImages = await this.docker.listImages({ all: false }) as Array<{ + Id: string; + RepoTags?: string[] | null; + Labels?: Record; + Size?: number; + Containers?: number; + }>; + const { repoKeys } = buildManagedImageRepoSet(managedContainers, rawImages); + const prunable = rawImages.filter((img) => { 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); + return classifyManagedImageCandidate({ + imageId: img.Id, + labels: img.Labels, + repoTags: img.RepoTags, + becomesFree: false, + managedImageIds, + unmanagedImageIds, + repoKeys, + resolveStack, + }).eligible; }); const sharedSizes = DockerController.mapSharedSizesFromDf(await this.safeDfSnapshot()); for (const img of prunable) { @@ -1065,19 +1131,31 @@ class DockerController { const managedImageIds = new Set(); const imageToStack = new Map(); const imageToContainerIds = new Map(); + const managedContainers: ManagedContainerForAttribution[] = []; + const resolveStack = (labels: Record | undefined) => { + const viaPath = DockerController.resolveContainerStack( + labels, projectToStack, knownSet, absDirToStack, resolvedBase, + ); + if (viaPath) return viaPath; + // Match resolveProjectLabel: stack directory names in knownSet resolve + // even when the project-name map cache was built for a different list. + return DockerController.resolveProjectLabel( + labels?.['com.docker.compose.project'], knownSet, projectToStack, + ); + }; 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, - ); + const stack = resolveStack(c.Labels); if (stack) { managedImageIds.add(c.ImageID); + managedContainers.push({ Image: c.Image, ImageID: c.ImageID, stack }); if (!imageToStack.has(c.ImageID)) imageToStack.set(c.ImageID, stack); + } else { + unmanagedImageIds.add(c.ImageID); } - else unmanagedImageIds.add(c.ImageID); } const plannedContainerIds = new Set( items.filter((i) => i.target === 'containers').map((i) => i.id), @@ -1092,6 +1170,7 @@ class DockerController { Containers?: number; Created?: number; }>; + const { repoKeys } = buildManagedImageRepoSet(managedContainers, rawImages); // 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'); @@ -1103,15 +1182,39 @@ class DockerController { && refs.length > 0 && refs.every((id) => plannedContainerIds.has(id)); if (refs.length > 0 && !becomesFree) continue; - const labeled = DockerController.resolveContainerStack( - img.Labels, projectToStack, knownSet, absDirToStack, resolvedBase, - ); - const stack = labeled ?? imageToStack.get(img.Id) ?? null; + let planReason = becomesFree + ? 'Image becomes unused after planned container removal' + : 'Image is not used by any container'; + let stackName: string | undefined = imageToStack.get(img.Id); + let managed = Boolean(stackName || managedImageIds.has(img.Id)); if (scope === 'managed') { - if (unmanagedImageIds.has(img.Id)) continue; - // Unattributed unused images (no managed container, no compose label) - // are not Sencho-managed; keep them out of managed prune. - if (!becomesFree && !stack && !managedImageIds.has(img.Id)) continue; + const classification = classifyManagedImageCandidate({ + imageId: img.Id, + labels: img.Labels, + repoTags: img.RepoTags, + becomesFree, + managedImageIds, + unmanagedImageIds, + repoKeys, + resolveStack, + }); + if (!classification.eligible) continue; + managed = true; + planReason = managedImagePlanReason(classification.reason); + // Repo-match deliberately omits stackName (repository sharing is not ownership). + // Label reasons supply classification.stackName; otherwise keep imageToStack when present. + if (classification.reason === 'repo-match') { + stackName = undefined; + } else { + stackName = classification.stackName ?? stackName; + } + } else { + // All-scope: foreign compose projects stay eligible; keep stack when known. + const labeled = resolveStack(img.Labels); + if (labeled) { + stackName = labeled; + managed = true; + } } const references = (img.RepoTags ?? []).filter((ref) => ref && ref !== ':'); const name = references[0] ?? ':'; @@ -1121,11 +1224,9 @@ class DockerController { id: img.Id, name, sizeBytes: unique > 0 ? unique : undefined, - managed: Boolean(stack || managedImageIds.has(img.Id)), - reason: becomesFree - ? 'Image becomes unused after planned container removal' - : 'Image is not used by any container', - stackName: stack ?? undefined, + managed, + reason: planReason, + stackName, image: { references, digest: img.RepoDigests?.find((digest) => Boolean(digest)), @@ -1457,25 +1558,80 @@ class DockerController { if (selfIdentity.isOwnImage(item.id)) { return { 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' }; + } + 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)); if (!img) { return { id: item.id, target: 'images', status: 'skipped', reason: 'Image no longer exists' }; } + // 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 { + 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; Labels?: Record; }>; - const references = allContainers.filter((container) => container.ImageID === img.Id + const containerRefs = allContainers.filter((container) => container.ImageID === img.Id || Boolean(container.ImageID?.startsWith(img.Id)) || img.Id.startsWith(container.ImageID ?? '')); - if (references.length > 0) { + if (containerRefs.length > 0) { return { id: item.id, target: 'images', status: 'skipped', reason: 'Image still has container references' }; } - await this.docker.getImage(item.id).remove({ force: false }); + 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; + } // 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 }; } diff --git a/backend/src/services/prunePlan.ts b/backend/src/services/prunePlan.ts index 585d04e5..5a95d640 100644 --- a/backend/src/services/prunePlan.ts +++ b/backend/src/services/prunePlan.ts @@ -50,12 +50,27 @@ export interface PrunePlan { targets: PruneTarget[]; items: PrunePlanItem[]; reclaimableBytes: number; - /** sha256 of sorted `target:id` pairs + scope + targets + nodeId. */ + /** + * sha256 of nodeId, scope, targets, and a sorted per-item identity key. + * Non-image items use `target:id`. Images also bind digest and the full + * sorted RepoTags set, so retagging an already-planned free image invalidates + * the dry-run authorization (fleet preflight must reject the whole execute). + */ fingerprint: string; createdAt: number; nodeId: number; } +/** Fields required to fingerprint one plan item (full PrunePlanItem is accepted). */ +export type FingerprintPlanItem = { + target: PruneTarget; + id: string; + image?: { + references: string[]; + digest?: string; + }; +}; + export type PruneItemOutcome = | { id: string; target: PruneTarget; status: 'removed'; sizeBytes?: number } | { id: string; target: PruneTarget; status: 'skipped'; reason: string } @@ -114,19 +129,53 @@ export function normalizePruneTargets(targets: PruneTarget[]): PruneTarget[] { return unique.sort((a, b) => (rank.get(a) ?? 99) - (rank.get(b) ?? 99)); } +/** + * Canonical identity line for one planned resource. Image lines include the + * full reviewed reference set so a tag add/remove on the same image Id is + * detectable as plan drift (not merely an Id match). + */ +export function fingerprintPlanItemKey(item: FingerprintPlanItem): string { + if (item.target === 'images') { + const refs = [...(item.image?.references ?? [])] + .filter((ref) => Boolean(ref) && ref !== ':') + .sort((a, b) => a.localeCompare(b)); + const digest = item.image?.digest ?? ''; + return `images:${item.id}\t${digest}\t${refs.join('\t')}`; + } + return `${item.target}:${item.id}`; +} + export function fingerprintPrunePlan( nodeId: number, scope: PruneScope, targets: PruneTarget[], - items: Pick[], + items: FingerprintPlanItem[], ): string { const lines = items - .map((item) => `${item.target}:${item.id}`) + .map((item) => fingerprintPlanItemKey(item)) .sort((a, b) => a.localeCompare(b)); const canonical = `${nodeId}|${scope}|${targets.join(',')}|${lines.join('\n')}`; return createHash('sha256').update(canonical).digest('hex'); } +/** Sorted live RepoTags comparable to a plan item's image.references. */ +export function normalizePruneImageReferences(refs: string[] | null | undefined): string[] { + if (!refs) return []; + return [...refs] + .filter((ref) => Boolean(ref) && ref !== ':') + .sort((a, b) => a.localeCompare(b)); +} + +export function pruneImageReferencesEqual( + planned: string[] | null | undefined, + live: string[] | null | undefined, +): boolean { + const a = normalizePruneImageReferences(planned); + const b = normalizePruneImageReferences(live); + if (a.length !== b.length) return false; + return a.every((ref, index) => ref === b[index]); +} + export class PrunePlanStaleError extends Error { readonly code = 'PRUNE_PLAN_STALE' as const; diff --git a/docs/features/fleet-actions.mdx b/docs/features/fleet-actions.mdx index 947ec9cf..0597bdf7 100644 --- a/docs/features/fleet-actions.mdx +++ b/docs/features/fleet-actions.mdx @@ -130,11 +130,13 @@ Reclaim disk space on every reachable node by deleting unused images, volumes, a The **Targets** checkboxes are independent and at least one must be ticked: **Images**, **Volumes**, **Networks**. The card defaults to Images alone, the cheapest and most common case. +The toolbar estimate updates as you change targets and scope. **Prune fleet** stays disabled until you complete a successful **Dry run** for the current targets, scope, and fleet roster (the card footer reminds you with "Run Dry run to unlock Prune fleet" while the gate is closed). + ### Managed only versus All unused Scope is a segmented control with two options: -- **Managed only** (default). Sencho looks up the stacks it knows about on the node, then prunes only resources owned by those stacks. Active containers and resources placed by other tools are untouched. +- **Managed only** (default). Sencho looks up the stacks it knows about on the node, then prunes only resources owned by those stacks. Active containers and resources placed by other tools are untouched. For images, that includes free previous tags that still share a repository with a stack container Sencho manages. - **All unused**. Sencho applies the target-specific Docker prune eligibility rules to each selected resource type. Any selected image, volume, or network not currently in use is deleted, including resources from workloads Sencho does not manage. The confirmation title flips to **Prune ALL unused resources across the fleet?**. ### Review the itemized dry run @@ -147,7 +149,7 @@ The node total is the sum of the sizes shown in that node's candidate rows. Imag ### Fingerprint-bound execution -**Prune fleet** remains disabled until the current targets, scope, and node roster have a valid reviewed plan for every reachable node. Execution sends one fingerprint per reviewed reachable node. Before deletion begins, the control instance rebuilds all plans, confirms that reviewed-unreachable nodes are still unreachable, and compares the complete configured-node roster. +**Prune fleet** remains disabled until the current targets, scope, and node roster have a valid reviewed plan for every reachable node. Execution sends one fingerprint per reviewed reachable node. Image fingerprints bind the image Id plus the full reviewed tag set (and digest when present), so retagging a planned free image after Dry run invalidates that node fingerprint and rejects the whole fleet execute before any node deletes. Before deletion begins, the control instance rebuilds all plans, confirms that reviewed-unreachable nodes are still unreachable, and compares the complete configured-node roster. If a node was added, removed, connected, disconnected, or changed candidates after the dry run, no node starts pruning. Run **Dry run** again to review the new state. Once fleet-wide preflight passes, each node revalidates immediately before deletion. A race at that point can produce a partial result, which is reported rather than hidden. diff --git a/docs/features/resources.mdx b/docs/features/resources.mdx index 599455df..a36b121a 100644 --- a/docs/features/resources.mdx +++ b/docs/features/resources.mdx @@ -29,9 +29,11 @@ The left card under the hero breaks down your Docker disk usage into three tiles | Tile | Meaning | |------|---------| -| **Sencho managed** (green) | Images and volumes used by stacks in your `COMPOSE_DIR` | +| **Sencho managed** (green) | Images and volumes **currently used** by stacks in your `COMPOSE_DIR` | | **External** (amber) | Images and volumes used by Docker projects outside Sencho | -| **Reclaimable** (neutral) | Unused images, stopped containers, and dangling volumes safe to delete | +| **Reclaimable** (neutral) | Unused images, stopped containers, and dangling volumes Docker can free | + +The managed tile does not count free (unused) images. Those rows appear under reclaimable space and in the Images tab as unused until you prune them. Each tile shows its size and its share of the total footprint as a percentage. The layout itself is fixed (Sencho managed on the left, External and Reclaimable stacked on the right); tile size does not scale with the percentage, so read the numbers rather than the proportions. Click any tile to filter the resource tabs below to that category. @@ -41,11 +43,13 @@ The right card under the hero exposes four prune actions. Each tile has a single | Action | What it removes | |--------|----------------| -| **Prune Unused Images** | Images with no running containers in Sencho stacks | +| **Prune Unused Images** | Free images owned by or still matching repositories used by your Sencho stacks | | **Prune Unused Volumes** | Volumes not attached to any Sencho container | | **Prune Dead Networks** | Networks not connected to any Sencho container | | **Purge Unmanaged Containers** | Containers Sencho doesn't recognize (started outside it) | +Managed image prune is broader than the green **Sencho managed** tile: after a stack update leaves an older tag unused, that free image can appear in the managed prune plan when a running stack container still uses the same image repository. Images labeled for Compose projects Sencho does not manage stay out of managed prune (use **All Docker** for a full host reclaim). Untagged dangling layers have no repository to match and usually require **All Docker** / all-scope prune. + 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 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. diff --git a/frontend/src/components/fleet/FleetActions/cards/FleetPruneCard.test.tsx b/frontend/src/components/fleet/FleetActions/cards/FleetPruneCard.test.tsx index ab4b2bd3..62820948 100644 --- a/frontend/src/components/fleet/FleetActions/cards/FleetPruneCard.test.tsx +++ b/frontend/src/components/fleet/FleetActions/cards/FleetPruneCard.test.tsx @@ -72,6 +72,23 @@ it('keeps destructive prune disabled until an itemized dry run is reviewed', asy render(); await waitFor(() => expect(screen.getByText('~ 4 KB reclaimable')).toBeInTheDocument()); expect(screen.getByRole('button', { name: 'Prune fleet' })).toBeDisabled(); + expect(screen.getByText(/Run Dry run to unlock Prune fleet/)).toBeInTheDocument(); +}); + +it('drops the unlock footer once dry run review is valid', async () => { + const user = userEvent.setup(); + mockedFetch.mockImplementation((url: string) => { + if (url === '/fleet/prune/estimate') return Promise.resolve(estimateResponse()); + if (url === '/fleet/labels/fleet-prune') return Promise.resolve(jsonResponse(200, { results: [planResult] })); + return Promise.resolve(jsonResponse(404, {})); + }); + + render(); + await waitFor(() => expect(screen.getByText(/Run Dry run to unlock Prune fleet/)).toBeInTheDocument()); + await user.click(screen.getByRole('button', { name: 'Dry run' })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Prune fleet' })).toBeEnabled()); + expect(screen.queryByText(/Run Dry run to unlock Prune fleet/)).not.toBeInTheDocument(); + expect(screen.getByText(/Reversible · no · reviewed across 1 node/)).toBeInTheDocument(); }); it('renders item metadata and enables prune after a valid dry run', async () => { diff --git a/frontend/src/components/fleet/FleetActions/cards/FleetPruneCard.tsx b/frontend/src/components/fleet/FleetActions/cards/FleetPruneCard.tsx index 24850b15..4ed94959 100644 --- a/frontend/src/components/fleet/FleetActions/cards/FleetPruneCard.tsx +++ b/frontend/src/components/fleet/FleetActions/cards/FleetPruneCard.tsx @@ -315,7 +315,11 @@ export function FleetPruneCard({ nodes }: Props) { variant: 'destructive', disabled: running || !reviewValid, }} - footerContext={`Reversible · no · reviewed across ${nodes.length} node${nodes.length === 1 ? '' : 's'}`} + footerContext={ + targets.size > 0 && !reviewValid + ? `Reversible · no · reviewed across ${nodes.length} node${nodes.length === 1 ? '' : 's'} · Run Dry run to unlock Prune fleet` + : `Reversible · no · reviewed across ${nodes.length} node${nodes.length === 1 ? '' : 's'}` + } >