fix(resources): attribute free images to managed prune by repository family (#1769)

* fix(resources): attribute free images to managed prune by repository family

After a stack update, unused previous tags lost compose labels and dropped out of managed prune. Match free image repositories still used by managed containers (excluding foreign Compose projects), and clarify the fleet dry-run unlock on the prune card footer.

* fix(resources): omit stackName on repo-match managed prune items

Repository sharing is not ownership; the confirm list must not show stack attribution for repo-matched free images. Pin repository-key normalization with exact Set assertions so the duplicated parser cannot drift silently.

* fix(fleet): bind prune fingerprints to image tag sets

RepoTag churn on an already-planned image Id no longer leaves the dry-run fingerprint unchanged, so fleet preflight rejects the whole execute when any node retags. Itemized delete also refuses on reference drift and reports multi-repository refuse without implying a clean no-op.
This commit is contained in:
Anso
2026-08-04 16:38:37 -04:00
committed by GitHub
parent 011f084e24
commit a29d451875
10 changed files with 928 additions and 60 deletions
@@ -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 ────────────────────────────────────────────────
@@ -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('<none>:<none>')).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<string>(),
unmanagedImageIds: new Set<string>(),
repoKeys: new Set<string>(),
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' });
});
});
+299 -3
View File
@@ -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: ['<none>:<none>'], 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/);
});
});