feat(fleet): show itemized prune plans (#1734)

* feat(fleet): itemize prune review plans

Build and display fingerprint-bound prune candidates for every reviewed
fleet node. Preflight all node plans before mutation and preserve detailed
removed, skipped, failed, and partial outcomes.

Add safe resource metadata projection, managed ownership attribution,
runtime contract validation, transport parity coverage, and operator docs.

Closes #1724

* fix(security): harden stack path lookup

Use a Map for Compose working-directory ownership resolution so untrusted
path strings cannot become object property writes.

* fix(fleet): harden prune execution safeguards
This commit is contained in:
Anso
2026-07-29 14:30:18 -04:00
committed by GitHub
parent 538a41b771
commit 44d6078241
21 changed files with 2582 additions and 869 deletions
+115 -81
View File
@@ -13,6 +13,7 @@ import SelfIdentityService from './SelfIdentityService';
import {
fingerprintPrunePlan,
normalizePruneTargets,
projectPruneOwnershipLabels,
PRUNEABLE_CONTAINER_STATES,
PrunePlanStaleError,
type PruneItemOutcome,
@@ -921,17 +922,18 @@ class DockerController {
if (selfIdentity.isOwnContainer(c.Id)) continue;
const state = String(c.State ?? '').toLowerCase();
if (!PRUNEABLE_CONTAINER_STATES.has(state)) continue;
if (scope === 'managed') {
const stack = DockerController.resolveContainerStack(
c.Labels, projectToStack, knownSet, absDirToStack, resolvedBase,
);
if (!stack) continue;
}
const stack = DockerController.resolveContainerStack(
c.Labels, projectToStack, knownSet, absDirToStack, resolvedBase,
);
if (scope === 'managed' && !stack) continue;
items.push({
target: 'containers',
id: c.Id,
name: containerName(c),
sizeBytes: typeof c.SizeRw === 'number' && c.SizeRw > 0 ? c.SizeRw : undefined,
managed: Boolean(stack),
reason: `Container is ${state} and no longer running`,
stackName: stack ?? undefined,
});
}
}
@@ -946,23 +948,29 @@ class DockerController {
const rawVolumeData = await this.docker.listVolumes();
const rawVolumes = (this.validateApiData<{ Volumes?: Array<{
Name: string;
Driver?: string;
Labels?: Record<string, string>;
}> }>(rawVolumeData)).Volumes || [];
for (const vol of rawVolumes) {
if (selfIdentity.isOwnVolume(vol.Name)) continue;
const usage = volumeUsage.get(vol.Name);
if (!usage || usage.refCount !== 0) continue;
if (scope === 'managed') {
const stack = DockerController.resolveProjectLabel(
vol.Labels?.['com.docker.compose.project'], knownSet, projectToStack,
);
if (!stack) continue;
}
const stack = DockerController.resolveContainerStack(
vol.Labels, projectToStack, knownSet, absDirToStack, resolvedBase,
);
if (scope === 'managed' && !stack) continue;
items.push({
target: 'volumes',
id: vol.Name,
name: vol.Name,
sizeBytes: usage.size > 0 ? usage.size : undefined,
managed: Boolean(stack),
reason: 'Volume is not referenced by any container',
stackName: stack ?? undefined,
volume: {
driver: vol.Driver,
ownershipLabels: projectPruneOwnershipLabels(vol.Labels),
},
});
}
}
@@ -971,6 +979,8 @@ class DockerController {
const rawNetworks = await this.docker.listNetworks() as Array<{
Id: string;
Name: string;
Driver?: string;
Scope?: string;
Labels?: Record<string, string>;
}>;
const networksInUse = new Set<string>();
@@ -998,19 +1008,30 @@ class DockerController {
);
continue;
}
if (scope === 'managed') {
const stack = DockerController.resolveProjectLabel(
net.Labels?.['com.docker.compose.project'], knownSet, projectToStack,
);
if (!stack) continue;
}
items.push({ target: 'networks', id: net.Id, name: net.Name });
const stack = DockerController.resolveContainerStack(
net.Labels, projectToStack, knownSet, absDirToStack, resolvedBase,
);
if (scope === 'managed' && !stack) continue;
items.push({
target: 'networks',
id: net.Id,
name: net.Name,
managed: Boolean(stack),
reason: 'Network has no attached containers',
stackName: stack ?? undefined,
network: {
driver: net.Driver,
scope: net.Scope,
ownershipLabels: projectPruneOwnershipLabels(net.Labels),
},
});
}
}
if (ordered.includes('images')) {
const unmanagedImageIds = new Set<string>();
const managedImageIds = new Set<string>();
const imageToStack = new Map<string, string>();
const imageToContainerIds = new Map<string, string[]>();
for (const c of allContainers) {
if (!c.ImageID) continue;
@@ -1020,7 +1041,10 @@ class DockerController {
const stack = DockerController.resolveContainerStack(
c.Labels, projectToStack, knownSet, absDirToStack, resolvedBase,
);
if (stack) managedImageIds.add(c.ImageID);
if (stack) {
managedImageIds.add(c.ImageID);
if (!imageToStack.has(c.ImageID)) imageToStack.set(c.ImageID, stack);
}
else unmanagedImageIds.add(c.ImageID);
}
const plannedContainerIds = new Set(
@@ -1029,10 +1053,12 @@ class DockerController {
const rawImages = await this.docker.listImages({ all: false }) as Array<{
Id: string;
RepoTags?: string[] | null;
RepoDigests?: string[] | null;
Labels?: Record<string, string>;
Size?: number;
VirtualSize?: number;
Containers?: number;
Created?: number;
}>;
// An image becomes free only when every container that references it is
// also in this plan (not merely when any planned container uses it).
@@ -1040,31 +1066,39 @@ class DockerController {
for (const img of rawImages) {
if (selfIdentity.isOwnImage(img.Id)) continue;
if (isImageHeld?.(img.Id)) continue;
const containers = img.Containers ?? 0;
const refs = imageToContainerIds.get(img.Id) ?? [];
const becomesFree = freeingImages
&& refs.length > 0
&& refs.length >= containers
&& refs.every((id) => plannedContainerIds.has(id));
if (containers > 0 && !becomesFree) continue;
if (refs.length > 0 && !becomesFree) continue;
const labeled = DockerController.resolveContainerStack(
img.Labels, projectToStack, knownSet, absDirToStack, resolvedBase,
);
const stack = labeled ?? imageToStack.get(img.Id) ?? null;
if (scope === 'managed') {
if (unmanagedImageIds.has(img.Id)) continue;
const labeled = DockerController.resolveProjectLabel(
img.Labels?.['com.docker.compose.project'], knownSet, projectToStack,
);
// Unattributed unused images (no managed container, no compose label)
// are not Sencho-managed; keep them out of managed prune.
if (!becomesFree && !labeled && !managedImageIds.has(img.Id)) continue;
if (!becomesFree && !stack && !managedImageIds.has(img.Id)) continue;
}
const name = img.RepoTags?.[0] && img.RepoTags[0] !== '<none>:<none>'
? img.RepoTags[0]
: img.Id.slice(0, 12);
const references = (img.RepoTags ?? []).filter((ref) => ref && ref !== '<none>:<none>');
const name = references[0] ?? '<none>:<none>';
const unique = DockerController.imageUniqueBytes(img, sharedSizes);
items.push({
target: 'images',
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,
image: {
references,
digest: img.RepoDigests?.find((digest) => Boolean(digest)),
createdAt: typeof img.Created === 'number' ? img.Created : undefined,
},
});
}
}
@@ -1093,7 +1127,8 @@ class DockerController {
/**
* Rebuild the plan with the same targets/scope. Returns the fresh plan when
* the fingerprint still matches, otherwise null (caller maps to 409).
* the fingerprint still matches, otherwise null so the caller can report
* staleness through its route-specific response contract.
*/
public async assertPlanFresh(
plan: PrunePlan,
@@ -1173,14 +1208,18 @@ class DockerController {
}
if (target === 'volumes') {
const outcome = await this.executePlannedVolume(item, fresh.scope, knownSet, projectToStack, selfIdentity);
const outcome = await this.executePlannedVolume(
item, fresh.scope, knownSet, projectToStack, absDirToStack, resolvedBase, selfIdentity,
);
outcomes.push(outcome);
if (outcome.status === 'removed') reclaimedBytes += outcome.sizeBytes ?? item.sizeBytes ?? 0;
continue;
}
if (target === 'networks') {
outcomes.push(await this.executePlannedNetwork(item, fresh.scope, knownSet, projectToStack, selfIdentity));
outcomes.push(await this.executePlannedNetwork(
item, fresh.scope, knownSet, projectToStack, absDirToStack, resolvedBase, selfIdentity,
));
continue;
}
@@ -1207,7 +1246,7 @@ class DockerController {
}
}
const outcome = await this.executePlannedImage(
item, fresh.scope, knownSet, projectToStack, absDirToStack, resolvedBase, selfIdentity,
item, selfIdentity,
);
outcomes.push(outcome);
if (outcome.status === 'removed') reclaimedBytes += outcome.sizeBytes ?? item.sizeBytes ?? 0;
@@ -1238,9 +1277,10 @@ class DockerController {
private async imageStillReferenced(imageId: string): Promise<boolean> {
try {
const images = await this.docker.listImages({ all: false }) as Array<{ Id: string; Containers?: number }>;
const match = images.find((img) => img.Id === imageId || img.Id.startsWith(imageId) || imageId.startsWith(img.Id));
return (match?.Containers ?? 0) > 0;
const containers = await this.docker.listContainers({ all: true }) as Array<{ ImageID?: string }>;
return containers.some((container) => container.ImageID === imageId
|| Boolean(container.ImageID?.startsWith(imageId))
|| imageId.startsWith(container.ImageID ?? ''));
} catch {
return true;
}
@@ -1250,8 +1290,8 @@ class DockerController {
item: PrunePlanItem,
scope: PruneScope,
knownSet: Set<string>,
projectToStack: Record<string, string>,
absDirToStack: Record<string, string>,
projectToStack: Map<string, string>,
absDirToStack: Map<string, string>,
resolvedBase: string,
selfIdentity: SelfIdentityService,
): Promise<
@@ -1305,7 +1345,9 @@ class DockerController {
item: PrunePlanItem,
scope: PruneScope,
knownSet: Set<string>,
projectToStack: Record<string, string>,
projectToStack: Map<string, string>,
absDirToStack: Map<string, string>,
resolvedBase: string,
selfIdentity: SelfIdentityService,
): Promise<PruneItemOutcome> {
if (selfIdentity.isOwnVolume(item.id)) {
@@ -1325,8 +1367,8 @@ class DockerController {
return { id: item.id, target: 'volumes', status: 'skipped', reason: 'Volume is in use' };
}
if (scope === 'managed') {
const stack = DockerController.resolveProjectLabel(
vol.Labels?.['com.docker.compose.project'], knownSet, projectToStack,
const stack = DockerController.resolveContainerStack(
vol.Labels, projectToStack, knownSet, absDirToStack, resolvedBase,
);
if (!stack) {
return { id: item.id, target: 'volumes', status: 'skipped', reason: 'No longer a managed volume' };
@@ -1340,7 +1382,9 @@ class DockerController {
item: PrunePlanItem,
scope: PruneScope,
knownSet: Set<string>,
projectToStack: Record<string, string>,
projectToStack: Map<string, string>,
absDirToStack: Map<string, string>,
resolvedBase: string,
selfIdentity: SelfIdentityService,
): Promise<PruneItemOutcome> {
if (selfIdentity.isOwnNetwork(item.id)) {
@@ -1363,8 +1407,8 @@ class DockerController {
return { id: item.id, target: 'networks', status: 'skipped', reason: 'Network is in use' };
}
if (scope === 'managed') {
const stack = DockerController.resolveProjectLabel(
inspected.Labels?.['com.docker.compose.project'], knownSet, projectToStack,
const stack = DockerController.resolveContainerStack(
inspected.Labels, projectToStack, knownSet, absDirToStack, resolvedBase,
);
if (!stack) {
return { id: item.id, target: 'networks', status: 'skipped', reason: 'No longer a managed network' };
@@ -1376,11 +1420,6 @@ class DockerController {
private async executePlannedImage(
item: PrunePlanItem,
scope: PruneScope,
knownSet: Set<string>,
projectToStack: Record<string, string>,
absDirToStack: Record<string, string>,
resolvedBase: string,
selfIdentity: SelfIdentityService,
): Promise<PruneItemOutcome> {
if (selfIdentity.isOwnImage(item.id)) {
@@ -1389,30 +1428,21 @@ class DockerController {
const rawImages = await this.docker.listImages({ all: false }) as Array<{
Id: string;
Size?: number;
Containers?: number;
}>;
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' };
}
if ((img.Containers ?? 0) > 0) {
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
|| Boolean(container.ImageID?.startsWith(img.Id))
|| img.Id.startsWith(container.ImageID ?? ''));
if (references.length > 0) {
return { id: item.id, target: 'images', status: 'skipped', reason: 'Image still has container references' };
}
if (scope === 'managed') {
const allContainers = await this.docker.listContainers({ all: true }) as Array<{
ImageID?: string;
Labels?: Record<string, string>;
}>;
for (const c of allContainers) {
if (c.ImageID !== img.Id) continue;
const stack = DockerController.resolveContainerStack(
c.Labels, projectToStack, knownSet, absDirToStack, resolvedBase,
);
if (!stack) {
return { id: item.id, target: 'images', status: 'skipped', reason: 'Image referenced by unmanaged container' };
}
}
}
await this.docker.getImage(item.id).remove({ force: false });
// 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 };
@@ -1925,19 +1955,20 @@ class DockerController {
private static resolveProjectLabel(
project: string | undefined,
knownSet: Set<string>,
projectToStack: Record<string, string>,
projectToStack: Map<string, string>,
): string | null {
if (!project) return null;
if (knownSet.has(project)) return project;
if (projectToStack[project]) return projectToStack[project];
return null;
return projectToStack.get(project) ?? null;
}
/** Builds a map from absolute stack directory paths to stack names. */
private static buildAbsDirMap(stackNames: string[]): Record<string, string> {
const map: Record<string, string> = {};
private static buildAbsDirMap(stackNames: string[]): Map<string, string> {
const map = new Map<string, string>();
for (const stackDir of stackNames) {
map[path.join(COMPOSE_DIR, stackDir)] = stackDir;
const stackPath = path.join(COMPOSE_DIR, stackDir);
map.set(stackPath, stackDir);
map.set(path.resolve(stackPath), stackDir);
}
return map;
}
@@ -1948,21 +1979,24 @@ class DockerController {
*/
private static resolveContainerStack(
containerLabels: Record<string, string> | undefined,
projectToStack: Record<string, string>,
projectToStack: Map<string, string>,
knownStackSet: Set<string>,
absDirToStack: Record<string, string>,
absDirToStack: Map<string, string>,
resolvedBase: string,
): string | null {
if (!containerLabels) return null;
// Primary: match by project name (handles name: overrides and standard directory-based names)
const project = containerLabels['com.docker.compose.project'];
if (project && projectToStack[project]) return projectToStack[project];
if (project) {
const stack = projectToStack.get(project);
if (stack) return stack;
}
// Fallback 1: match by working_dir
const workingDir = containerLabels['com.docker.compose.project.working_dir'];
if (workingDir) {
const match = absDirToStack[workingDir] ?? absDirToStack[path.resolve(workingDir)];
const match = absDirToStack.get(workingDir) ?? absDirToStack.get(path.resolve(workingDir));
if (match) return match;
}
@@ -1989,15 +2023,15 @@ class DockerController {
* Builds (or returns cached) mapping from Docker project name to Sencho stack directory name.
* Compose files with a top-level `name:` field override the default project name.
*/
private static async resolveProjectNameMap(stackNames: string[]): Promise<Record<string, string>> {
private static async resolveProjectNameMap(stackNames: string[]): Promise<Map<string, string>> {
return CacheService.getInstance().getOrFetch(
PROJECT_NAME_CACHE_KEY,
PROJECT_NAME_CACHE_TTL_MS,
async () => {
const map: Record<string, string> = {};
const map = new Map<string, string>();
await Promise.all(stackNames.map(async (stackDir) => {
map[stackDir] = stackDir;
map.set(stackDir, stackDir);
for (const fileName of COMPOSE_FILE_NAMES) {
const filePath = path.join(COMPOSE_DIR, stackDir, fileName);
@@ -2005,7 +2039,7 @@ class DockerController {
const content = await fs.readFile(filePath, 'utf-8');
const parsed = yaml.parse(content);
if (parsed?.name && typeof parsed.name === 'string') {
map[parsed.name] = stackDir;
map.set(parsed.name, stackDir);
}
break;
} catch (err: unknown) {
+66 -2
View File
@@ -3,13 +3,47 @@ import { createHash } from 'crypto';
export type PruneTarget = 'images' | 'volumes' | 'networks' | 'containers';
export type PruneScope = 'managed' | 'all';
export interface PrunePlanItem {
target: PruneTarget;
interface PrunePlanItemBase {
id: string;
name: string;
sizeBytes?: number;
managed: boolean;
reason: string;
stackName?: string;
}
export type PrunePlanItem =
| (PrunePlanItemBase & { target: 'containers'; image?: never; volume?: never; network?: never })
| (PrunePlanItemBase & {
target: 'images';
image: {
references: string[];
digest?: string;
createdAt?: number;
};
volume?: never;
network?: never;
})
| (PrunePlanItemBase & {
target: 'volumes';
volume: {
driver?: string;
ownershipLabels?: Record<string, string>;
};
image?: never;
network?: never;
})
| (PrunePlanItemBase & {
target: 'networks';
network: {
driver?: string;
scope?: string;
ownershipLabels?: Record<string, string>;
};
image?: never;
volume?: never;
});
export interface PrunePlan {
scope: PruneScope;
/** Ordered execution sequence (dependency-safe when multi-target). */
@@ -34,6 +68,36 @@ export const PRUNE_EXECUTION_ORDER: readonly PruneTarget[] = ['volumes', 'contai
export const PRUNEABLE_CONTAINER_STATES = new Set(['created', 'exited', 'dead']);
const COMPOSE_OWNERSHIP_LABEL_KEYS = new Set([
'com.docker.compose.project',
'com.docker.compose.project.working_dir',
'com.docker.compose.project.config_files',
'com.docker.compose.volume',
'com.docker.compose.network',
'com.docker.compose.service',
]);
/**
* Disclosure allowlist for ownership evidence returned to API clients.
* Do not broaden it without reviewing Docker label values for sensitive data.
*/
export function projectPruneOwnershipLabels(value: unknown): Record<string, string> | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
const projected = Object.entries(value).filter(
(entry): entry is [string, string] => COMPOSE_OWNERSHIP_LABEL_KEYS.has(entry[0])
&& typeof entry[1] === 'string' && entry[1].length > 0,
);
return projected.length > 0 ? Object.fromEntries(projected) : undefined;
}
export function hasOnlyPruneOwnershipLabels(value: unknown): boolean {
if (value === undefined) return true;
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
return Object.entries(value).every(
([key, label]) => COMPOSE_OWNERSHIP_LABEL_KEYS.has(key) && typeof label === 'string' && label.length > 0,
);
}
export function isPruneTarget(value: unknown): value is PruneTarget {
return typeof value === 'string' && (PRUNE_TARGETS as readonly string[]).includes(value);
}