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
+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 };
}