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/);
});
});
@@ -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 === '<none>:<none>' || 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 !== '<none>:<none>');
}
/**
* 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<string>; repoToStack: Map<string, string> } {
const byId = new Map(images.map((img) => [img.Id, img]));
const repoKeys = new Set<string>();
const repoToStack = new Map<string, string>();
const multiOwner = new Set<string>();
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<string, string> | undefined;
repoTags: string[] | null | undefined;
becomesFree: boolean;
managedImageIds: Set<string>;
unmanagedImageIds: Set<string>;
repoKeys: Set<string>;
resolveStack: (labels: Record<string, string> | 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';
}
+204 -48
View File
@@ -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<string, string> | 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<string>();
const managedImageIds = new Set<string>();
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<string, string>;
}>) {
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<string, string>;
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<string, string> | 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<string>();
const managedImageIds = new Set<string>();
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<string, string>;
}>) {
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<string, string>;
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<string>();
const imageToStack = new Map<string, string>();
const imageToContainerIds = new Map<string, string[]>();
const managedContainers: ManagedContainerForAttribution[] = [];
const resolveStack = (labels: Record<string, string> | 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 !== '<none>:<none>');
const name = references[0] ?? '<none>:<none>';
@@ -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<string, string>;
}>;
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 };
}
+52 -3
View File
@@ -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 !== '<none>:<none>')
.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<PrunePlanItem, 'target' | 'id'>[],
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 !== '<none>:<none>')
.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;