feat: add Docker label audit across Fleet and Stack views (#1531)

This commit is contained in:
Anso
2026-07-03 18:26:09 -04:00
committed by GitHub
parent 10fb93dcb1
commit 4a350e7a0a
27 changed files with 3099 additions and 1 deletions
+165
View File
@@ -47,6 +47,8 @@ import { runLocalLabelAssign, validateLabelTemplate, validateRemoteAssignResults
import { MAX_ASSIGNMENTS } from '../helpers/constants';
import { buildLocalConfigurationStatus, type ConfigurationStatus } from './dashboard';
import { buildLocalGraph, mergeFleetGraph, isLocalDependencyGraph, type FleetNodeGraphResult } from '../services/DependencyGraphService';
import { buildNodeLabelInventory, VALID_LABEL_SOURCES, type NodeLabelInventory } from '../services/LabelInventoryService';
import { labelInventoryOptionsFromRequest, requireRevealAdmin } from '../helpers/labelInventoryRequest';
import { PROXY_TIER_HEADER } from '../services/license-headers';
import { LicenseService } from '../services/LicenseService';
@@ -727,6 +729,169 @@ fleetRouter.get('/dependency-map', authMiddleware, async (req: Request, res: Res
}
});
interface FleetNodeLabelInventoryResult {
nodeId: number;
nodeName: string;
status: 'ok' | 'error';
inventory: NodeLabelInventory | null;
error: string | null;
}
function isStringOrNull(v: unknown): boolean {
return typeof v === 'string' || v === null;
}
function isLabelIndexContainerRef(v: unknown): boolean {
if (!v || typeof v !== 'object') return false;
const o = v as Record<string, unknown>;
return typeof o.id === 'string'
&& typeof o.name === 'string'
&& isStringOrNull(o.stack)
&& isStringOrNull(o.service);
}
function isLabelValue(v: unknown): boolean {
if (!v || typeof v !== 'object') return false;
const o = v as Record<string, unknown>;
return typeof o.key === 'string'
&& typeof o.value === 'string'
&& typeof o.source === 'string'
&& VALID_LABEL_SOURCES.has(o.source);
}
function isContainerLabelRow(v: unknown): boolean {
if (!v || typeof v !== 'object') return false;
const o = v as Record<string, unknown>;
return typeof o.id === 'string'
&& typeof o.name === 'string'
&& typeof o.state === 'string'
&& isStringOrNull(o.stack)
&& isStringOrNull(o.service)
&& Array.isArray(o.labels)
&& o.labels.every(isLabelValue);
}
function isLabelIndexRow(v: unknown): boolean {
if (!v || typeof v !== 'object') return false;
const o = v as Record<string, unknown>;
return typeof o.key === 'string'
&& typeof o.value === 'string'
&& typeof o.source === 'string'
&& VALID_LABEL_SOURCES.has(o.source)
&& Array.isArray(o.containers)
&& o.containers.every(isLabelIndexContainerRef);
}
/**
* Validate a remote node's label-inventory payload deeply enough that neither the
* aggregation sort nor the Fleet UI ever receives a malformed row. A single bad
* `byLabel` or `containers` element would otherwise crash the whole fleet request (or
* the client) rather than degrading that node into `nodeErrors`. Only wire fields are
* checked; the internal `imageId` is not part of the shape sent over the wire.
*/
function isNodeLabelInventory(v: unknown): v is NodeLabelInventory {
if (!v || typeof v !== 'object') return false;
const o = v as Record<string, unknown>;
return typeof o.nodeId === 'number'
&& Array.isArray(o.containers)
&& o.containers.every(isContainerLabelRow)
&& Array.isArray(o.byLabel)
&& o.byLabel.every(isLabelIndexRow);
}
/**
* Fleet-wide Docker label inventory. Auth + node:read (Community). Fans out to
* each node's /api/system/container-labels; unreachable nodes degrade gracefully.
*/
fleetRouter.get('/container-labels', authMiddleware, async (req: Request, res: Response): Promise<void> => {
if (!requirePermission(req, res, 'node:read')) return;
if (!requireRevealAdmin(req, res)) return;
const options = labelInventoryOptionsFromRequest(req);
try {
const db = DatabaseService.getInstance();
const nodes = db.getNodes();
const results = await Promise.allSettled(
nodes.map(async (node: Node): Promise<FleetNodeLabelInventoryResult> => {
if (node.type === 'local') {
const inventory = await buildNodeLabelInventory(node.id, options);
return { nodeId: node.id, nodeName: node.name, status: 'ok', inventory, error: null };
}
const target = NodeRegistry.getInstance().getProxyTarget(node.id);
if (!target) {
return { nodeId: node.id, nodeName: node.name, status: 'error', inventory: null, error: formatNoTargetError(node) };
}
const revealQs = options.revealSecrets ? '?reveal=1' : '';
const resp = await fetch(
`${target.apiUrl.replace(/\/$/, '')}/api/system/container-labels${revealQs}`,
{
headers: { ...(target.apiToken ? { Authorization: `Bearer ${target.apiToken}` } : {}) },
signal: AbortSignal.timeout(30000),
},
);
if (!resp.ok) {
const errBody = await resp.json().catch(() => null) as { error?: string } | null;
return { nodeId: node.id, nodeName: node.name, status: 'error', inventory: null, error: errBody?.error ?? `Remote returned ${resp.status}` };
}
const inventory = await resp.json().catch(() => null);
if (!isNodeLabelInventory(inventory)) {
return { nodeId: node.id, nodeName: node.name, status: 'error', inventory: null, error: 'Remote returned an unexpected label-inventory payload' };
}
return { nodeId: node.id, nodeName: node.name, status: 'ok', inventory, error: null };
}),
);
const perNode: FleetNodeLabelInventoryResult[] = results.map((result, i) => {
if (result.status === 'fulfilled') return result.value;
console.error(`[Fleet] Container labels fetch failed for node ${nodes[i].name}:`, result.reason);
return { nodeId: nodes[i].id, nodeName: nodes[i].name, status: 'error', inventory: null, error: getErrorMessage(result.reason, 'Failed to reach node') };
});
const aggregatedByLabel = new Map<string, import('../services/LabelInventoryService').LabelIndexRow>();
for (const nodeResult of perNode) {
if (nodeResult.status !== 'ok' || !nodeResult.inventory) continue;
for (const row of nodeResult.inventory.byLabel) {
const key = `${row.key}\0${row.value}\0${row.source}`;
const existing = aggregatedByLabel.get(key);
if (!existing) {
aggregatedByLabel.set(key, {
...row,
containers: row.containers.map(c => ({
...c,
nodeId: nodeResult.nodeId,
nodeName: nodeResult.nodeName,
})),
});
} else {
existing.containers.push(...row.containers.map(c => ({
...c,
nodeId: nodeResult.nodeId,
nodeName: nodeResult.nodeName,
})));
}
}
}
const nodeErrors: Record<number, string> = {};
for (const n of perNode) {
if (n.status === 'error' && n.error) nodeErrors[n.nodeId] = n.error;
}
res.json({
nodes: perNode,
aggregatedByLabel: [...aggregatedByLabel.values()].sort((a, b) =>
a.key.localeCompare(b.key) || a.value.localeCompare(b.value) || a.source.localeCompare(b.source)),
nodeErrors,
generatedAt: Date.now(),
});
} catch (error) {
console.error('[Fleet] Container labels error:', error);
res.status(500).json({ error: 'Failed to build fleet container label inventory' });
}
});
interface FleetNetworkingSummaryNode {
nodeId: number;
nodeName: string;