feat(fleet): add fleet-wide Docker prune to Fleet Actions (#1104)

Adds a third card to the Fleet Actions tab that fans out Docker prune
(images, volumes, networks) across every node in one submit. Local nodes
call DockerController under a bulk-prune lock; remote nodes receive one
POST /api/system/prune/system per target. Per-node + per-target results
with reclaimed bytes are surfaced inline via ResultsList.

Tier: Skipper / Admiral (requirePaid + requireAdmin), matching the rest
of Fleet Actions. The frontend card is mounted inside the existing
isPaid branch at FleetActionsTab; no new frontend gate is required.

The card uses an amber accent rail and the Eraser icon so it reads as
'cleanup' rather than 'destructive stop'. Scope toggle defaults to
Managed only (Sencho-tagged resources) with an All unused option that
escalates the destructive-confirm copy.
This commit is contained in:
Anso
2026-05-19 00:13:32 -04:00
committed by GitHub
parent 9f22f73cfb
commit 1f673073ca
6 changed files with 645 additions and 9 deletions
+136
View File
@@ -1124,6 +1124,142 @@ fleetRouter.post('/labels/fleet-stop', authMiddleware, async (req: Request, res:
}
});
// Fleet-wide Docker prune. Fans out to every node, running per-target prune
// (images/volumes/networks) under the chosen scope. Local nodes call
// DockerController directly under a per-node bulk-prune lock; remote nodes
// receive one POST /api/system/prune/system per target via the standard
// Bearer-token path. Concurrent execution against the per-node prune route in
// systemMaintenance.ts is safe because Docker's prune API is internally
// serialized and idempotent (the worst case is a duplicate call returning 0
// reclaimed bytes).
// Tier: requirePaid + requireAdmin.
const FLEET_PRUNE_TARGETS = ['images', 'volumes', 'networks'] as const;
type FleetPruneTarget = (typeof FLEET_PRUNE_TARGETS)[number];
fleetRouter.post('/labels/fleet-prune', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePaid(req, res)) return;
if (!requireAdmin(req, res)) return;
const body = req.body as { targets?: unknown; scope?: unknown } | undefined;
if (!body || typeof body !== 'object') {
res.status(400).json({ error: 'Request body is required' });
return;
}
const rawTargets = Array.isArray(body.targets) ? body.targets : null;
if (!rawTargets || rawTargets.length === 0) {
res.status(400).json({ error: 'targets must be a non-empty array' });
return;
}
const dedup = new Set<FleetPruneTarget>();
for (const t of rawTargets) {
if (typeof t !== 'string' || !(FLEET_PRUNE_TARGETS as readonly string[]).includes(t)) {
res.status(400).json({ error: `Invalid target: ${typeof t === 'string' ? t : typeof t}` });
return;
}
dedup.add(t as FleetPruneTarget);
}
const targets: FleetPruneTarget[] = Array.from(dedup);
const scope: 'managed' | 'all' = body.scope === 'all' ? 'all' : 'managed';
type TargetResult = { target: FleetPruneTarget; success: boolean; reclaimedBytes: number; error?: string };
type NodeResult = {
nodeId: number; nodeName: string; reachable: boolean; error?: string; targets: TargetResult[];
};
try {
const db = DatabaseService.getInstance();
const nodes = db.getNodes();
const results: NodeResult[] = await Promise.all(nodes.map(async (node): Promise<NodeResult> => {
if (node.type === 'local') {
const lockKey = `bulk-prune:${node.id}`;
if (activeBulkActions.has(lockKey)) {
return {
nodeId: node.id, nodeName: node.name, reachable: true,
targets: targets.map(t => ({ target: t, success: false, reclaimedBytes: 0, error: 'A prune is already running on this node' })),
};
}
activeBulkActions.add(lockKey);
try {
const knownStacks = scope === 'managed' ? await FileSystemService.getInstance(node.id).getStacks() : [];
const dockerController = DockerController.getInstance(node.id);
const targetResults: TargetResult[] = [];
let anySuccess = false;
for (const target of targets) {
try {
const result = scope === 'managed'
? await dockerController.pruneManagedOnly(target, knownStacks)
: await dockerController.pruneSystem(target);
targetResults.push({ target, success: true, reclaimedBytes: result.reclaimedBytes });
if (result.reclaimedBytes > 0 || result.success) anySuccess = true;
} catch (err) {
targetResults.push({ target, success: false, reclaimedBytes: 0, error: getErrorMessage(err, 'Prune failed') });
}
}
if (anySuccess) invalidateNodeCaches(node.id);
return { nodeId: node.id, nodeName: node.name, reachable: true, targets: targetResults };
} finally {
activeBulkActions.delete(lockKey);
}
}
// Remote node: POST /api/system/prune/system per target, short-circuiting
// on the first transport-level failure so we don't hammer a dead node.
if (!node.api_url || !node.api_token) {
return {
nodeId: node.id, nodeName: node.name, reachable: false, error: 'Remote node not configured',
targets: targets.map(t => ({ target: t, success: false, reclaimedBytes: 0, error: 'Remote node not configured' })),
};
}
const baseUrl = node.api_url.replace(/\/$/, '');
const targetResults: TargetResult[] = [];
let nodeUnreachable: string | null = null;
for (const target of targets) {
if (nodeUnreachable) {
targetResults.push({ target, success: false, reclaimedBytes: 0, error: nodeUnreachable });
continue;
}
try {
const response = await fetch(`${baseUrl}/api/system/prune/system`, {
method: 'POST',
headers: { Authorization: `Bearer ${node.api_token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ target, scope }),
signal: AbortSignal.timeout(120000),
});
if (!response.ok) {
const errBody = (await response.json().catch(() => ({}))) as { error?: string };
const message = errBody.error || `Remote returned ${response.status}`;
nodeUnreachable = message;
targetResults.push({ target, success: false, reclaimedBytes: 0, error: message });
continue;
}
const remote = (await response.json().catch(() => null)) as { success?: boolean; reclaimedBytes?: number } | null;
if (!remote || typeof remote.reclaimedBytes !== 'number') {
targetResults.push({ target, success: false, reclaimedBytes: 0, error: 'Invalid response from remote node' });
continue;
}
targetResults.push({ target, success: remote.success !== false, reclaimedBytes: remote.reclaimedBytes });
} catch (err) {
const message = getErrorMessage(err, 'Failed to reach remote node');
nodeUnreachable = message;
targetResults.push({ target, success: false, reclaimedBytes: 0, error: message });
}
}
return {
nodeId: node.id, nodeName: node.name,
reachable: nodeUnreachable === null,
error: nodeUnreachable ?? undefined,
targets: targetResults,
};
}));
res.json({ results });
} catch (error) {
console.error('[Fleet] fleet-prune error:', error);
res.status(500).json({ error: getErrorMessage(error, 'Failed to run fleet prune') });
}
});
// ─── Fleet Snapshots (manual: Community; scheduled: Skipper+) ───
fleetRouter.post('/snapshots', authMiddleware, async (req: Request, res: Response): Promise<void> => {