mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 12:17:34 +00:00
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:
@@ -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';
|
||||
}
|
||||
Reference in New Issue
Block a user