mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-31 04:38:11 +00:00
feat: add Docker label audit across Fleet and Stack views (#1531)
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user