mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-17 22:17:50 +00:00
feat: add Docker label audit across Fleet and Stack views (#1531)
This commit is contained in:
@@ -978,6 +978,59 @@ describe('DockerController - inspectImage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// --- label / image inspection for the label inventory --------------------------
|
||||
|
||||
describe('DockerController - inspectImageLabels', () => {
|
||||
it('returns the image label map', async () => {
|
||||
mockDocker.getImage.mockReturnValue({
|
||||
inspect: vi.fn().mockResolvedValue({ Config: { Labels: { 'org.opencontainers.image.title': 'Plex' } } }),
|
||||
});
|
||||
const dc = DockerController.getInstance(1);
|
||||
const result = await dc.inspectImageLabels('sha256:img');
|
||||
expect(result).toEqual({ labels: { 'org.opencontainers.image.title': 'Plex' } });
|
||||
expect(mockDocker.getImage).toHaveBeenCalledWith('sha256:img');
|
||||
});
|
||||
|
||||
it('returns null (not a silent empty map) and logs when the image inspect fails', async () => {
|
||||
mockDocker.getImage.mockReturnValue({
|
||||
inspect: vi.fn().mockRejectedValue(new Error('No such image')),
|
||||
});
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const dc = DockerController.getInstance(1);
|
||||
expect(await dc.inspectImageLabels('missing')).toBeNull();
|
||||
expect(errSpy).toHaveBeenCalled();
|
||||
errSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('short-circuits an empty image id without inspecting', async () => {
|
||||
const dc = DockerController.getInstance(1);
|
||||
expect(await dc.inspectImageLabels('')).toBeNull();
|
||||
expect(mockDocker.getImage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DockerController - inspectContainerLabelsAndImage', () => {
|
||||
it('returns labels and the image ref from container inspect', async () => {
|
||||
mockDocker.getContainer.mockReturnValue({
|
||||
inspect: vi.fn().mockResolvedValue({ Config: { Labels: { 'traefik.enable': 'true' } }, Image: 'sha256:imgref' }),
|
||||
});
|
||||
const dc = DockerController.getInstance(1);
|
||||
const result = await dc.inspectContainerLabelsAndImage('c1');
|
||||
expect(result).toEqual({ labels: { 'traefik.enable': 'true' }, imageId: 'sha256:imgref' });
|
||||
});
|
||||
|
||||
it('returns null and logs when the container inspect fails', async () => {
|
||||
mockDocker.getContainer.mockReturnValue({
|
||||
inspect: vi.fn().mockRejectedValue(new Error('no such container')),
|
||||
});
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const dc = DockerController.getInstance(1);
|
||||
expect(await dc.inspectContainerLabelsAndImage('gone')).toBeNull();
|
||||
expect(errSpy).toHaveBeenCalled();
|
||||
errSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// --- createNetwork validation --------------------------------------------------
|
||||
|
||||
describe('createNetwork', () => {
|
||||
|
||||
@@ -21,6 +21,7 @@ const NODE_READ_ROUTES = [
|
||||
'/api/fleet/dependency-map',
|
||||
'/api/fleet/networking-summary',
|
||||
'/api/fleet/update-status',
|
||||
'/api/fleet/container-labels',
|
||||
];
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -61,4 +62,20 @@ describe('fleet topology reads require node:read', () => {
|
||||
const res = await request(app).get('/api/fleet/overview');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('denies ?reveal=1 on container-labels for a non-admin (viewer)', async () => {
|
||||
const res = await request(app).get('/api/fleet/container-labels?reveal=1').set('Authorization', `Bearer ${viewerToken}`);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('denies the node-local /api/system/container-labels for a role without node:read (deployer)', async () => {
|
||||
const res = await request(app).get('/api/system/container-labels').set('Authorization', `Bearer ${deployerToken}`);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PERMISSION_DENIED');
|
||||
});
|
||||
|
||||
it('denies ?reveal=1 on the node-local container-labels for a non-admin (viewer)', async () => {
|
||||
const res = await request(app).get('/api/system/container-labels?reveal=1').set('Authorization', `Bearer ${viewerToken}`);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -909,7 +909,9 @@ describe('GitSourceService.createStackFromGit', () => {
|
||||
sha,
|
||||
});
|
||||
const svc = GitSourceService.getInstance();
|
||||
const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true });
|
||||
|
||||
try {
|
||||
const result = await svc.createStackFromGit({
|
||||
stackName: 'create-happy',
|
||||
repoUrl: 'https://github.com/example/repo.git',
|
||||
@@ -934,6 +936,9 @@ describe('GitSourceService.createStackFromGit', () => {
|
||||
expect(onDisk).toContain('image: nginx');
|
||||
|
||||
await cleanupStackDir('create-happy');
|
||||
} finally {
|
||||
validateSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('multi-file create then pull reports no local changes (hash is path-independent)', async () => {
|
||||
|
||||
@@ -0,0 +1,574 @@
|
||||
/**
|
||||
* Label inventory service, provenance, redaction, and GET routes.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import request from 'supertest';
|
||||
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
|
||||
import { ComposeService } from '../services/ComposeService';
|
||||
import DockerController from '../services/DockerController';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { NodeRegistry } from '../services/NodeRegistry';
|
||||
import {
|
||||
buildNodeLabelInventory,
|
||||
buildStackLabelInventory,
|
||||
} from '../services/LabelInventoryService';
|
||||
import { REDACTED_SENTINEL } from '../helpers/labelValueRedaction';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let authCookie: string;
|
||||
let nodeId: number;
|
||||
|
||||
function composeDir(): string { return process.env.COMPOSE_DIR as string; }
|
||||
|
||||
function writeStack(stack: string, files: Record<string, string>): void {
|
||||
const dir = path.join(composeDir(), stack);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
for (const [name, content] of Object.entries(files)) fs.writeFileSync(path.join(dir, name), content);
|
||||
}
|
||||
|
||||
function stubRender(serviceLabels: Record<string, Record<string, string>> | null): void {
|
||||
const rendered = serviceLabels === null
|
||||
? null
|
||||
: JSON.stringify({
|
||||
name: 'proj',
|
||||
services: Object.fromEntries(
|
||||
Object.entries(serviceLabels).map(([s, labels]) => [s, { labels }]),
|
||||
),
|
||||
});
|
||||
vi.spyOn(ComposeService, 'getInstance').mockReturnValue({
|
||||
renderConfig: vi.fn().mockResolvedValue({ rendered, stderr: '', code: rendered === null ? 1 : 0, timedOut: false }),
|
||||
} as unknown as ComposeService);
|
||||
}
|
||||
|
||||
interface StubRow {
|
||||
id: string;
|
||||
name: string;
|
||||
state: string;
|
||||
stack: string | null;
|
||||
service: string | null;
|
||||
labels: Record<string, string>;
|
||||
inspectFailed?: boolean;
|
||||
imageId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub DockerController for the label inventory. `images` maps an image id to its label
|
||||
* map, or to `null` to simulate an image inspect failure. An image id absent from the map
|
||||
* inspects successfully with no labels. Returns the `inspectImageLabels` spy so tests can
|
||||
* assert deduplication.
|
||||
*/
|
||||
function stubDockerList(
|
||||
rows: StubRow[],
|
||||
opts: { images?: Record<string, Record<string, string> | null> } = {},
|
||||
): { inspectImageLabels: ReturnType<typeof vi.fn> } {
|
||||
const withDefaults = rows.map(r => ({ inspectFailed: false, imageId: 'img-default', ...r }));
|
||||
const images = opts.images ?? {};
|
||||
const inspectImageLabels = vi.fn(async (imageId: string) => {
|
||||
if (imageId in images) {
|
||||
const labels = images[imageId];
|
||||
return labels === null ? null : { labels };
|
||||
}
|
||||
return { labels: {} };
|
||||
});
|
||||
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
|
||||
listContainersForLabelInventory: vi.fn().mockResolvedValue(withDefaults),
|
||||
getContainersByStack: vi.fn().mockImplementation(async (stack: string) =>
|
||||
withDefaults.filter(r => r.stack === stack).map(r => ({
|
||||
Id: r.id,
|
||||
Names: [`/${r.name}`],
|
||||
State: r.state,
|
||||
Service: r.service,
|
||||
})),
|
||||
),
|
||||
inspectContainerLabelsAndImage: vi.fn().mockImplementation(async (id: string) => {
|
||||
const row = withDefaults.find(r => r.id === id);
|
||||
if (!row || row.inspectFailed) return null;
|
||||
return { labels: row.labels, imageId: row.imageId };
|
||||
}),
|
||||
inspectImageLabels,
|
||||
} as unknown as DockerController);
|
||||
return { inspectImageLabels };
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ app } = await import('../index'));
|
||||
authCookie = await loginAsTestAdmin(app);
|
||||
nodeId = (DatabaseService.getInstance().getDb().prepare('SELECT id FROM nodes WHERE is_default = 1').get() as { id: number }).id;
|
||||
});
|
||||
|
||||
afterAll(() => cleanupTestDb(tmpDir));
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe('buildNodeLabelInventory', () => {
|
||||
it('builds inverted index and compose-system provenance', async () => {
|
||||
stubDockerList([
|
||||
{
|
||||
id: 'c1',
|
||||
name: 'web-1',
|
||||
state: 'running',
|
||||
stack: 'mys',
|
||||
service: 'web',
|
||||
imageId: 'img1',
|
||||
labels: {
|
||||
'com.docker.compose.service': 'web',
|
||||
'traefik.enable': 'true',
|
||||
},
|
||||
},
|
||||
]);
|
||||
const inv = await buildNodeLabelInventory(nodeId);
|
||||
expect(inv.containers).toHaveLength(1);
|
||||
expect(inv.byLabel).toHaveLength(2);
|
||||
const svc = inv.containers[0].labels.find(l => l.key === 'com.docker.compose.service');
|
||||
expect(svc?.source).toBe('compose-system');
|
||||
const traefik = inv.containers[0].labels.find(l => l.key === 'traefik.enable');
|
||||
expect(traefik?.source).toBe('runtime');
|
||||
expect(inv.partial).toBe(false);
|
||||
});
|
||||
|
||||
it('attributes image-inherited labels to the image, but runtime overrides stay runtime', async () => {
|
||||
stubDockerList([
|
||||
{
|
||||
id: 'c1',
|
||||
name: 'plex-1',
|
||||
state: 'running',
|
||||
stack: 'media',
|
||||
service: 'plex',
|
||||
imageId: 'plex-img',
|
||||
labels: {
|
||||
'org.opencontainers.image.title': 'Plex',
|
||||
'traefik.enable': 'true',
|
||||
},
|
||||
},
|
||||
], { images: { 'plex-img': { 'org.opencontainers.image.title': 'Plex', 'traefik.enable': 'false' } } });
|
||||
const inv = await buildNodeLabelInventory(nodeId);
|
||||
const oci = inv.containers[0].labels.find(l => l.key === 'org.opencontainers.image.title');
|
||||
expect(oci?.source).toBe('image');
|
||||
// Same key on the image but a different value: the container overrides it, so runtime.
|
||||
const traefik = inv.containers[0].labels.find(l => l.key === 'traefik.enable');
|
||||
expect(traefik?.source).toBe('runtime');
|
||||
expect(inv.partial).toBe(false);
|
||||
});
|
||||
|
||||
it('marks labels unknown and the inventory partial when the image inspect fails', async () => {
|
||||
stubDockerList([
|
||||
{ id: 'c1', name: 'a-1', state: 'running', stack: 's', service: 'a', imageId: 'broken', labels: { 'custom.label': 'v' } },
|
||||
], { images: { broken: null } });
|
||||
const inv = await buildNodeLabelInventory(nodeId);
|
||||
expect(inv.containers[0].labels.find(l => l.key === 'custom.label')?.source).toBe('unknown');
|
||||
expect(inv.partial).toBe(true);
|
||||
});
|
||||
|
||||
it('treats an empty image id as unknown and partial without inspecting an empty id', async () => {
|
||||
const { inspectImageLabels } = stubDockerList([
|
||||
{ id: 'c1', name: 'a-1', state: 'running', stack: 's', service: 'a', imageId: '', labels: { 'custom.label': 'v' } },
|
||||
]);
|
||||
const inv = await buildNodeLabelInventory(nodeId);
|
||||
expect(inv.containers[0].labels.find(l => l.key === 'custom.label')?.source).toBe('unknown');
|
||||
expect(inv.partial).toBe(true);
|
||||
expect(inspectImageLabels).not.toHaveBeenCalledWith('');
|
||||
});
|
||||
|
||||
it('inspects each shared image only once (dedup)', async () => {
|
||||
const { inspectImageLabels } = stubDockerList([
|
||||
{ id: 'c1', name: 'a-1', state: 'running', stack: 's', service: 'a', imageId: 'shared', labels: { a: '1' } },
|
||||
{ id: 'c2', name: 'a-2', state: 'running', stack: 's', service: 'a', imageId: 'shared', labels: { a: '1' } },
|
||||
], { images: { shared: {} } });
|
||||
await buildNodeLabelInventory(nodeId);
|
||||
const sharedCalls = inspectImageLabels.mock.calls.filter(c => c[0] === 'shared');
|
||||
expect(sharedCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('redacts secret-like label values by default', async () => {
|
||||
stubDockerList([
|
||||
{
|
||||
id: 'c1',
|
||||
name: 'app-1',
|
||||
state: 'running',
|
||||
stack: 'sec',
|
||||
service: 'app',
|
||||
imageId: 'img1',
|
||||
labels: { 'my.api.token': 'super-secret', 'traefik.enable': 'true' },
|
||||
},
|
||||
]);
|
||||
const inv = await buildNodeLabelInventory(nodeId);
|
||||
const token = inv.containers[0].labels.find(l => l.key === 'my.api.token');
|
||||
expect(token?.value).toBe(REDACTED_SENTINEL);
|
||||
expect(token?.redacted).toBe(true);
|
||||
const plain = inv.containers[0].labels.find(l => l.key === 'traefik.enable');
|
||||
expect(plain?.value).toBe('true');
|
||||
expect(plain?.redacted).toBeUndefined();
|
||||
});
|
||||
|
||||
it('redacts Traefik basicauth and digestauth label values', async () => {
|
||||
stubDockerList([
|
||||
{
|
||||
id: 'c1', name: 'web-1', state: 'running', stack: 's', service: 'web', imageId: 'img1',
|
||||
labels: {
|
||||
'traefik.http.middlewares.foo.basicauth.users': 'admin:$apr1$abc123',
|
||||
'traefik.http.middlewares.bar.digestauth.users': 'admin:realm:deadbeef',
|
||||
'traefik.enable': 'true',
|
||||
},
|
||||
},
|
||||
]);
|
||||
const inv = await buildNodeLabelInventory(nodeId);
|
||||
const basic = inv.containers[0].labels.find(l => l.key.endsWith('basicauth.users'));
|
||||
const digest = inv.containers[0].labels.find(l => l.key.endsWith('digestauth.users'));
|
||||
expect(basic?.value).toBe(REDACTED_SENTINEL);
|
||||
expect(basic?.redacted).toBe(true);
|
||||
expect(digest?.value).toBe(REDACTED_SENTINEL);
|
||||
expect(digest?.redacted).toBe(true);
|
||||
expect(inv.containers[0].labels.find(l => l.key === 'traefik.enable')?.redacted).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reveals secret-like values when revealSecrets is true', async () => {
|
||||
stubDockerList([
|
||||
{
|
||||
id: 'c1',
|
||||
name: 'app-1',
|
||||
state: 'running',
|
||||
stack: 'sec',
|
||||
service: 'app',
|
||||
imageId: 'img1',
|
||||
labels: { 'api.token': 'visible-when-revealed' },
|
||||
},
|
||||
]);
|
||||
const inv = await buildNodeLabelInventory(nodeId, { revealSecrets: true });
|
||||
expect(inv.containers[0].labels[0].value).toBe('visible-when-revealed');
|
||||
expect(inv.containers[0].labels[0].redacted).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildStackLabelInventory', () => {
|
||||
it('reconciles declared and runtime labels', async () => {
|
||||
writeStack('lbl1', {
|
||||
'compose.yaml': 'services:\n web:\n image: nginx\n labels:\n traefik.enable: "true"\n compose.only: "1"\n',
|
||||
});
|
||||
stubRender({ web: { 'traefik.enable': 'true', 'compose.only': '1' } });
|
||||
stubDockerList([
|
||||
{
|
||||
id: 'c1',
|
||||
name: 'lbl1-web-1',
|
||||
state: 'running',
|
||||
stack: 'lbl1',
|
||||
service: 'web',
|
||||
imageId: 'img1',
|
||||
labels: {
|
||||
'traefik.enable': 'true',
|
||||
'runtime.only': '1',
|
||||
'com.docker.compose.service': 'web',
|
||||
},
|
||||
},
|
||||
]);
|
||||
const inv = await buildStackLabelInventory(nodeId, 'lbl1');
|
||||
expect(inv.renderable).toBe(true);
|
||||
expect(inv.partial).toBe(false);
|
||||
const web = inv.services.find(s => s.service === 'web');
|
||||
expect(web?.declaredLabels.map(l => l.key)).toEqual(['compose.only', 'traefik.enable']);
|
||||
expect(web?.replicas[0].onlyInCompose).toEqual(['compose.only']);
|
||||
expect(web?.replicas[0].onlyOnContainer).toContain('runtime.only');
|
||||
expect(web?.replicas[0].inBoth).toContain('traefik.enable');
|
||||
expect(web?.replicas[0].changed).toEqual([]);
|
||||
});
|
||||
|
||||
it('flags a value that drifted between compose and runtime as changed, not inBoth', async () => {
|
||||
writeStack('drift1', {
|
||||
'compose.yaml': 'services:\n web:\n image: nginx\n labels:\n watchtower.enable: "true"\n',
|
||||
});
|
||||
stubRender({ web: { 'watchtower.enable': 'true' } });
|
||||
stubDockerList([
|
||||
{
|
||||
id: 'c1',
|
||||
name: 'drift1-web-1',
|
||||
state: 'running',
|
||||
stack: 'drift1',
|
||||
service: 'web',
|
||||
imageId: 'img1',
|
||||
labels: { 'watchtower.enable': 'false' },
|
||||
},
|
||||
]);
|
||||
const inv = await buildStackLabelInventory(nodeId, 'drift1');
|
||||
const web = inv.services.find(s => s.service === 'web');
|
||||
expect(web?.replicas[0].changed).toEqual(['watchtower.enable']);
|
||||
expect(web?.replicas[0].inBoth).not.toContain('watchtower.enable');
|
||||
});
|
||||
|
||||
it('detects drift on a secret-like key while its value stays redacted', async () => {
|
||||
writeStack('drift2', {
|
||||
'compose.yaml': 'services:\n web:\n image: nginx\n labels:\n auth.token: "declared"\n',
|
||||
});
|
||||
stubRender({ web: { 'auth.token': 'declared' } });
|
||||
stubDockerList([
|
||||
{ id: 'c1', name: 'drift2-web-1', state: 'running', stack: 'drift2', service: 'web', imageId: 'img1', labels: { 'auth.token': 'runtime' } },
|
||||
]);
|
||||
const inv = await buildStackLabelInventory(nodeId, 'drift2');
|
||||
const web = inv.services.find(s => s.service === 'web');
|
||||
expect(web?.replicas[0].changed).toEqual(['auth.token']);
|
||||
const rt = web?.replicas[0].runtimeLabels.find(l => l.key === 'auth.token');
|
||||
expect(rt?.value).toBe(REDACTED_SENTINEL);
|
||||
expect(rt?.redacted).toBe(true);
|
||||
});
|
||||
|
||||
it('marks a replica inspectFailed and skips reconciliation instead of reporting false drift', async () => {
|
||||
writeStack('fail1', {
|
||||
'compose.yaml': 'services:\n web:\n image: nginx\n labels:\n traefik.enable: "true"\n',
|
||||
});
|
||||
stubRender({ web: { 'traefik.enable': 'true' } });
|
||||
stubDockerList([
|
||||
{ id: 'c1', name: 'fail1-web-1', state: 'running', stack: 'fail1', service: 'web', imageId: 'img1', labels: {}, inspectFailed: true },
|
||||
]);
|
||||
const inv = await buildStackLabelInventory(nodeId, 'fail1');
|
||||
const web = inv.services.find(s => s.service === 'web');
|
||||
expect(web?.replicas[0].inspectFailed).toBe(true);
|
||||
expect(web?.replicas[0].onlyInCompose).toEqual([]);
|
||||
expect(web?.replicas[0].runtimeLabels).toEqual([]);
|
||||
expect(inv.partial).toBe(true);
|
||||
});
|
||||
|
||||
it('inspects each shared image only once across replicas (dedup)', async () => {
|
||||
writeStack('ddup', { 'compose.yaml': 'services:\n web:\n image: nginx\n' });
|
||||
stubRender({ web: {} });
|
||||
const { inspectImageLabels } = stubDockerList([
|
||||
{ id: 'c1', name: 'ddup-web-1', state: 'running', stack: 'ddup', service: 'web', imageId: 'shared', labels: { a: '1' } },
|
||||
{ id: 'c2', name: 'ddup-web-2', state: 'running', stack: 'ddup', service: 'web', imageId: 'shared', labels: { a: '1' } },
|
||||
], { images: { shared: {} } });
|
||||
await buildStackLabelInventory(nodeId, 'ddup');
|
||||
expect(inspectImageLabels.mock.calls.filter(c => c[0] === 'shared')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('attributes provenance on the stack path: compose wins over image, image labels tagged image', async () => {
|
||||
writeStack('prov1', {
|
||||
'compose.yaml': 'services:\n web:\n image: nginx\n labels:\n foo: "bar"\n',
|
||||
});
|
||||
stubRender({ web: { foo: 'bar' } });
|
||||
stubDockerList([
|
||||
{ id: 'c1', name: 'prov1-web-1', state: 'running', stack: 'prov1', service: 'web', imageId: 'img1', labels: { foo: 'bar', 'org.opencontainers.image.title': 'Nginx' } },
|
||||
], { images: { img1: { foo: 'bar', 'org.opencontainers.image.title': 'Nginx' } } });
|
||||
const inv = await buildStackLabelInventory(nodeId, 'prov1');
|
||||
const rep = inv.services.find(s => s.service === 'web')?.replicas[0];
|
||||
// foo is on both the image and the Compose file with the same value: Compose wins.
|
||||
expect(rep?.runtimeLabels.find(l => l.key === 'foo')?.source).toBe('compose');
|
||||
expect(rep?.runtimeLabels.find(l => l.key === 'org.opencontainers.image.title')?.source).toBe('image');
|
||||
expect(inv.partial).toBe(false);
|
||||
});
|
||||
|
||||
it('marks stack runtime labels unknown and the inventory partial when the image inspect fails', async () => {
|
||||
writeStack('prov2', { 'compose.yaml': 'services:\n web:\n image: nginx\n' });
|
||||
stubRender({ web: {} });
|
||||
stubDockerList([
|
||||
{ id: 'c1', name: 'prov2-web-1', state: 'running', stack: 'prov2', service: 'web', imageId: 'broken', labels: { 'custom.label': 'v' } },
|
||||
], { images: { broken: null } });
|
||||
const inv = await buildStackLabelInventory(nodeId, 'prov2');
|
||||
const rep = inv.services.find(s => s.service === 'web')?.replicas[0];
|
||||
expect(rep?.runtimeLabels.find(l => l.key === 'custom.label')?.source).toBe('unknown');
|
||||
expect(inv.partial).toBe(true);
|
||||
});
|
||||
|
||||
it('parses list-form compose labels', async () => {
|
||||
writeStack('lbl2', {
|
||||
'compose.yaml': 'services:\n web:\n image: nginx\n labels:\n - "watchtower.enable=true"\n',
|
||||
});
|
||||
stubRender({ web: { 'watchtower.enable': 'true' } });
|
||||
stubDockerList([]);
|
||||
const inv = await buildStackLabelInventory(nodeId, 'lbl2');
|
||||
expect(inv.services[0].declaredLabels[0]).toMatchObject({ key: 'watchtower.enable', value: 'true', source: 'compose' });
|
||||
});
|
||||
|
||||
it('sets renderable false when compose render fails', async () => {
|
||||
writeStack('lbl3', { 'compose.yaml': 'services:\n web:\n image: nginx\n' });
|
||||
stubRender(null);
|
||||
stubDockerList([]);
|
||||
const inv = await buildStackLabelInventory(nodeId, 'lbl3');
|
||||
expect(inv.renderable).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves non-system labels to unknown and skips reconciliation when render fails', async () => {
|
||||
writeStack('rf1', { 'compose.yaml': 'services:\n web:\n image: nginx\n' });
|
||||
stubRender(null);
|
||||
stubDockerList([
|
||||
{
|
||||
id: 'c1', name: 'rf1-web-1', state: 'running', stack: 'rf1', service: 'web', imageId: 'img1',
|
||||
labels: { 'traefik.enable': 'true', 'com.docker.compose.service': 'web' },
|
||||
},
|
||||
]);
|
||||
const inv = await buildStackLabelInventory(nodeId, 'rf1');
|
||||
expect(inv.renderable).toBe(false);
|
||||
// Render failure is signalled by renderable, not partial (which is for inspect failures).
|
||||
expect(inv.partial).toBe(false);
|
||||
const rep = inv.services.find(s => s.service === 'web')?.replicas[0];
|
||||
expect(rep?.runtimeLabels.find(l => l.key === 'traefik.enable')?.source).toBe('unknown');
|
||||
expect(rep?.runtimeLabels.find(l => l.key === 'com.docker.compose.service')?.source).toBe('compose-system');
|
||||
expect(rep?.onlyOnContainer).toEqual([]);
|
||||
expect(rep?.changed).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/system/container-labels', () => {
|
||||
it('requires authentication', async () => {
|
||||
const res = await request(app).get('/api/system/container-labels');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns node inventory', async () => {
|
||||
stubDockerList([
|
||||
{ id: 'c1', name: 'a', state: 'running', stack: 's', service: 'web', imageId: 'img1', labels: { foo: 'bar' } },
|
||||
]);
|
||||
const res = await request(app).get('/api/system/container-labels').set('Cookie', authCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.nodeId).toBe(nodeId);
|
||||
expect(res.body.containers).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('redacts secrets by default and reveals them for an admin with reveal=1', async () => {
|
||||
stubDockerList([
|
||||
{ id: 'c1', name: 'a', state: 'running', stack: 's', service: 'web', imageId: 'img1', labels: { 'api.token': 's3cr3t' } },
|
||||
]);
|
||||
const redacted = await request(app).get('/api/system/container-labels').set('Cookie', authCookie);
|
||||
const rLabel = redacted.body.containers[0].labels.find((l: { key: string }) => l.key === 'api.token');
|
||||
expect(rLabel.value).toBe(REDACTED_SENTINEL);
|
||||
expect(rLabel.redacted).toBe(true);
|
||||
|
||||
stubDockerList([
|
||||
{ id: 'c1', name: 'a', state: 'running', stack: 's', service: 'web', imageId: 'img1', labels: { 'api.token': 's3cr3t' } },
|
||||
]);
|
||||
const revealed = await request(app).get('/api/system/container-labels?reveal=1').set('Cookie', authCookie);
|
||||
const vLabel = revealed.body.containers[0].labels.find((l: { key: string }) => l.key === 'api.token');
|
||||
expect(vLabel.value).toBe('s3cr3t');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/stacks/:stackName/label-inventory', () => {
|
||||
it('returns 404 for unknown stack', async () => {
|
||||
const res = await request(app).get('/api/stacks/missing-stack/label-inventory').set('Cookie', authCookie);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('returns stack inventory', async () => {
|
||||
writeStack('route1', { 'compose.yaml': 'services:\n web:\n image: nginx\n' });
|
||||
stubRender({ web: {} });
|
||||
stubDockerList([]);
|
||||
const res = await request(app).get('/api/stacks/route1/label-inventory').set('Cookie', authCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.stackName).toBe('route1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/fleet/container-labels', () => {
|
||||
it('requires authentication', async () => {
|
||||
const res = await request(app).get('/api/fleet/container-labels');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('aggregates the local node with no node errors', async () => {
|
||||
stubDockerList([
|
||||
{ id: 'c1', name: 'a-1', state: 'running', stack: 's', service: 'a', imageId: 'img1', labels: { 'shared.label': 'v' } },
|
||||
{ id: 'c2', name: 'b-1', state: 'running', stack: 's', service: 'b', imageId: 'img1', labels: { 'shared.label': 'v' } },
|
||||
]);
|
||||
const res = await request(app).get('/api/fleet/container-labels').set('Cookie', authCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.nodeErrors).toEqual({});
|
||||
const shared = res.body.aggregatedByLabel.filter((r: { key: string }) => r.key === 'shared.label');
|
||||
expect(shared).toHaveLength(1);
|
||||
expect(shared[0].containers).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('keeps the same key=value distinct when the source differs', async () => {
|
||||
stubDockerList([
|
||||
{ id: 'c1', name: 'a-1', state: 'running', stack: 's', service: 'a', imageId: 'ia', labels: { 'dup.label': 'v' } },
|
||||
{ id: 'c2', name: 'b-1', state: 'running', stack: 's', service: 'b', imageId: 'ib', labels: { 'dup.label': 'v' } },
|
||||
], { images: { ia: { 'dup.label': 'v' }, ib: {} } });
|
||||
const res = await request(app).get('/api/fleet/container-labels').set('Cookie', authCookie);
|
||||
const dup = res.body.aggregatedByLabel.filter((r: { key: string }) => r.key === 'dup.label');
|
||||
expect(dup).toHaveLength(2);
|
||||
// The server sorts by key, value, then source; assert that order directly (no re-sort).
|
||||
expect(dup.map((r: { source: string }) => r.source)).toEqual(['image', 'runtime']);
|
||||
});
|
||||
|
||||
it('degrades an unreachable remote into nodeErrors without failing the whole request', async () => {
|
||||
stubDockerList([
|
||||
{ id: 'c1', name: 'a-1', state: 'running', stack: 's', service: 'a', imageId: 'img1', labels: { foo: 'bar' } },
|
||||
]);
|
||||
const db = DatabaseService.getInstance();
|
||||
const remoteId = db.addNode({ name: 'remote-lbl', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://remote.invalid', api_token: 't' });
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't' } : null);
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('nope', { status: 502 }));
|
||||
try {
|
||||
const res = await request(app).get('/api/fleet/container-labels').set('Cookie', authCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.nodeErrors[remoteId]).toBeDefined();
|
||||
expect(res.body.aggregatedByLabel.some((r: { key: string }) => r.key === 'foo')).toBe(true);
|
||||
} finally {
|
||||
db.deleteNode(remoteId);
|
||||
}
|
||||
});
|
||||
|
||||
it('degrades a malformed remote payload into nodeErrors, not a 500', async () => {
|
||||
stubDockerList([
|
||||
{ id: 'c1', name: 'a-1', state: 'running', stack: 's', service: 'a', imageId: 'img1', labels: { foo: 'bar' } },
|
||||
]);
|
||||
const db = DatabaseService.getInstance();
|
||||
const remoteId = db.addNode({ name: 'remote-bad', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://remote.invalid', api_token: 't' });
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't' } : null);
|
||||
// byLabel row missing valid key/value/source: must be rejected by the deep guard.
|
||||
const malformed = JSON.stringify({ nodeId: remoteId, containers: [], byLabel: [{ key: 123, containers: [] }], partial: false, generatedAt: 0 });
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(malformed, { status: 200, headers: { 'content-type': 'application/json' } }));
|
||||
try {
|
||||
const res = await request(app).get('/api/fleet/container-labels').set('Cookie', authCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.nodeErrors[remoteId]).toBeDefined();
|
||||
} finally {
|
||||
db.deleteNode(remoteId);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a remote row with an invalid source value via the source allowlist', async () => {
|
||||
stubDockerList([]);
|
||||
const db = DatabaseService.getInstance();
|
||||
const remoteId = db.addNode({ name: 'remote-src', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://remote.invalid', api_token: 't' });
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't' } : null);
|
||||
const badSource = JSON.stringify({ nodeId: remoteId, containers: [], partial: false, generatedAt: 0, byLabel: [{ key: 'k', value: 'v', source: 'not-a-source', containers: [{ id: 'c', name: 'n' }] }] });
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(badSource, { status: 200, headers: { 'content-type': 'application/json' } }));
|
||||
try {
|
||||
const res = await request(app).get('/api/fleet/container-labels').set('Cookie', authCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.nodeErrors[remoteId]).toBe('Remote returned an unexpected label-inventory payload');
|
||||
} finally {
|
||||
db.deleteNode(remoteId);
|
||||
}
|
||||
});
|
||||
|
||||
it('degrades a remote with a malformed inventory container into nodeErrors', async () => {
|
||||
stubDockerList([]);
|
||||
const db = DatabaseService.getInstance();
|
||||
const remoteId = db.addNode({ name: 'remote-cont', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://remote.invalid', api_token: 't' });
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't' } : null);
|
||||
const badContainer = JSON.stringify({ nodeId: remoteId, byLabel: [], partial: false, generatedAt: 0, containers: [{}] });
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(badContainer, { status: 200, headers: { 'content-type': 'application/json' } }));
|
||||
try {
|
||||
const res = await request(app).get('/api/fleet/container-labels').set('Cookie', authCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.nodeErrors[remoteId]).toBeDefined();
|
||||
} finally {
|
||||
db.deleteNode(remoteId);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a remote row with a malformed nested container ref', async () => {
|
||||
stubDockerList([]);
|
||||
const db = DatabaseService.getInstance();
|
||||
const remoteId = db.addNode({ name: 'remote-ref', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://remote.invalid', api_token: 't' });
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't' } : null);
|
||||
const badRef = JSON.stringify({ nodeId: remoteId, containers: [], partial: false, generatedAt: 0, byLabel: [{ key: 'k', value: 'v', source: 'runtime', containers: [{ id: 5 }] }] });
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(badRef, { status: 200, headers: { 'content-type': 'application/json' } }));
|
||||
try {
|
||||
const res = await request(app).get('/api/fleet/container-labels').set('Cookie', authCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.nodeErrors[remoteId]).toBeDefined();
|
||||
} finally {
|
||||
db.deleteNode(remoteId);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Request } from 'express';
|
||||
import type { LabelInventoryOptions } from '../services/LabelInventoryService';
|
||||
import { requireAdmin } from '../middleware/tierGates';
|
||||
|
||||
/** Parse ?reveal=1; full values only when the caller is an admin. */
|
||||
export function labelInventoryOptionsFromRequest(req: Request): LabelInventoryOptions {
|
||||
const wantsReveal = req.query.reveal === '1' || req.query.reveal === 'true';
|
||||
if (!wantsReveal) return { revealSecrets: false };
|
||||
// requireAdmin is synchronous guard; routes call it before building inventory when reveal is requested.
|
||||
return { revealSecrets: true };
|
||||
}
|
||||
|
||||
/** Returns false and sends 403 when reveal was requested but caller is not admin. */
|
||||
export function requireRevealAdmin(req: Request, res: import('express').Response): boolean {
|
||||
const wantsReveal = req.query.reveal === '1' || req.query.reveal === 'true';
|
||||
if (!wantsReveal) return true;
|
||||
return requireAdmin(req, res);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { isLikelySecretKey } from './secretClassification';
|
||||
|
||||
export const REDACTED_SENTINEL = '[redacted]';
|
||||
|
||||
/**
|
||||
* Compound single-token segments specific to Docker/Compose labels that the generic
|
||||
* env classifier does not split (e.g. Traefik `basicauth`/`digestauth` middleware keys,
|
||||
* whose value carries inline `user:passwordhash` credentials).
|
||||
*/
|
||||
const SECRET_LABEL_SEGMENTS = new Set(['BASICAUTH', 'DIGESTAUTH']);
|
||||
|
||||
/** True when a Docker/Compose label key likely carries a sensitive value. */
|
||||
export function isLikelySecretLabelKey(rawKey: string): boolean {
|
||||
if (isLikelySecretKey(rawKey)) return true;
|
||||
const segments = rawKey.trim().toUpperCase().split(/[^A-Z0-9]+/).filter(Boolean);
|
||||
return segments.some(seg => SECRET_LABEL_SEGMENTS.has(seg));
|
||||
}
|
||||
|
||||
export function redactLabelValue(key: string, value: string, revealSecrets: boolean): { value: string; redacted?: boolean } {
|
||||
if (revealSecrets || !isLikelySecretLabelKey(key)) {
|
||||
return { value };
|
||||
}
|
||||
return { value: REDACTED_SENTINEL, redacted: true };
|
||||
}
|
||||
@@ -16,6 +16,7 @@ const SECRET_SEGMENTS = new Set([
|
||||
'SECRET', 'SECRETS',
|
||||
'TOKEN', 'KEY', 'APIKEY',
|
||||
'CREDENTIAL', 'CREDENTIALS', 'AUTH',
|
||||
'BASIC',
|
||||
]);
|
||||
|
||||
/** Connection strings whose value is sensitive but whose segments are innocuous. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -27,6 +27,8 @@ import { buildStackNetworkFacts } from '../services/network/composeNetworkInspec
|
||||
import { buildStorageInventory } from '../services/storage/inventory';
|
||||
import { buildEffectiveAnatomy } from '../services/effectiveAnatomy';
|
||||
import { buildEnvInventory } from '../services/EnvInventoryService';
|
||||
import { buildStackLabelInventory } from '../services/LabelInventoryService';
|
||||
import { labelInventoryOptionsFromRequest, requireRevealAdmin } from '../helpers/labelInventoryRequest';
|
||||
import { EXPOSURE_INTENTS, type ExposureIntent } from '../services/network/types';
|
||||
import { UpdateGuardService } from '../services/UpdateGuardService';
|
||||
import { HealthGateService } from '../services/HealthGateService';
|
||||
@@ -1322,6 +1324,22 @@ stacksRouter.get('/:stackName/env-inventory', async (req: Request, res: Response
|
||||
}
|
||||
});
|
||||
|
||||
// Docker/Compose label inventory: declared compose labels vs runtime container
|
||||
// labels per service. Read-only; auto-proxies to the active node.
|
||||
stacksRouter.get('/:stackName/label-inventory', async (req: Request, res: Response) => {
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return;
|
||||
if (!(await requireStackExists(req.nodeId, stackName, res))) return;
|
||||
if (!requireRevealAdmin(req, res)) return;
|
||||
try {
|
||||
res.json(await buildStackLabelInventory(req.nodeId, stackName, labelInventoryOptionsFromRequest(req)));
|
||||
} catch (error) {
|
||||
console.error('[Stacks] Failed to build label inventory for %s:', sanitizeForLog(stackName),
|
||||
sanitizeForLog(inspect(error, { depth: 4 })));
|
||||
res.status(500).json({ error: 'Failed to build label inventory' });
|
||||
}
|
||||
});
|
||||
|
||||
// Exposure intent: the user's per-stack (service '') and per-service exposure
|
||||
// classification, stored separately from generated facts so mismatches stay
|
||||
// detectable. Rows are stored independently; precedence (a service row taking
|
||||
|
||||
@@ -9,6 +9,9 @@ import { isDebugEnabled } from '../utils/debug';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
import { withTimeout, TimeoutError } from '../utils/withTimeout';
|
||||
import { buildNodeLabelInventory } from '../services/LabelInventoryService';
|
||||
import { labelInventoryOptionsFromRequest, requireRevealAdmin } from '../helpers/labelInventoryRequest';
|
||||
import { requirePermission } from '../middleware/permissions';
|
||||
|
||||
// `docker system df` (the call backing estimateSystemReclaim) can take 30+
|
||||
// seconds on Docker Desktop with many volumes; 8s matches the MonitorService
|
||||
@@ -218,6 +221,19 @@ systemMaintenanceRouter.get('/docker-df', async (req: Request, res: Response) =>
|
||||
}
|
||||
});
|
||||
|
||||
// Node-wide Docker/Compose label inventory for fleet fan-out and local audit.
|
||||
systemMaintenanceRouter.get('/container-labels', async (req: Request, res: Response) => {
|
||||
if (!requirePermission(req, res, 'node:read')) return;
|
||||
if (!requireRevealAdmin(req, res)) return;
|
||||
try {
|
||||
const inventory = await buildNodeLabelInventory(req.nodeId, labelInventoryOptionsFromRequest(req));
|
||||
res.json(inventory);
|
||||
} catch (error) {
|
||||
console.error('Failed to build container label inventory:', error);
|
||||
res.status(500).json({ error: 'Failed to build container label inventory' });
|
||||
}
|
||||
});
|
||||
|
||||
systemMaintenanceRouter.get('/resources', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const knownStacks = await FileSystemService.getInstance(req.nodeId).getStacks();
|
||||
|
||||
@@ -40,6 +40,7 @@ export const CAPABILITIES = [
|
||||
'update-guard',
|
||||
'compose-networking',
|
||||
'env-inventory',
|
||||
'container-label-inventory',
|
||||
'project-env-files',
|
||||
'compose-storage',
|
||||
'cross-node-rbac',
|
||||
|
||||
@@ -8,6 +8,7 @@ import * as yaml from 'yaml';
|
||||
|
||||
import { NodeRegistry } from './NodeRegistry';
|
||||
import { CacheService } from './CacheService';
|
||||
import { FileSystemService } from './FileSystemService';
|
||||
import SelfIdentityService from './SelfIdentityService';
|
||||
import { isPathWithinBase } from '../utils/validation';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
@@ -222,6 +223,17 @@ export interface CreateNetworkOptions {
|
||||
Attachable?: boolean;
|
||||
}
|
||||
|
||||
export interface LabelInventoryRow {
|
||||
id: string;
|
||||
name: string;
|
||||
state: string;
|
||||
stack: string | null;
|
||||
service: string | null;
|
||||
labels: Record<string, string>;
|
||||
imageId: string;
|
||||
inspectFailed: boolean;
|
||||
}
|
||||
|
||||
class DockerController {
|
||||
private static readonly SYSTEM_NETWORKS = new Set(['bridge', 'host', 'none']);
|
||||
/**
|
||||
@@ -863,6 +875,93 @@ class DockerController {
|
||||
return this.validateApiData<any[]>(containers);
|
||||
}
|
||||
|
||||
/** Runtime labels + image ref from container inspect. Null (logged) when inspect fails. */
|
||||
public async inspectContainerLabelsAndImage(
|
||||
containerId: string,
|
||||
): Promise<{ labels: Record<string, string>; imageId: string } | null> {
|
||||
try {
|
||||
const info = await this.docker.getContainer(containerId).inspect();
|
||||
return { labels: info.Config?.Labels ?? {}, imageId: info.Image ?? '' };
|
||||
} catch (err) {
|
||||
console.error('[DockerController] Container inspect failed for %s:', sanitizeForLog(containerId), err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Image-level labels for label provenance. Null (logged) when the image cannot be inspected. */
|
||||
public async inspectImageLabels(imageId: string): Promise<{ labels: Record<string, string> } | null> {
|
||||
if (!imageId) return null;
|
||||
try {
|
||||
const info = await this.docker.getImage(imageId).inspect();
|
||||
return { labels: info.Config?.Labels ?? {} };
|
||||
} catch (err) {
|
||||
console.error('[DockerController] Image inspect failed for %s:', sanitizeForLog(imageId), err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Containers with runtime labels for the label-inventory API. Resolves stack
|
||||
* membership with the same multi-fallback strategy as bulk status. Captures the
|
||||
* image ref so label provenance can distinguish image-inherited labels.
|
||||
*/
|
||||
public async listContainersForLabelInventory(): Promise<LabelInventoryRow[]> {
|
||||
const knownStacks = await FileSystemService.getInstance(this.nodeId).getStacks();
|
||||
const listed = await this.getAllContainers() as Array<{
|
||||
Id?: string;
|
||||
Names?: string[];
|
||||
State?: string;
|
||||
Labels?: Record<string, string>;
|
||||
ImageID?: string;
|
||||
}>;
|
||||
const projectToStack = await DockerController.resolveProjectNameMap(knownStacks);
|
||||
const absDirToStack = DockerController.buildAbsDirMap(knownStacks);
|
||||
const resolvedBase = path.resolve(COMPOSE_DIR);
|
||||
const knownStackSet = new Set(knownStacks);
|
||||
|
||||
const CONCURRENCY = 8;
|
||||
const results: LabelInventoryRow[] = new Array(listed.length);
|
||||
|
||||
let index = 0;
|
||||
const worker = async () => {
|
||||
while (index < listed.length) {
|
||||
const i = index++;
|
||||
const c = listed[i];
|
||||
const id = c.Id ?? '';
|
||||
const name = (c.Names?.[0] ?? '').replace(/^\//, '');
|
||||
const state = c.State ?? 'unknown';
|
||||
const stack = DockerController.resolveContainerStack(
|
||||
c.Labels,
|
||||
projectToStack,
|
||||
knownStackSet,
|
||||
absDirToStack,
|
||||
resolvedBase,
|
||||
);
|
||||
const service = c.Labels?.['com.docker.compose.service'] ?? null;
|
||||
if (!id) {
|
||||
results[i] = { id, name, state, stack, service, labels: {}, imageId: c.ImageID ?? '', inspectFailed: true };
|
||||
continue;
|
||||
}
|
||||
let labels: Record<string, string>;
|
||||
let imageId: string;
|
||||
let inspectFailed = false;
|
||||
try {
|
||||
const info = await this.docker.getContainer(id).inspect();
|
||||
labels = info.Config?.Labels ?? {};
|
||||
imageId = info.Image ?? '';
|
||||
} catch (err) {
|
||||
console.error('[DockerController] Container inspect failed for %s:', sanitizeForLog(id), err);
|
||||
labels = c.Labels ?? {};
|
||||
imageId = c.ImageID ?? '';
|
||||
inspectFailed = true;
|
||||
}
|
||||
results[i] = { id, name, state, stack, service, labels, imageId, inspectFailed };
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, listed.length) }, worker));
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Resolve a container by its durable name (not ephemeral ID). */
|
||||
public async findContainerByName(name: string): Promise<{
|
||||
id: string;
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
/**
|
||||
* Per-node and per-stack Docker/Compose label inventory with provenance and
|
||||
* optional value redaction for secret-like keys.
|
||||
*/
|
||||
|
||||
import { ComposeService } from './ComposeService';
|
||||
import DockerController from './DockerController';
|
||||
import { redactLabelValue } from '../helpers/labelValueRedaction';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
|
||||
export type LabelSource = 'compose' | 'runtime' | 'image' | 'compose-system' | 'unknown';
|
||||
|
||||
/**
|
||||
* Every valid label source, typed as a set of strings so the wire validator can test an
|
||||
* untrusted `string` without a cast. The literals are `LabelSource` members, so the union
|
||||
* still documents the valid values.
|
||||
*/
|
||||
export const VALID_LABEL_SOURCES: ReadonlySet<string> = new Set<LabelSource>([
|
||||
'compose', 'runtime', 'image', 'compose-system', 'unknown',
|
||||
]);
|
||||
|
||||
export interface LabelValue {
|
||||
key: string;
|
||||
value: string;
|
||||
source: LabelSource;
|
||||
redacted?: boolean;
|
||||
}
|
||||
|
||||
export interface ContainerLabelRow {
|
||||
id: string;
|
||||
name: string;
|
||||
stack: string | null;
|
||||
service: string | null;
|
||||
state: string;
|
||||
labels: LabelValue[];
|
||||
}
|
||||
|
||||
export interface LabelIndexContainerRef {
|
||||
id: string;
|
||||
name: string;
|
||||
stack: string | null;
|
||||
service: string | null;
|
||||
nodeId?: number;
|
||||
nodeName?: string;
|
||||
}
|
||||
|
||||
export interface LabelIndexRow {
|
||||
key: string;
|
||||
value: string;
|
||||
redacted?: boolean;
|
||||
source: LabelSource;
|
||||
containers: LabelIndexContainerRef[];
|
||||
}
|
||||
|
||||
export interface NodeLabelInventory {
|
||||
nodeId: number;
|
||||
containers: ContainerLabelRow[];
|
||||
byLabel: LabelIndexRow[];
|
||||
partial: boolean;
|
||||
generatedAt: number;
|
||||
}
|
||||
|
||||
export interface StackLabelReplica {
|
||||
id: string;
|
||||
name: string;
|
||||
state: string;
|
||||
runtimeLabels: LabelValue[];
|
||||
onlyInCompose: string[];
|
||||
onlyOnContainer: string[];
|
||||
inBoth: string[];
|
||||
/** Keys declared in Compose and present at runtime but with a different value. */
|
||||
changed: string[];
|
||||
/** Runtime labels could not be read for this replica; reconciliation is skipped. */
|
||||
inspectFailed?: boolean;
|
||||
}
|
||||
|
||||
export interface StackServiceLabelRow {
|
||||
service: string;
|
||||
declaredLabels: LabelValue[];
|
||||
replicas: StackLabelReplica[];
|
||||
}
|
||||
|
||||
export interface StackLabelInventory {
|
||||
stackName: string;
|
||||
renderable: boolean;
|
||||
services: StackServiceLabelRow[];
|
||||
/** A replica or its image could not be fully inspected; some provenance is unknown. */
|
||||
partial: boolean;
|
||||
generatedAt: number;
|
||||
}
|
||||
|
||||
export interface LabelInventoryOptions {
|
||||
revealSecrets?: boolean;
|
||||
}
|
||||
|
||||
const INSPECT_CONCURRENCY = 8;
|
||||
const COMPOSE_SYSTEM_PREFIX = 'com.docker.compose.';
|
||||
|
||||
function str(v: unknown): string | undefined {
|
||||
if (typeof v === 'string') return v;
|
||||
if (typeof v === 'number' || typeof v === 'boolean') return String(v);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truthful provenance for a runtime label. Precedence: compose-system prefix, then
|
||||
* (stack path only) a Compose-declared key with the same value, then an exact image
|
||||
* label match, then plain runtime. When the image could not be inspected
|
||||
* (`imageLabels === null`) an otherwise-unattributable label is `unknown`, not `runtime`.
|
||||
*/
|
||||
function resolveLabelSource(
|
||||
key: string,
|
||||
value: string,
|
||||
imageLabels: Record<string, string> | null,
|
||||
declared?: Record<string, string>,
|
||||
): LabelSource {
|
||||
if (key.startsWith(COMPOSE_SYSTEM_PREFIX)) return 'compose-system';
|
||||
if (declared && declared[key] === value) return 'compose';
|
||||
if (imageLabels) return imageLabels[key] === value ? 'image' : 'runtime';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect each unique, non-empty image id once (deduped, bounded concurrency). Returns a
|
||||
* map from image id to its label map, or `null` for an image that could not be inspected,
|
||||
* and whether any inspection failed (so callers can mark the inventory partial).
|
||||
*/
|
||||
async function buildImageLabelMap(
|
||||
docker: DockerController,
|
||||
imageIds: string[],
|
||||
): Promise<{ map: Map<string, Record<string, string> | null>; partial: boolean }> {
|
||||
const unique = [...new Set(imageIds.filter(id => id.length > 0))];
|
||||
const inspected = await mapWithConcurrency(unique, INSPECT_CONCURRENCY, async (imageId) => {
|
||||
const result = await docker.inspectImageLabels(imageId);
|
||||
return { imageId, labels: result ? result.labels : null };
|
||||
});
|
||||
const map = new Map<string, Record<string, string> | null>();
|
||||
let partial = false;
|
||||
for (const { imageId, labels } of inspected) {
|
||||
map.set(imageId, labels);
|
||||
if (labels === null) partial = true;
|
||||
}
|
||||
return { map, partial };
|
||||
}
|
||||
|
||||
function parseLabelsMap(labels: unknown): Record<string, string> {
|
||||
if (Array.isArray(labels)) {
|
||||
const out: Record<string, string> = {};
|
||||
for (const entry of labels) {
|
||||
const raw = str(entry);
|
||||
if (!raw) continue;
|
||||
const eq = raw.indexOf('=');
|
||||
if (eq === -1) {
|
||||
out[raw] = '';
|
||||
} else {
|
||||
out[raw.slice(0, eq)] = raw.slice(eq + 1);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (labels && typeof labels === 'object') {
|
||||
const out: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(labels as Record<string, unknown>)) {
|
||||
const val = str(v);
|
||||
if (val !== undefined) out[k] = val;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function toLabelValue(
|
||||
key: string,
|
||||
value: string,
|
||||
source: LabelSource,
|
||||
revealSecrets: boolean,
|
||||
): LabelValue {
|
||||
const redacted = redactLabelValue(key, value, revealSecrets);
|
||||
return { key, value: redacted.value, source, ...(redacted.redacted ? { redacted: true } : {}) };
|
||||
}
|
||||
|
||||
function stripContainerName(names: string[] | undefined): string {
|
||||
const first = names?.[0];
|
||||
if (!first) return '';
|
||||
return first.replace(/^\//, '');
|
||||
}
|
||||
|
||||
async function mapWithConcurrency<T, R>(
|
||||
items: T[],
|
||||
limit: number,
|
||||
fn: (item: T) => Promise<R>,
|
||||
): Promise<R[]> {
|
||||
const results: R[] = new Array(items.length);
|
||||
let index = 0;
|
||||
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
||||
while (index < items.length) {
|
||||
const i = index++;
|
||||
results[i] = await fn(items[i]);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
|
||||
function buildByLabelIndex(
|
||||
containers: ContainerLabelRow[],
|
||||
nodeId?: number,
|
||||
nodeName?: string,
|
||||
): LabelIndexRow[] {
|
||||
const map = new Map<string, LabelIndexRow>();
|
||||
for (const container of containers) {
|
||||
for (const label of container.labels) {
|
||||
const mapKey = `${label.key}\0${label.value}\0${label.source}`;
|
||||
let row = map.get(mapKey);
|
||||
if (!row) {
|
||||
row = {
|
||||
key: label.key,
|
||||
value: label.value,
|
||||
source: label.source,
|
||||
...(label.redacted ? { redacted: true } : {}),
|
||||
containers: [],
|
||||
};
|
||||
map.set(mapKey, row);
|
||||
}
|
||||
row.containers.push({
|
||||
id: container.id,
|
||||
name: container.name,
|
||||
stack: container.stack,
|
||||
service: container.service,
|
||||
...(nodeId !== undefined ? { nodeId } : {}),
|
||||
...(nodeName !== undefined ? { nodeName } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...map.values()].sort((a, b) =>
|
||||
a.key.localeCompare(b.key) || a.value.localeCompare(b.value) || a.source.localeCompare(b.source));
|
||||
}
|
||||
|
||||
function reconcileKeys(
|
||||
declared: Record<string, string>,
|
||||
runtime: Record<string, string>,
|
||||
): { onlyInCompose: string[]; onlyOnContainer: string[]; inBoth: string[]; changed: string[] } {
|
||||
const runtimeKeys = new Set(Object.keys(runtime));
|
||||
const onlyInCompose: string[] = [];
|
||||
const onlyOnContainer: string[] = [];
|
||||
const inBoth: string[] = [];
|
||||
const changed: string[] = [];
|
||||
for (const k of Object.keys(declared)) {
|
||||
if (!runtimeKeys.has(k)) onlyInCompose.push(k);
|
||||
else if (declared[k] === runtime[k]) inBoth.push(k);
|
||||
else changed.push(k);
|
||||
}
|
||||
for (const k of runtimeKeys) {
|
||||
if (!(k in declared)) onlyOnContainer.push(k);
|
||||
}
|
||||
onlyInCompose.sort();
|
||||
onlyOnContainer.sort();
|
||||
inBoth.sort();
|
||||
changed.sort();
|
||||
return { onlyInCompose, onlyOnContainer, inBoth, changed };
|
||||
}
|
||||
|
||||
/** Node-wide Docker label inventory for fleet and system routes. */
|
||||
export async function buildNodeLabelInventory(
|
||||
nodeId: number,
|
||||
options: LabelInventoryOptions = {},
|
||||
): Promise<NodeLabelInventory> {
|
||||
const revealSecrets = options.revealSecrets === true;
|
||||
const docker = DockerController.getInstance(nodeId);
|
||||
const rows = await docker.listContainersForLabelInventory();
|
||||
const { map: imageLabelMap, partial: imagePartial } = await buildImageLabelMap(docker, rows.map(r => r.imageId));
|
||||
|
||||
let missingImage = false;
|
||||
const containers: ContainerLabelRow[] = rows.map((row) => {
|
||||
const imageLabels = row.imageId ? (imageLabelMap.get(row.imageId) ?? null) : null;
|
||||
if (!row.imageId) missingImage = true;
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
stack: row.stack,
|
||||
service: row.service,
|
||||
state: row.state,
|
||||
labels: Object.entries(row.labels).map(([key, value]) =>
|
||||
toLabelValue(key, value, resolveLabelSource(key, value, imageLabels), revealSecrets),
|
||||
).sort((a, b) => a.key.localeCompare(b.key)),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
nodeId,
|
||||
containers,
|
||||
byLabel: buildByLabelIndex(containers),
|
||||
partial: rows.some(r => r.inspectFailed) || imagePartial || missingImage,
|
||||
generatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Per-stack declared vs runtime label reconciliation for Stack Anatomy. */
|
||||
export async function buildStackLabelInventory(
|
||||
nodeId: number,
|
||||
stackName: string,
|
||||
options: LabelInventoryOptions = {},
|
||||
): Promise<StackLabelInventory> {
|
||||
const revealSecrets = options.revealSecrets === true;
|
||||
const result = await ComposeService.getInstance(nodeId).renderConfig(stackName);
|
||||
let renderable = false;
|
||||
// A failed render leaves the declared map empty. Without the declared model we cannot
|
||||
// tell a Compose-declared label from a runtime one, so non-system runtime labels are
|
||||
// resolved to `unknown` and reconciliation is skipped rather than shown as false drift.
|
||||
// `renderable === false` (not `partial`) signals this to the UI; `partial` is reserved
|
||||
// for inspection failures so the two banners stay distinct.
|
||||
let renderFailed = false;
|
||||
const declaredByService = new Map<string, Record<string, string>>();
|
||||
|
||||
if (result.rendered !== null) {
|
||||
try {
|
||||
const parsed = JSON.parse(result.rendered) as { services?: Record<string, { labels?: unknown }> };
|
||||
for (const [serviceName, svc] of Object.entries(parsed.services ?? {})) {
|
||||
declaredByService.set(serviceName, parseLabelsMap(svc.labels));
|
||||
}
|
||||
renderable = true;
|
||||
} catch (err) {
|
||||
console.error('[LabelInventory] Failed to parse rendered compose for stack %s:', sanitizeForLog(stackName), err);
|
||||
renderFailed = true;
|
||||
}
|
||||
} else {
|
||||
console.error('[LabelInventory] Compose render failed for stack %s (code %s): %s',
|
||||
sanitizeForLog(stackName), result.code, sanitizeForLog(result.stderr));
|
||||
renderFailed = true;
|
||||
}
|
||||
|
||||
const docker = DockerController.getInstance(nodeId);
|
||||
const stackContainers = await docker.getContainersByStack(stackName) as Array<{ Id?: string; Names?: string[]; State?: string; Service?: string }>;
|
||||
const inspected = await mapWithConcurrency(stackContainers, INSPECT_CONCURRENCY, async (c) => {
|
||||
const id = c.Id ?? '';
|
||||
const name = stripContainerName(c.Names);
|
||||
const state = c.State ?? 'unknown';
|
||||
const service = c.Service ?? null;
|
||||
const result = id ? await docker.inspectContainerLabelsAndImage(id) : null;
|
||||
return { id, name, state, service, labels: result?.labels ?? {}, imageId: result?.imageId ?? '', inspectFailed: result === null };
|
||||
});
|
||||
|
||||
const { map: imageLabelMap, partial: imagePartial } = await buildImageLabelMap(docker, inspected.map(r => r.imageId));
|
||||
let partial = imagePartial;
|
||||
|
||||
const replicasByService = new Map<string, typeof inspected>();
|
||||
for (const replica of inspected) {
|
||||
const svc = replica.service ?? '_unknown';
|
||||
const list = replicasByService.get(svc) ?? [];
|
||||
list.push(replica);
|
||||
replicasByService.set(svc, list);
|
||||
}
|
||||
|
||||
const serviceNames = new Set<string>([
|
||||
...declaredByService.keys(),
|
||||
...replicasByService.keys(),
|
||||
]);
|
||||
serviceNames.delete('_unknown');
|
||||
|
||||
const services: StackServiceLabelRow[] = [...serviceNames].sort().map((service) => {
|
||||
const declared = declaredByService.get(service) ?? {};
|
||||
const declaredLabels = Object.entries(declared)
|
||||
.map(([key, value]) => toLabelValue(key, value, 'compose', revealSecrets))
|
||||
.sort((a, b) => a.key.localeCompare(b.key));
|
||||
|
||||
const replicas: StackLabelReplica[] = (replicasByService.get(service) ?? []).map((rep) => {
|
||||
// A failed inspect has no runtime labels; reconciling against {} would falsely
|
||||
// report every declared label as Compose-only, so skip reconciliation and flag it.
|
||||
if (rep.inspectFailed) {
|
||||
partial = true;
|
||||
return {
|
||||
id: rep.id, name: rep.name, state: rep.state,
|
||||
runtimeLabels: [], onlyInCompose: [], onlyOnContainer: [], inBoth: [], changed: [],
|
||||
inspectFailed: true,
|
||||
};
|
||||
}
|
||||
const imageLabels = rep.imageId ? (imageLabelMap.get(rep.imageId) ?? null) : null;
|
||||
if (!rep.imageId) partial = true;
|
||||
// Without a declared model, only compose-system keys can be attributed with
|
||||
// confidence; everything else is unknown, and reconciliation is skipped.
|
||||
const runtimeLabels = Object.entries(rep.labels)
|
||||
.map(([key, value]) => {
|
||||
const source: LabelSource = renderFailed && !key.startsWith(COMPOSE_SYSTEM_PREFIX)
|
||||
? 'unknown'
|
||||
: resolveLabelSource(key, value, imageLabels, declared);
|
||||
return toLabelValue(key, value, source, revealSecrets);
|
||||
})
|
||||
.sort((a, b) => a.key.localeCompare(b.key));
|
||||
const runtimeMap = Object.fromEntries(Object.entries(rep.labels));
|
||||
const { onlyInCompose, onlyOnContainer, inBoth, changed } = renderFailed
|
||||
? { onlyInCompose: [], onlyOnContainer: [], inBoth: [], changed: [] }
|
||||
: reconcileKeys(declared, runtimeMap);
|
||||
return {
|
||||
id: rep.id,
|
||||
name: rep.name,
|
||||
state: rep.state,
|
||||
runtimeLabels,
|
||||
onlyInCompose,
|
||||
onlyOnContainer,
|
||||
inBoth,
|
||||
changed,
|
||||
};
|
||||
});
|
||||
|
||||
return { service, declaredLabels, replicas };
|
||||
});
|
||||
|
||||
return {
|
||||
stackName,
|
||||
renderable,
|
||||
services,
|
||||
partial,
|
||||
generatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user