From 4a350e7a0ae2f27492f3f526fc3b59a9549f2ba0 Mon Sep 17 00:00:00 2001 From: Anso Date: Fri, 3 Jul 2026 18:26:09 -0400 Subject: [PATCH] feat: add Docker label audit across Fleet and Stack views (#1531) --- .../src/__tests__/docker-controller.test.ts | 53 ++ .../src/__tests__/fleet-read-authz.test.ts | 17 + .../src/__tests__/git-source-service.test.ts | 5 + backend/src/__tests__/label-inventory.test.ts | 574 ++++++++++++++++++ backend/src/helpers/labelInventoryRequest.ts | 18 + backend/src/helpers/labelValueRedaction.ts | 24 + backend/src/helpers/secretClassification.ts | 1 + backend/src/routes/fleet.ts | 165 +++++ backend/src/routes/stacks.ts | 18 + backend/src/routes/systemMaintenance.ts | 16 + backend/src/services/CapabilityRegistry.ts | 1 + backend/src/services/DockerController.ts | 99 +++ backend/src/services/LabelInventoryService.ts | 415 +++++++++++++ docs/docs.json | 1 + docs/features/docker-label-audit.mdx | 55 ++ docs/features/editor.mdx | 1 + docs/openapi.yaml | 243 ++++++++ frontend/src/components/FleetView.tsx | 18 +- frontend/src/components/StackAnatomyPanel.tsx | 10 + .../components/fleet/ContainerLabelsTab.tsx | 380 ++++++++++++ .../__tests__/ContainerLabelsTab.test.tsx | 167 +++++ .../components/stack/ComposeLabelsPanel.tsx | 365 +++++++++++ .../__tests__/ComposeLabelsPanel.test.tsx | 304 ++++++++++ .../src/lib/__tests__/labelInventory.test.ts | 31 + frontend/src/lib/capabilities.ts | 1 + frontend/src/lib/events.ts | 1 + frontend/src/lib/labelInventory.ts | 117 ++++ 27 files changed, 3099 insertions(+), 1 deletion(-) create mode 100644 backend/src/__tests__/label-inventory.test.ts create mode 100644 backend/src/helpers/labelInventoryRequest.ts create mode 100644 backend/src/helpers/labelValueRedaction.ts create mode 100644 backend/src/services/LabelInventoryService.ts create mode 100644 docs/features/docker-label-audit.mdx create mode 100644 frontend/src/components/fleet/ContainerLabelsTab.tsx create mode 100644 frontend/src/components/fleet/__tests__/ContainerLabelsTab.test.tsx create mode 100644 frontend/src/components/stack/ComposeLabelsPanel.tsx create mode 100644 frontend/src/components/stack/__tests__/ComposeLabelsPanel.test.tsx create mode 100644 frontend/src/lib/__tests__/labelInventory.test.ts create mode 100644 frontend/src/lib/labelInventory.ts diff --git a/backend/src/__tests__/docker-controller.test.ts b/backend/src/__tests__/docker-controller.test.ts index f9a0ecff..ff3544f0 100644 --- a/backend/src/__tests__/docker-controller.test.ts +++ b/backend/src/__tests__/docker-controller.test.ts @@ -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', () => { diff --git a/backend/src/__tests__/fleet-read-authz.test.ts b/backend/src/__tests__/fleet-read-authz.test.ts index 75490854..a61c2255 100644 --- a/backend/src/__tests__/fleet-read-authz.test.ts +++ b/backend/src/__tests__/fleet-read-authz.test.ts @@ -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); + }); }); diff --git a/backend/src/__tests__/git-source-service.test.ts b/backend/src/__tests__/git-source-service.test.ts index 09fc0f30..0396f226 100644 --- a/backend/src/__tests__/git-source-service.test.ts +++ b/backend/src/__tests__/git-source-service.test.ts @@ -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 () => { diff --git a/backend/src/__tests__/label-inventory.test.ts b/backend/src/__tests__/label-inventory.test.ts new file mode 100644 index 00000000..2dcac783 --- /dev/null +++ b/backend/src/__tests__/label-inventory.test.ts @@ -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): 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> | 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; + 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 | null> } = {}, +): { inspectImageLabels: ReturnType } { + 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); + } + }); +}); diff --git a/backend/src/helpers/labelInventoryRequest.ts b/backend/src/helpers/labelInventoryRequest.ts new file mode 100644 index 00000000..c3958d8a --- /dev/null +++ b/backend/src/helpers/labelInventoryRequest.ts @@ -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); +} diff --git a/backend/src/helpers/labelValueRedaction.ts b/backend/src/helpers/labelValueRedaction.ts new file mode 100644 index 00000000..d6a1c38b --- /dev/null +++ b/backend/src/helpers/labelValueRedaction.ts @@ -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 }; +} diff --git a/backend/src/helpers/secretClassification.ts b/backend/src/helpers/secretClassification.ts index 7a435cb9..5dd352cd 100644 --- a/backend/src/helpers/secretClassification.ts +++ b/backend/src/helpers/secretClassification.ts @@ -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. */ diff --git a/backend/src/routes/fleet.ts b/backend/src/routes/fleet.ts index d5c9319b..14eb28fa 100644 --- a/backend/src/routes/fleet.ts +++ b/backend/src/routes/fleet.ts @@ -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; + 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; + 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; + 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; + 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; + 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 => { + 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 => { + 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(); + 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 = {}; + 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; diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts index 6a3c36ad..192fa241 100644 --- a/backend/src/routes/stacks.ts +++ b/backend/src/routes/stacks.ts @@ -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 diff --git a/backend/src/routes/systemMaintenance.ts b/backend/src/routes/systemMaintenance.ts index 180fcbb3..137fd816 100644 --- a/backend/src/routes/systemMaintenance.ts +++ b/backend/src/routes/systemMaintenance.ts @@ -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(); diff --git a/backend/src/services/CapabilityRegistry.ts b/backend/src/services/CapabilityRegistry.ts index 16a0d4b5..bef0744f 100644 --- a/backend/src/services/CapabilityRegistry.ts +++ b/backend/src/services/CapabilityRegistry.ts @@ -40,6 +40,7 @@ export const CAPABILITIES = [ 'update-guard', 'compose-networking', 'env-inventory', + 'container-label-inventory', 'project-env-files', 'compose-storage', 'cross-node-rbac', diff --git a/backend/src/services/DockerController.ts b/backend/src/services/DockerController.ts index f3c44f56..47c4a7bd 100644 --- a/backend/src/services/DockerController.ts +++ b/backend/src/services/DockerController.ts @@ -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; + 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(containers); } + /** Runtime labels + image ref from container inspect. Null (logged) when inspect fails. */ + public async inspectContainerLabelsAndImage( + containerId: string, + ): Promise<{ labels: Record; 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 } | 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 { + const knownStacks = await FileSystemService.getInstance(this.nodeId).getStacks(); + const listed = await this.getAllContainers() as Array<{ + Id?: string; + Names?: string[]; + State?: string; + Labels?: Record; + 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; + 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; diff --git a/backend/src/services/LabelInventoryService.ts b/backend/src/services/LabelInventoryService.ts new file mode 100644 index 00000000..7e5b0d84 --- /dev/null +++ b/backend/src/services/LabelInventoryService.ts @@ -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 = new Set([ + '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 | null, + declared?: Record, +): 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 | 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 | 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 { + if (Array.isArray(labels)) { + const out: Record = {}; + 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 = {}; + for (const [k, v] of Object.entries(labels as Record)) { + 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( + items: T[], + limit: number, + fn: (item: T) => Promise, +): Promise { + 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(); + 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, + runtime: Record, +): { 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 { + 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 { + 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>(); + + if (result.rendered !== null) { + try { + const parsed = JSON.parse(result.rendered) as { services?: Record }; + 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(); + 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([ + ...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(), + }; +} diff --git a/docs/docs.json b/docs/docs.json index 5b66799c..b1ef8d73 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -108,6 +108,7 @@ "features/compose-doctor", "features/compose-networking", "features/environment-guardrails", + "features/docker-label-audit", "features/compose-storage", "features/stack-labels", "features/sidebar" diff --git a/docs/features/docker-label-audit.mdx b/docs/features/docker-label-audit.mdx new file mode 100644 index 00000000..d37f456a --- /dev/null +++ b/docs/features/docker-label-audit.mdx @@ -0,0 +1,55 @@ +--- +title: "Docker Label Audit" +description: "Audit Docker and Compose labels that drive external automation across the fleet and inside each stack." +--- + +Docker labels are metadata declared on Compose services or attached to running containers. They are different from Sencho Stack Labels used for organizing stacks and Node Labels used for Blueprint placement. + +Sencho surfaces two read-only audit views for these labels: + +- **Fleet · Docker Labels** for estate-wide visibility across every node +- **Stack · Compose Labels** for per-stack reconciliation between declared Compose labels and labels present on running containers + +## Fleet Docker Labels tab + +Open **Fleet** and select the **Docker Labels** tab (after **Map**). The panel is titled **Docker label audit** and offers two layouts: + +| View | What it shows | +|------|----------------| +| **By container** | Each container with its label count. Expand a row to see every key, value, and provenance badge. | +| **By label** | Each unique `key=value` pair and every container that carries it, with node name chips on multi-node fleets. | + +Use the search box to filter by label key, value, container name, stack, or node. When a container belongs to a known stack, **Open stack** jumps to that stack in the editor. + +Every label carries a provenance badge so you can tell where it came from: + +- **Image** for labels inherited from the container image (for example OCI `org.opencontainers.image.*` metadata) +- **Present at runtime** for other labels set on the running container +- **Docker Compose system label** for keys starting with `com.docker.compose.` +- **Unknown** when a container or its image could not be inspected + +The fleet view reads container-level metadata and does not open the compose file, so it cannot tell a Compose-declared label from any other label set on the container: labels you declared in Compose appear here as **Present at runtime**. To see which labels come from the compose file, use the per-stack **Compose Labels** tab below. When a container or its image cannot be inspected, the affected labels show as **Unknown** and the panel names the nodes it could not fully inspect. + +In the **By label** layout, open the **Filters** popover next to the search box. The **Defined by** section lists toggle pills for each provenance type present in the data (**Image**, **Runtime**, **System**, and **Unknown** when present). Turn a pill off to hide that source; the button shows a count badge while any filter is active, and **Clear filters** resets the popover. Automation tools such as Watchtower, Diun, and Traefik read these labels, so the audit makes it easy to confirm which containers are opted in or out. + +Runtime labels are static until the container is recreated. Changes declared in Compose require save and redeploy before they appear on running containers. + +## Stack Compose Labels tab + +Inside the stack editor, open the **Compose Labels** tab in the anatomy strip. For each service you see: + +- **Declared in Compose** labels from the effective rendered compose model +- **Present at runtime** labels read from each running replica, each with its provenance badge (Compose, Image, or runtime) +- Reconciliation hints: **only in Compose**, **only on running container**, **present in both**, or **value changed** when a key is declared and running but the values differ + +Because this tab renders the compose model, it can identify Compose-declared labels accurately. A toolbar at the top combines a **search box** with a **Filters** popover. The search matches label keys and values as well as service and container names. The popover has a **Defined by** section with toggle pills for each provenance present (**Compose File**, **Image**, **Runtime**, **System**) and a **Services** section to show or hide individual service cards when the stack has more than one service. The Filters button shows a count badge for active facet and service filters; **Clear filters** resets both sections. Matching a service or container name reveals that parent's labels even when the text does not match a specific key or value. Reconciliation counts always reflect only the labels currently visible. + +When Compose cannot be fully rendered, the panel warns that declared labels may be incomplete but still shows whatever runtime data is available. If a replica cannot be inspected, it is flagged with **Runtime labels unavailable** and the panel notes that provenance may be incomplete. + +## Sensitive values + +Some label keys look like secrets (for example keys containing `token`, `password`, or `auth`). Their values are redacted by default. Admins can reveal full values with the **Reveal** control, which re-fetches the inventory with elevated read access. + +## Editing labels + +Compose label editing from these panels is not available yet. To change labels today, edit the compose file directly and redeploy the stack. diff --git a/docs/features/editor.mdx b/docs/features/editor.mdx index 8ed0ce74..431a8090 100644 --- a/docs/features/editor.mdx +++ b/docs/features/editor.mdx @@ -80,6 +80,7 @@ The tab row always shows four tabs: **Anatomy**, **Activity**, **Dossier**, and | **Dossier** | Yes | Exportable Markdown of the anatomy combined with operator notes. See [Stack Dossier](/features/stack-dossier). | | **Drift** | Yes | Live comparison of the declared compose against the running containers. See [Stack Drift](/features/stack-drift). | | **Environment** | When `env-inventory` capability is present | Variable inventory across all env files, with status for each variable. See [Environment Guardrails](/features/environment-guardrails). | +| **Compose Labels** | When `container-label-inventory` capability is present | Declared Compose labels vs runtime container labels per service. See [Docker Label Audit](/features/docker-label-audit). | | **Networking** | When `compose-networking` capability is present | Port exposure summary per service with intent classification. See [Compose Networking](/features/compose-networking). | | **Doctor** | When `compose-doctor` capability is present | Preflight check results grouped by severity. The tab gains a red dot for blocker findings and an amber dot for high-risk findings. See [Compose Doctor](/features/compose-doctor). | | **Storage** | When `compose-storage` capability is present | Mount inventory with portability assessment and snapshot coverage. See [Compose Storage](/features/compose-storage). | diff --git a/docs/openapi.yaml b/docs/openapi.yaml index af88cc97..eb731ed9 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -149,6 +149,148 @@ components: type: boolean example: true + LabelSource: + type: string + description: Provenance of a label. `unknown` when a container or image could not be inspected. + enum: [compose, runtime, image, compose-system, unknown] + + LabelValue: + type: object + required: [key, value, source] + properties: + key: { type: string } + value: + type: string + description: Redacted to `[redacted]` for secret-like keys unless the caller is an admin and passes `reveal=1`. + source: { $ref: "#/components/schemas/LabelSource" } + redacted: { type: boolean } + + LabelIndexContainerRef: + type: object + required: [id, name, stack, service] + properties: + id: { type: string } + name: { type: string } + stack: { type: string, nullable: true } + service: { type: string, nullable: true } + nodeId: { type: integer } + nodeName: { type: string } + + LabelIndexRow: + type: object + description: One unique key/value/source and every container carrying it. + required: [key, value, source, containers] + properties: + key: { type: string } + value: { type: string } + source: { $ref: "#/components/schemas/LabelSource" } + redacted: { type: boolean } + containers: + type: array + items: { $ref: "#/components/schemas/LabelIndexContainerRef" } + + ContainerLabelRow: + type: object + required: [id, name, stack, service, state, labels] + properties: + id: { type: string } + name: { type: string } + stack: { type: string, nullable: true } + service: { type: string, nullable: true } + state: { type: string } + labels: + type: array + items: { $ref: "#/components/schemas/LabelValue" } + + StackLabelReplica: + type: object + required: [id, name, state, runtimeLabels, onlyInCompose, onlyOnContainer, inBoth, changed] + properties: + id: { type: string } + name: { type: string } + state: { type: string } + runtimeLabels: + type: array + items: { $ref: "#/components/schemas/LabelValue" } + onlyInCompose: { type: array, items: { type: string } } + onlyOnContainer: { type: array, items: { type: string } } + inBoth: { type: array, items: { type: string } } + changed: + type: array + items: { type: string } + description: Keys declared in Compose and present at runtime but with a different value. + inspectFailed: + type: boolean + description: Runtime labels could not be read for this replica; reconciliation was skipped. + + StackServiceLabelRow: + type: object + required: [service, declaredLabels, replicas] + properties: + service: { type: string } + declaredLabels: + type: array + items: { $ref: "#/components/schemas/LabelValue" } + replicas: + type: array + items: { $ref: "#/components/schemas/StackLabelReplica" } + + StackLabelInventory: + type: object + required: [stackName, renderable, services, partial, generatedAt] + properties: + stackName: { type: string } + renderable: + type: boolean + description: False when the Compose model could not be rendered; declared provenance is then unknown. + partial: + type: boolean + description: A replica or its image could not be fully inspected. + generatedAt: { type: integer } + services: + type: array + items: { $ref: "#/components/schemas/StackServiceLabelRow" } + + NodeLabelInventory: + type: object + required: [nodeId, containers, byLabel, partial, generatedAt] + properties: + nodeId: { type: integer } + partial: { type: boolean } + generatedAt: { type: integer } + containers: + type: array + items: { $ref: "#/components/schemas/ContainerLabelRow" } + byLabel: + type: array + items: { $ref: "#/components/schemas/LabelIndexRow" } + + FleetLabelInventory: + type: object + required: [nodes, aggregatedByLabel, nodeErrors, generatedAt] + properties: + generatedAt: { type: integer } + nodes: + type: array + items: + type: object + required: [nodeId, nodeName, status, inventory, error] + properties: + nodeId: { type: integer } + nodeName: { type: string } + status: { type: string, enum: [ok, error] } + inventory: + nullable: true + allOf: [{ $ref: "#/components/schemas/NodeLabelInventory" }] + error: { type: string, nullable: true } + aggregatedByLabel: + type: array + items: { $ref: "#/components/schemas/LabelIndexRow" } + nodeErrors: + type: object + additionalProperties: { type: string } + description: Map of node id to error for nodes that were unreachable or returned a malformed payload. + FailureClassification: type: object description: Classified cause of a failed deploy or update, with a suggested next step. @@ -947,6 +1089,107 @@ paths: "500": $ref: "#/components/responses/InternalError" + /api/stacks/{stackName}/label-inventory: + get: + operationId: getStackLabelInventory + tags: [Stacks] + summary: Get Docker label inventory for a stack + description: >- + Returns declared Compose labels and runtime container labels per service, + with reconciliation hints (only in Compose, only on running container, + present in both, or value changed when the values differ) and provenance + for each runtime label (compose, image, runtime, compose-system, or unknown + when a container or image cannot be inspected). Secret-like label values are + redacted unless the caller is an admin and passes `reveal=1`. Requires + `stack:read` permission. + parameters: + - $ref: "#/components/parameters/stackName" + - $ref: "#/components/parameters/nodeId" + - name: reveal + in: query + required: false + schema: + type: string + enum: ['1', 'true'] + responses: + "200": + description: Stack label inventory. + content: + application/json: + schema: + $ref: "#/components/schemas/StackLabelInventory" + "403": + $ref: "#/components/responses/Forbidden" + "404": + description: Stack not found. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "500": + $ref: "#/components/responses/InternalError" + + /api/system/container-labels: + get: + operationId: getNodeContainerLabels + tags: [Fleet] + summary: Get the Docker label inventory for the active node + description: >- + Returns every container on the node with its labels and provenance + (compose, image, runtime, compose-system, or unknown), plus an inverted + `byLabel` index. Marks `partial` when a container or image inspect fails. + Secret-like values are redacted unless the caller is an admin and passes + `reveal=1`. Requires `node:read` permission. + parameters: + - $ref: "#/components/parameters/nodeId" + - name: reveal + in: query + required: false + schema: + type: string + enum: ['1', 'true'] + responses: + "200": + description: Node container label inventory. + content: + application/json: + schema: + $ref: "#/components/schemas/NodeLabelInventory" + "403": + $ref: "#/components/responses/Forbidden" + "500": + $ref: "#/components/responses/InternalError" + + /api/fleet/container-labels: + get: + operationId: getFleetContainerLabels + tags: [Fleet] + summary: Get the Docker label inventory aggregated across the fleet + description: >- + Fans out to every node's container label inventory and aggregates the + inverted index by key, value, and source. Unreachable or malformed nodes + degrade into `nodeErrors` rather than failing the whole request. Secret-like + values are redacted unless the caller is an admin and passes `reveal=1`. + Requires `node:read` permission. + parameters: + - name: reveal + in: query + required: false + schema: + type: string + enum: ['1', 'true'] + responses: + "200": + description: Fleet-wide container label inventory. + content: + application/json: + schema: + $ref: "#/components/schemas/FleetLabelInventory" + "403": + $ref: "#/components/responses/Forbidden" + "500": + $ref: "#/components/responses/InternalError" + /api/stacks/{stackName}/env: get: operationId: getStackEnv diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx index 8661767e..deed0276 100644 --- a/frontend/src/components/FleetView.tsx +++ b/frontend/src/components/FleetView.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'; import { RefreshCw, Camera, FileDown, Network, SlidersHorizontal, - Send, KeyRound, ArrowLeftRight, Wrench, Workflow, + Send, KeyRound, ArrowLeftRight, Wrench, Workflow, Tag, } from 'lucide-react'; import { FleetMasthead } from './fleet/FleetMasthead'; import { ReconnectingOverlay } from './FleetView/ReconnectingOverlay'; @@ -20,6 +20,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger, TabsHighlight, TabsHighlightI import { springs } from '@/lib/motion'; import { useLicense } from '@/context/LicenseContext'; import { useAuth } from '@/context/AuthContext'; +import { useNodes } from '@/context/NodeContext'; import { PaidGate } from './PaidGate'; import FleetSnapshots from './FleetSnapshots'; import { FleetConfiguration } from './fleet/FleetConfiguration'; @@ -29,6 +30,7 @@ import { DeploymentsTab } from './blueprints/DeploymentsTab'; import { FleetActionsTab } from './fleet/FleetActions/FleetActionsTab'; import { SecretsTab } from './fleet/secrets/SecretsTab'; import { DependencyMapTab } from './fleet/DependencyMapTab'; +import { ContainerLabelsTab } from './fleet/ContainerLabelsTab'; import { useNodeActions } from './nodes/useNodeActions'; import type { FleetTab } from '@/lib/events'; import type { SectionId } from '@/components/settings/types'; @@ -49,6 +51,8 @@ interface FleetViewProps { export function FleetView({ onNavigateToNode, onOpenSettingsSection, onOpenMuteRulesWithPrefill, fleetUpdatesIntent, onFleetUpdatesIntentConsumed, fleetTab, onFleetTabConsumed }: FleetViewProps) { const { isPaid } = useLicense(); const { isAdmin } = useAuth(); + const { hasCapability } = useNodes(); + const containerLabelsEnabled = hasCapability('container-label-inventory'); const { prefs, updatePrefs } = useFleetPreferences(); const updateStatus = useFleetUpdateStatus(); @@ -132,6 +136,13 @@ export function FleetView({ onNavigateToNode, onOpenSettingsSection, onOpenMuteR Map + {containerLabelsEnabled && ( + + + Docker Labels + + + )} {isPaid && ( @@ -245,6 +256,11 @@ export function FleetView({ onNavigateToNode, onOpenSettingsSection, onOpenMuteR + {containerLabelsEnabled && ( + + + + )} {isPaid && ( diff --git a/frontend/src/components/StackAnatomyPanel.tsx b/frontend/src/components/StackAnatomyPanel.tsx index 1ee8b61b..5a5a38ac 100644 --- a/frontend/src/components/StackAnatomyPanel.tsx +++ b/frontend/src/components/StackAnatomyPanel.tsx @@ -15,6 +15,7 @@ import DriftPanel from './stack/DriftPanel'; import PreflightPanel from './stack/PreflightPanel'; import StoragePanel from './stack/StoragePanel'; import EnvironmentPanel from './stack/EnvironmentPanel'; +import ComposeLabelsPanel from './stack/ComposeLabelsPanel'; import StackNetworkingPanel from './stack/StackNetworkingPanel'; import { useNodes } from '@/context/NodeContext'; import type { NotificationItem } from '@/components/dashboard/types'; @@ -101,6 +102,7 @@ export default function StackAnatomyPanel({ const networkingEnabled = hasCapability('compose-networking'); const storageEnabled = hasCapability('compose-storage'); const envInventoryEnabled = hasCapability('env-inventory'); + const composeLabelsEnabled = hasCapability('container-label-inventory'); const [gitSource, setGitSource] = useState<{ stack: string; info: GitSourceInfo; multiFile: boolean } | null>(null); // Merged effective facts (services/ports/volumes/networks/restart) for a @@ -370,6 +372,9 @@ export default function StackAnatomyPanel({ {envInventoryEnabled && ( Environment )} + {composeLabelsEnabled && ( + Compose Labels + )} {networkingEnabled && ( Networking )} @@ -623,6 +628,11 @@ export default function StackAnatomyPanel({ )} + {composeLabelsEnabled && ( + + + + )} {doctorEnabled && ( diff --git a/frontend/src/components/fleet/ContainerLabelsTab.tsx b/frontend/src/components/fleet/ContainerLabelsTab.tsx new file mode 100644 index 00000000..1d384399 --- /dev/null +++ b/frontend/src/components/fleet/ContainerLabelsTab.tsx @@ -0,0 +1,380 @@ +import { useCallback, useEffect, useMemo, useState, Fragment } from 'react'; +import { ChevronDown, ChevronRight, ExternalLink, Lock, RefreshCw, Search, SlidersHorizontal } from 'lucide-react'; +import { apiFetch } from '@/lib/api'; +import { toast } from '@/components/ui/toast-store'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { SegmentedControl } from '@/components/ui/segmented-control'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { Badge } from '@/components/ui/badge'; +import { useAuth } from '@/context/AuthContext'; +import { + LABEL_DISAMBIGUATION_COPY, + SOURCE_LABELS, + SOURCE_FACET_LABELS, + matchesSearch, + sourcesPresent, + type ContainerLabelRow, + type FleetLabelInventoryResponse, + type LabelIndexRow, + type LabelSource, + type LabelValue, +} from '@/lib/labelInventory'; + +type ViewMode = 'container' | 'label'; + +const FILTER_SECTION_LABEL_CLASS = 'text-[10px] leading-3 font-mono uppercase tracking-[0.18em] text-stat-subtitle'; + +interface ContainerLabelsTabProps { + onNavigateToNode: (nodeId: number, stackName: string) => void; +} + +function LabelValueCell({ label, onReveal }: { label: LabelValue; onReveal?: () => void }) { + const { isAdmin } = useAuth(); + return ( + + {label.redacted && } + {label.value} + {label.redacted && isAdmin && onReveal && ( + + )} + + ); +} + +export function ContainerLabelsTab({ onNavigateToNode }: ContainerLabelsTabProps) { + const { isAdmin } = useAuth(); + const [viewMode, setViewMode] = useState('container'); + const [search, setSearch] = useState(''); + const [loading, setLoading] = useState(true); + const [revealSecrets, setRevealSecrets] = useState(false); + const [data, setData] = useState(null); + const [expanded, setExpanded] = useState>(new Set()); + const [excludedSources, setExcludedSources] = useState>(new Set()); + + const fetchInventory = useCallback(async (reveal = revealSecrets) => { + setLoading(true); + try { + const qs = reveal ? '?reveal=1' : ''; + const res = await apiFetch(`/fleet/container-labels${qs}`, { localOnly: true }); + if (!res.ok) throw new Error('Failed to load Docker label audit'); + setData(await res.json() as FleetLabelInventoryResponse); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Failed to load Docker label audit'; + toast.error(msg); + setData(null); + } finally { + setLoading(false); + } + }, [revealSecrets]); + + useEffect(() => { + void fetchInventory(); + }, [fetchInventory]); + + const allContainers = useMemo(() => { + if (!data) return [] as Array; + const rows: Array = []; + for (const node of data.nodes) { + if (node.status !== 'ok' || !node.inventory) continue; + for (const c of node.inventory.containers) { + rows.push({ ...c, nodeId: node.nodeId, nodeName: node.nodeName }); + } + } + return rows; + }, [data]); + + const filteredContainers = useMemo(() => { + return allContainers.filter(c => + matchesSearch(search, c.name, c.stack, c.service, c.state, c.nodeName) + || c.labels.some(l => matchesSearch(search, l.key, l.value)), + ); + }, [allContainers, search]); + + // Sources present across the aggregated index, for the data-driven facet row. Derived from + // the unfiltered data so toggling a facet off never removes the facet itself. + const labelSourceFacets = useMemo( + () => sourcesPresent((data?.aggregatedByLabel ?? []).map(r => r.source)), + [data], + ); + + const filteredByLabel = useMemo(() => { + const source = data?.aggregatedByLabel ?? []; + return source.filter(row => + !excludedSources.has(row.source) + && (matchesSearch(search, row.key, row.value) + || row.containers.some(c => matchesSearch(search, c.name, c.stack, c.nodeName))), + ); + }, [data, search, excludedSources]); + + const toggleSource = (s: LabelSource) => { + setExcludedSources(prev => { + const next = new Set(prev); + if (next.has(s)) next.delete(s); else next.add(s); + return next; + }); + }; + + // Nodes that were unreachable (nodeErrors) or whose inventory came back partial (some + // containers or images could not be inspected). Named so the warning is truthful. + const degradedNodes = useMemo(() => { + const unreachable: string[] = []; + const partial: string[] = []; + if (data) { + const nameById = new Map(data.nodes.map(n => [n.nodeId, n.nodeName] as const)); + for (const idStr of Object.keys(data.nodeErrors)) { + unreachable.push(nameById.get(Number(idStr)) ?? `node ${idStr}`); + } + for (const n of data.nodes) { + if (n.status === 'ok' && n.inventory?.partial) partial.push(n.nodeName); + } + } + return { unreachable, partial }; + }, [data]); + + const toggleExpanded = (id: string) => { + setExpanded(prev => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const handleReveal = () => { + setRevealSecrets(true); + void fetchInventory(true); + }; + + return ( +
+

{LABEL_DISAMBIGUATION_COPY}

+ +
+
+

Docker label audit

+ setViewMode(v as ViewMode)} + options={[ + { value: 'container', label: 'By container' }, + { value: 'label', label: 'By label' }, + ]} + /> +
+
+
+ + setSearch(e.target.value)} + placeholder="Filter labels, containers, stacks..." + className="h-9 w-56 pl-8 max-md:w-full" + /> +
+ {viewMode === 'label' && labelSourceFacets.length > 0 && ( + + + + + +
+ +
+ {labelSourceFacets.map((s) => ( + + ))} +
+
+ {excludedSources.size > 0 && ( + + )} +
+
+ )} + +
+
+ + {loading && !data && ( +

Loading Docker label audit...

+ )} + + {!loading && data && (degradedNodes.unreachable.length > 0 || degradedNodes.partial.length > 0) && ( +

+ {degradedNodes.unreachable.length > 0 && `Could not reach ${degradedNodes.unreachable.join(', ')}. `} + {degradedNodes.partial.length > 0 && `Some containers or images could not be inspected on ${degradedNodes.partial.join(', ')}. `} + Showing partial fleet data. +

+ )} + + {viewMode === 'container' && ( +
+ + + + + Container + Stack + Node + State + Labels + + + + + {filteredContainers.map((c) => { + const rowKey = `${c.nodeId}:${c.id}`; + const isOpen = expanded.has(rowKey); + return ( + + + + + + {c.name || c.id.slice(0, 12)} + {c.stack ?? '—'} + {c.nodeName} + {c.state} + {c.labels.length} + + {c.stack && ( + + )} + + + {isOpen && ( + + +
+ + {c.labels.map((label) => ( + + {label.key} + + + + {SOURCE_LABELS[label.source]} + + ))} + {c.labels.length === 0 && ( + + No labels on this container. + + )} + +
+ + + )} + + ); + })} + {filteredContainers.length === 0 && !loading && ( + + No containers match this filter. + + )} + + +
+ )} + + {viewMode === 'label' && ( +

+ Fleet shows container-level provenance and cannot distinguish Compose-declared labels from other container-set labels, so those appear as "Present at runtime". +

+ )} + + {viewMode === 'label' && ( +
+ {filteredByLabel.map((row) => ( + + ))} + {filteredByLabel.length === 0 && !loading && ( +

No labels match this filter.

+ )} +
+ )} + +

+ Runtime labels are static until the container is recreated. Changes in Compose require save and redeploy. +

+
+ ); +} + +function LabelGroupCard({ + row, + onReveal, + onNavigateToNode, +}: { + row: LabelIndexRow; + onReveal?: () => void; + onNavigateToNode: (nodeId: number, stackName: string) => void; +}) { + return ( +
+
+ {row.key} + = + + {SOURCE_LABELS[row.source]} +
+
    + {row.containers.map((c) => ( +
  • + {c.name} + {c.nodeName && {c.nodeName}} + {c.stack && ( + <> + · {c.stack} + {c.nodeId !== undefined && ( + + )} + + )} +
  • + ))} +
+
+ ); +} diff --git a/frontend/src/components/fleet/__tests__/ContainerLabelsTab.test.tsx b/frontend/src/components/fleet/__tests__/ContainerLabelsTab.test.tsx new file mode 100644 index 00000000..4a6819de --- /dev/null +++ b/frontend/src/components/fleet/__tests__/ContainerLabelsTab.test.tsx @@ -0,0 +1,167 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ContainerLabelsTab } from '../ContainerLabelsTab'; +import { LABEL_DISAMBIGUATION_COPY } from '@/lib/labelInventory'; + +vi.mock('@/lib/api', () => ({ + apiFetch: vi.fn(), +})); + +vi.mock('@/context/AuthContext', () => ({ + useAuth: () => ({ isAdmin: true }), +})); + +import { apiFetch } from '@/lib/api'; + +const mockFleet = { + nodes: [ + { + nodeId: 1, + nodeName: 'Local', + status: 'ok' as const, + error: null, + inventory: { + nodeId: 1, + partial: false, + generatedAt: Date.now(), + containers: [ + { + id: 'c1', + name: 'web-1', + stack: 'demo', + service: 'web', + state: 'running', + labels: [{ key: 'traefik.enable', value: 'true', source: 'runtime' as const }], + }, + ], + byLabel: [], + }, + }, + ], + aggregatedByLabel: [ + { + key: 'traefik.enable', + value: 'true', + source: 'runtime' as const, + containers: [{ id: 'c1', name: 'web-1', stack: 'demo', service: 'web', nodeId: 1, nodeName: 'Local' }], + }, + ], + nodeErrors: {}, + generatedAt: Date.now(), +}; + +// Two rows sharing the same key=value but distinct sources (image vs runtime). +const dupMock = { + nodes: [{ nodeId: 1, nodeName: 'Local', status: 'ok' as const, error: null, inventory: { nodeId: 1, partial: false, generatedAt: Date.now(), containers: [], byLabel: [] } }], + aggregatedByLabel: [ + { key: 'dup.label', value: 'v', source: 'image' as const, containers: [{ id: 'c1', name: 'a-1', stack: 's', service: 'a', nodeId: 1, nodeName: 'Local' }] }, + { key: 'dup.label', value: 'v', source: 'runtime' as const, containers: [{ id: 'c2', name: 'b-1', stack: 's', service: 'b', nodeId: 1, nodeName: 'Local' }] }, + ], + nodeErrors: {}, + generatedAt: Date.now(), +}; + +describe('ContainerLabelsTab', () => { + beforeEach(() => { + vi.mocked(apiFetch).mockResolvedValue({ + ok: true, + json: async () => mockFleet, + } as Response); + }); + + it('renders audit sections and toggles view mode', async () => { + const user = userEvent.setup(); + render(); + expect(await screen.findByText(LABEL_DISAMBIGUATION_COPY)).toBeInTheDocument(); + expect(screen.getByText('Docker label audit')).toBeInTheDocument(); + expect(await screen.findByText('web-1')).toBeInTheDocument(); + + await user.click(screen.getByText('By label')); + expect(await screen.findByText('traefik.enable')).toBeInTheDocument(); + }); + + it('keeps the same key=value distinct per source with its own badge', async () => { + const user = userEvent.setup(); + vi.mocked(apiFetch).mockResolvedValue({ ok: true, json: async () => dupMock } as Response); + render(); + await user.click(await screen.findByText('By label')); + expect(screen.getAllByText('dup.label')).toHaveLength(2); + // The misleading "External automation label" marker on non-Compose keys was removed. + expect(screen.queryByText(/External automation label/)).toBeNull(); + }); + + it('filters the by-label list by search text', async () => { + const user = userEvent.setup(); + render(); + await user.click(await screen.findByText('By label')); + expect(screen.getByText('traefik.enable')).toBeInTheDocument(); + await user.type(screen.getByPlaceholderText(/Filter labels/), 'nope'); + expect(screen.queryByText('traefik.enable')).toBeNull(); + expect(screen.getByText('No labels match this filter.')).toBeInTheDocument(); + }); + + it('filters the by-container list by search text', async () => { + const user = userEvent.setup(); + render(); + expect(await screen.findByText('web-1')).toBeInTheDocument(); + await user.type(screen.getByPlaceholderText(/Filter labels/), 'nope'); + expect(screen.queryByText('web-1')).toBeNull(); + expect(screen.getByText('No containers match this filter.')).toBeInTheDocument(); + }); + + it('exposes a facet in the Filters popover only for sources present, with exact labels', async () => { + const user = userEvent.setup(); + render(); + await user.click(await screen.findByText('By label')); + await user.click(screen.getByRole('button', { name: /Filters/ })); + // mockFleet has only a runtime source. + expect(screen.getByRole('button', { name: 'Runtime' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Image' })).toBeNull(); + }); + + it('filters the by-label list by source facet, hiding an unpressed source', async () => { + const user = userEvent.setup(); + vi.mocked(apiFetch).mockResolvedValue({ ok: true, json: async () => dupMock } as Response); + render(); + await user.click(await screen.findByText('By label')); + expect(screen.getAllByText('dup.label')).toHaveLength(2); + await user.click(screen.getByRole('button', { name: /Filters/ })); + const imageBtn = screen.getByRole('button', { name: 'Image' }); + expect(imageBtn).toHaveAttribute('aria-pressed', 'true'); + + await user.click(imageBtn); + expect(screen.getAllByText('dup.label')).toHaveLength(1); + // The facet stays present (just unpressed) because facets derive from unfiltered data. + expect(screen.getByRole('button', { name: 'Image' })).toHaveAttribute('aria-pressed', 'false'); + }); + + it('shows the empty state when every source facet is turned off', async () => { + const user = userEvent.setup(); + vi.mocked(apiFetch).mockResolvedValue({ ok: true, json: async () => dupMock } as Response); + render(); + await user.click(await screen.findByText('By label')); + await user.click(screen.getByRole('button', { name: /Filters/ })); + await user.click(screen.getByRole('button', { name: 'Image' })); + await user.click(screen.getByRole('button', { name: 'Runtime' })); + expect(screen.getByText('No labels match this filter.')).toBeInTheDocument(); + }); + + it('names unreachable and partial nodes in the warning', async () => { + vi.mocked(apiFetch).mockResolvedValue({ + ok: true, + json: async () => ({ + nodes: [ + { nodeId: 1, nodeName: 'Local', status: 'ok' as const, error: null, inventory: { nodeId: 1, partial: true, generatedAt: Date.now(), containers: [], byLabel: [] } }, + { nodeId: 2, nodeName: 'Edge', status: 'error' as const, error: 'boom', inventory: null }, + ], + aggregatedByLabel: [], + nodeErrors: { 2: 'boom' }, + generatedAt: Date.now(), + }), + } as Response); + render(); + expect(await screen.findByText(/Could not reach Edge/)).toBeInTheDocument(); + expect(screen.getByText(/could not be inspected on Local/)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/stack/ComposeLabelsPanel.tsx b/frontend/src/components/stack/ComposeLabelsPanel.tsx new file mode 100644 index 00000000..d72d9834 --- /dev/null +++ b/frontend/src/components/stack/ComposeLabelsPanel.tsx @@ -0,0 +1,365 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Info, Lock, Search, SlidersHorizontal } from 'lucide-react'; +import { apiFetch } from '@/lib/api'; +import { toast } from '@/components/ui/toast-store'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Input } from '@/components/ui/input'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { useAuth } from '@/context/AuthContext'; +import { + LABEL_DISAMBIGUATION_COPY, + SOURCE_LABELS, + SOURCE_FACET_LABELS, + matchesSearch, + sourcesPresent, + type LabelSource, + type LabelValue, + type StackLabelInventory, +} from '@/lib/labelInventory'; + +const LABEL_CLASS = 'font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle'; +const CARD_CLASS = 'rounded-lg border border-muted px-3 py-2.5'; +const FILTER_SECTION_LABEL_CLASS = 'text-[10px] leading-3 font-mono uppercase tracking-[0.18em] text-stat-subtitle'; + +/** + * A label is visible when its source is not excluded AND either the text matches its + * key/value or the parent matched the search (a service by name, a replica by name or id). + * Parent matches bypass text matching only, never the source facets. + */ +function labelVisible(label: LabelValue, search: string, excluded: Set, parentHit: boolean): boolean { + if (excluded.has(label.source)) return false; + return parentHit || matchesSearch(search, label.key, label.value); +} + +function LabelRow({ label, onReveal }: { label: LabelValue; onReveal?: () => void }) { + const { isAdmin } = useAuth(); + return ( +
+ {label.key} + = + + {label.redacted && } + {label.value} + + + {SOURCE_LABELS[label.source]} + + {label.redacted && isAdmin && onReveal && ( + + )} +
+ ); +} + +function MismatchBadge({ kind, count }: { kind: 'only-compose' | 'only-container' | 'both' | 'changed'; count: number }) { + const labels = { + 'only-compose': 'only in Compose', + 'only-container': 'only on running container', + both: 'present in both', + changed: 'value changed', + }; + const tones = { + 'only-compose': 'border-warning/40 bg-warning/[0.06] text-warning', + 'only-container': 'border-info/40 bg-info/[0.06] text-info', + both: 'border-muted bg-card/40 text-stat-subtitle', + changed: 'border-warning/40 bg-warning/[0.06] text-warning', + }; + if (count === 0) return null; + return ( + + {count} {labels[kind]} + + ); +} + +export default function ComposeLabelsPanel({ stackName }: { stackName: string }) { + const { isAdmin } = useAuth(); + const [inventory, setInventory] = useState(null); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(false); + const [revealSecrets, setRevealSecrets] = useState(false); + const [search, setSearch] = useState(''); + const [excludedSources, setExcludedSources] = useState>(new Set()); + const [hiddenServices, setHiddenServices] = useState>(new Set()); + + const fetchInventory = useCallback(async (reveal = revealSecrets) => { + setLoading(true); + setLoadError(false); + try { + const qs = reveal ? '?reveal=1' : ''; + const res = await apiFetch(`/stacks/${stackName}/label-inventory${qs}`); + if (!res.ok) { + setLoadError(true); + throw new Error('Failed to load Compose label inventory'); + } + setInventory(await res.json() as StackLabelInventory); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to load Compose label inventory'); + setInventory(null); + } finally { + setLoading(false); + } + }, [stackName, revealSecrets]); + + useEffect(() => { + void fetchInventory(); + }, [fetchInventory]); + + // Reset filters when the stack changes so selections never leak between stacks. Not keyed on + // reveal/refetch, so filters are preserved across a reveal on the same stack. + useEffect(() => { + setSearch(''); + setExcludedSources(new Set()); + setHiddenServices(new Set()); + }, [stackName]); + + const filtersActive = search.trim() !== '' || excludedSources.size > 0; + // Count of active facet filters for the Filters popover badge (search is its own control). + const filterCount = excludedSources.size + hiddenServices.size; + + const sourceFacets = useMemo(() => { + const srcs: LabelSource[] = []; + for (const svc of inventory?.services ?? []) { + for (const l of svc.declaredLabels) srcs.push(l.source); + for (const rep of svc.replicas) for (const l of rep.runtimeLabels) srcs.push(l.source); + } + return sourcesPresent(srcs); + }, [inventory]); + + const serviceNames = useMemo(() => (inventory?.services ?? []).map(s => s.service), [inventory]); + + // Filter-aware view model: computed before render so badges reflect only visible labels and + // empty/failure states stay truthful. Per-service checkbox is the only thing that hides a card. + const serviceViews = useMemo(() => { + return (inventory?.services ?? []) + .filter(svc => !hiddenServices.has(svc.service)) + .map(svc => { + const serviceHit = matchesSearch(search, svc.service); + const declaredVisible = svc.declaredLabels.filter(l => labelVisible(l, search, excludedSources, serviceHit)); + const declaredVisibleKeys = new Set(declaredVisible.map(l => l.key)); + const replicas = svc.replicas.map(rep => { + if (rep.inspectFailed) { + return { rep, inspectFailed: true as const, runtimeVisible: [] as LabelValue[], badges: { onlyInCompose: 0, onlyOnContainer: 0, inBoth: 0, changed: 0 }, render: true }; + } + const replicaHit = serviceHit || matchesSearch(search, rep.name, rep.id); + const runtimeVisible = rep.runtimeLabels.filter(l => labelVisible(l, search, excludedSources, replicaHit)); + const rtKeys = new Set(runtimeVisible.map(l => l.key)); + const badges = { + onlyInCompose: rep.onlyInCompose.filter(k => declaredVisibleKeys.has(k)).length, + onlyOnContainer: rep.onlyOnContainer.filter(k => rtKeys.has(k)).length, + inBoth: rep.inBoth.filter(k => rtKeys.has(k) && declaredVisibleKeys.has(k)).length, + changed: (rep.changed ?? []).filter(k => rtKeys.has(k) && declaredVisibleKeys.has(k)).length, + }; + const anyBadge = badges.onlyInCompose + badges.onlyOnContainer + badges.inBoth + badges.changed > 0; + const render = runtimeVisible.length > 0 || anyBadge || !filtersActive; + return { rep, inspectFailed: false as const, runtimeVisible, badges, render }; + }); + const renderedReplicas = replicas.filter(r => r.render); + const noRunning = svc.replicas.length === 0 && svc.declaredLabels.length > 0 && (!filtersActive || declaredVisible.length > 0); + const hasContent = declaredVisible.length > 0 || renderedReplicas.length > 0 || noRunning; + return { svc, declaredVisible, replicas: renderedReplicas, noRunning, hasContent }; + }); + }, [inventory, hiddenServices, search, excludedSources, filtersActive]); + + const toggleSource = (s: LabelSource) => { + setExcludedSources(prev => { + const next = new Set(prev); + if (next.has(s)) next.delete(s); else next.add(s); + return next; + }); + }; + const toggleService = (s: string) => { + setHiddenServices(prev => { + const next = new Set(prev); + if (next.has(s)) next.delete(s); else next.add(s); + return next; + }); + }; + + const handleReveal = () => { + setRevealSecrets(true); + void fetchInventory(true); + }; + + if (loading && !inventory) { + return

Loading Compose labels…

; + } + + if (loadError) { + return

Could not load Compose labels for this stack.

; + } + + const noServices = (inventory?.services.length ?? 0) === 0; + const allServicesHidden = !noServices && serviceNames.every(s => hiddenServices.has(s)); + + return ( +
+
+ compose labels +
+

{LABEL_DISAMBIGUATION_COPY}

+ + {!noServices && ( +
+
+ + setSearch(e.target.value)} + placeholder="Filter labels, services..." + className="h-9 pl-8" + data-testid="compose-label-search" + /> +
+ {(sourceFacets.length > 0 || serviceNames.length > 1) && ( + + + + + + {sourceFacets.length > 0 && ( +
+ +
+ {sourceFacets.map(s => ( + + ))} +
+
+ )} + {serviceNames.length > 1 && ( +
+ +
+ {serviceNames.map(name => ( + + ))} +
+
+ )} + {filterCount > 0 && ( + + )} +
+
+ )} +
+ )} + + {!inventory?.renderable && ( +
+ + Compose could not be fully rendered. Declared labels may be incomplete. +
+ )} + + {inventory?.partial && ( +
+ + Some containers or images could not be inspected. Label provenance may be incomplete. +
+ )} + + {noServices && ( +

No Compose or runtime labels found for this stack.

+ )} + + {allServicesHidden && ( +

No services selected.

+ )} + + {serviceViews.map(({ svc, declaredVisible, replicas, noRunning, hasContent }) => ( +
+

{svc.service}

+ + {declaredVisible.length > 0 && ( +
+

Declared in Compose

+ {declaredVisible.map((label) => ( + + ))} +
+ )} + + {replicas.map(({ rep, inspectFailed, runtimeVisible, badges }) => ( +
+

+ {rep.name || rep.id.slice(0, 12)} · {rep.state} +

+ {inspectFailed ? ( +

Runtime labels unavailable for this container.

+ ) : ( + <> + {(badges.onlyInCompose > 0 || badges.onlyOnContainer > 0 || badges.changed > 0) && ( +
+ + + + +
+ )} + {runtimeVisible.length > 0 ? ( + runtimeVisible.map((label) => ( + + )) + ) : ( + !filtersActive &&

No runtime labels on this container.

+ )} + + )} +
+ ))} + + {noRunning && ( +

+ No running containers for this service. Container may need redeploy for Compose label changes to apply. +

+ )} + + {filtersActive && !hasContent && ( +

No labels match the filter.

+ )} +
+ ))} + +

+ Runtime labels are static until the container is recreated. Container may need redeploy for Compose label changes to apply. +

+
+ ); +} diff --git a/frontend/src/components/stack/__tests__/ComposeLabelsPanel.test.tsx b/frontend/src/components/stack/__tests__/ComposeLabelsPanel.test.tsx new file mode 100644 index 00000000..b28328d9 --- /dev/null +++ b/frontend/src/components/stack/__tests__/ComposeLabelsPanel.test.tsx @@ -0,0 +1,304 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import ComposeLabelsPanel from '../ComposeLabelsPanel'; +import { LABEL_DISAMBIGUATION_COPY } from '@/lib/labelInventory'; + +vi.mock('@/lib/api', () => ({ + apiFetch: vi.fn(), +})); + +vi.mock('@/context/AuthContext', () => ({ + useAuth: () => ({ isAdmin: true }), +})); + +import { apiFetch } from '@/lib/api'; + +const mockInventory = { + stackName: 'demo', + renderable: true, + generatedAt: Date.now(), + services: [ + { + service: 'web', + declaredLabels: [{ key: 'traefik.enable', value: 'true', source: 'compose' as const }], + replicas: [ + { + id: 'c1', + name: 'demo-web-1', + state: 'running', + runtimeLabels: [ + { key: 'traefik.enable', value: 'true', source: 'runtime' as const }, + { key: 'runtime.only', value: '1', source: 'runtime' as const }, + ], + onlyInCompose: [], + onlyOnContainer: ['runtime.only'], + inBoth: ['traefik.enable'], + }, + ], + }, + ], +}; + +describe('ComposeLabelsPanel', () => { + beforeEach(() => { + vi.mocked(apiFetch).mockResolvedValue({ + ok: true, + json: async () => mockInventory, + } as Response); + }); + + it('shows disambiguation copy and service labels', async () => { + render(); + expect(await screen.findByText(LABEL_DISAMBIGUATION_COPY)).toBeInTheDocument(); + expect(await screen.findByText('web')).toBeInTheDocument(); + expect(screen.getAllByText('traefik.enable')).toHaveLength(2); + expect(screen.getByTestId('mismatch-only-container')).toBeInTheDocument(); + expect(screen.getByTestId('mismatch-both')).toBeInTheDocument(); + }); + + it('renders a value-changed badge when a label value drifted', async () => { + vi.mocked(apiFetch).mockResolvedValue({ + ok: true, + json: async () => ({ + stackName: 'demo', renderable: true, partial: false, generatedAt: Date.now(), + services: [{ + service: 'web', + declaredLabels: [{ key: 'watchtower.enable', value: 'true', source: 'compose' as const }], + replicas: [{ + id: 'c1', name: 'demo-web-1', state: 'running', + runtimeLabels: [{ key: 'watchtower.enable', value: 'false', source: 'runtime' as const }], + onlyInCompose: [], onlyOnContainer: [], inBoth: [], changed: ['watchtower.enable'], + }], + }], + }), + } as Response); + render(); + expect(await screen.findByTestId('mismatch-changed')).toBeInTheDocument(); + }); + + it('shows "Runtime labels unavailable" for a replica whose inspect failed', async () => { + vi.mocked(apiFetch).mockResolvedValue({ + ok: true, + json: async () => ({ + stackName: 'demo', renderable: true, partial: true, generatedAt: Date.now(), + services: [{ + service: 'web', + declaredLabels: [{ key: 'traefik.enable', value: 'true', source: 'compose' as const }], + replicas: [{ + id: 'c1', name: 'demo-web-1', state: 'running', + runtimeLabels: [], onlyInCompose: [], onlyOnContainer: [], inBoth: [], changed: [], + inspectFailed: true, + }], + }], + }), + } as Response); + render(); + expect(await screen.findByText('Runtime labels unavailable for this container.')).toBeInTheDocument(); + expect(screen.getByText(/could not be inspected/i)).toBeInTheDocument(); + }); + + // A service whose declared/runtime labels have distinctive keys so name-search can be isolated. + const searchMock = { + stackName: 'demo', renderable: true, partial: false, generatedAt: Date.now(), + services: [{ + service: 'web', + declaredLabels: [{ key: 'aaa.declared', value: '1', source: 'compose' as const }], + replicas: [{ + id: 'w1', name: 'demo-web-1', state: 'running', + runtimeLabels: [{ key: 'zzz.runtime', value: 'q', source: 'runtime' as const }], + onlyInCompose: ['aaa.declared'], onlyOnContainer: ['zzz.runtime'], inBoth: [], changed: [], + }], + }], + }; + + it('service-name search exposes that service\'s declared and runtime labels', async () => { + const user = userEvent.setup(); + vi.mocked(apiFetch).mockResolvedValue({ ok: true, json: async () => searchMock } as Response); + render(); + await user.type(await screen.findByTestId('compose-label-search'), 'web'); + expect(screen.getByText('aaa.declared')).toBeInTheDocument(); + expect(screen.getByText('zzz.runtime')).toBeInTheDocument(); + }); + + it('replica-name search exposes runtime labels only, not service-level declared labels', async () => { + const user = userEvent.setup(); + vi.mocked(apiFetch).mockResolvedValue({ ok: true, json: async () => searchMock } as Response); + render(); + await user.type(await screen.findByTestId('compose-label-search'), 'demo-web-1'); + expect(screen.getByText('zzz.runtime')).toBeInTheDocument(); + expect(screen.queryByText('aaa.declared')).toBeNull(); + }); + + it('hides inBoth and changed badges when Compose File is filtered out', async () => { + const user = userEvent.setup(); + vi.mocked(apiFetch).mockResolvedValue({ + ok: true, + json: async () => ({ + stackName: 'demo', renderable: true, partial: false, generatedAt: Date.now(), + services: [{ + service: 'web', + declaredLabels: [{ key: 'watchtower.enable', value: 'true', source: 'compose' as const }], + replicas: [{ + id: 'c1', name: 'demo-web-1', state: 'running', + runtimeLabels: [{ key: 'watchtower.enable', value: 'false', source: 'runtime' as const }], + onlyInCompose: [], onlyOnContainer: [], inBoth: [], changed: ['watchtower.enable'], + }], + }], + }), + } as Response); + render(); + expect(await screen.findByTestId('mismatch-changed')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: /Filters/ })); + await user.click(screen.getByRole('button', { name: 'Compose File' })); + expect(screen.queryByTestId('mismatch-changed')).toBeNull(); + }); + + it('a source facet hides that source; turning off Compose File hides declared labels', async () => { + const user = userEvent.setup(); + vi.mocked(apiFetch).mockResolvedValue({ ok: true, json: async () => searchMock } as Response); + render(); + expect(await screen.findByText('aaa.declared')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: /Filters/ })); + await user.click(screen.getByRole('button', { name: 'Compose File' })); + expect(screen.queryByText('aaa.declared')).toBeNull(); + expect(screen.getByText('zzz.runtime')).toBeInTheDocument(); + }); + + it('recomputes badge counts from the visible set', async () => { + const user = userEvent.setup(); + vi.mocked(apiFetch).mockResolvedValue({ ok: true, json: async () => searchMock } as Response); + render(); + // Search only the runtime key: onlyOnContainer stays 1, onlyInCompose drops to 0. + await user.type(await screen.findByTestId('compose-label-search'), 'zzz.runtime'); + expect(screen.getByTestId('mismatch-only-container')).toBeInTheDocument(); + expect(screen.queryByTestId('mismatch-only-compose')).toBeNull(); + }); + + it('shows the genuine "No runtime labels" for an empty replica when no filters are active', async () => { + vi.mocked(apiFetch).mockResolvedValue({ + ok: true, json: async () => ({ + stackName: 'demo', renderable: true, partial: false, generatedAt: Date.now(), + services: [{ + service: 'web', declaredLabels: [{ key: 'a', value: '1', source: 'compose' as const }], + replicas: [{ id: 'w1', name: 'demo-web-1', state: 'running', runtimeLabels: [], onlyInCompose: ['a'], onlyOnContainer: [], inBoth: [], changed: [] }], + }], + }), + } as Response); + render(); + expect(await screen.findByText('No runtime labels on this container.')).toBeInTheDocument(); + }); + + it('keeps the inspectFailed warning under an active filter but hides it when the service is unchecked', async () => { + const user = userEvent.setup(); + vi.mocked(apiFetch).mockResolvedValue({ + ok: true, json: async () => ({ + stackName: 'demo', renderable: true, partial: true, generatedAt: Date.now(), + services: [ + { service: 'web', declaredLabels: [], replicas: [{ id: 'w1', name: 'demo-web-1', state: 'running', runtimeLabels: [], onlyInCompose: [], onlyOnContainer: [], inBoth: [], changed: [], inspectFailed: true }] }, + { service: 'db', declaredLabels: [{ key: 'x', value: '1', source: 'compose' as const }], replicas: [] }, + ], + }), + } as Response); + render(); + // Active text filter that matches nothing: the inspectFailed warning must still show. + await user.type(await screen.findByTestId('compose-label-search'), 'nomatchxyz'); + expect(screen.getByText('Runtime labels unavailable for this container.')).toBeInTheDocument(); + // Hiding the web service (via the Filters popover) removes its card and the warning. + await user.click(screen.getByRole('button', { name: /Filters/ })); + await user.click(screen.getByRole('button', { name: 'web' })); + expect(screen.queryByText('Runtime labels unavailable for this container.')).toBeNull(); + }); + + it('distinguishes genuine-empty inventory from filtered-empty (no duplicate messages)', async () => { + const user = userEvent.setup(); + // Genuine empty inventory. + vi.mocked(apiFetch).mockResolvedValue({ + ok: true, json: async () => ({ stackName: 'demo', renderable: true, partial: false, generatedAt: Date.now(), services: [] }), + } as Response); + const { unmount } = render(); + expect(await screen.findByText('No Compose or runtime labels found for this stack.')).toBeInTheDocument(); + unmount(); + + // Filtered-empty: one per-card message, no panel-level duplicate. + vi.mocked(apiFetch).mockResolvedValue({ ok: true, json: async () => searchMock } as Response); + render(); + await user.type(await screen.findByTestId('compose-label-search'), 'nomatchxyz'); + expect(screen.getByTestId('compose-label-service-no-match')).toBeInTheDocument(); + expect(screen.queryByText('No Compose or runtime labels found for this stack.')).toBeNull(); + }); + + it('shows "No services selected" when every service checkbox is unchecked', async () => { + const user = userEvent.setup(); + vi.mocked(apiFetch).mockResolvedValue({ + ok: true, json: async () => ({ + stackName: 'demo', renderable: true, partial: false, generatedAt: Date.now(), + services: [ + { service: 'web', declaredLabels: [{ key: 'a', value: '1', source: 'compose' as const }], replicas: [] }, + { service: 'db', declaredLabels: [{ key: 'b', value: '2', source: 'compose' as const }], replicas: [] }, + ], + }), + } as Response); + render(); + await user.click(await screen.findByRole('button', { name: /Filters/ })); + await user.click(screen.getByRole('button', { name: 'web' })); + await user.click(screen.getByRole('button', { name: 'db' })); + expect(screen.getByTestId('compose-labels-no-services')).toBeInTheDocument(); + }); + + it('preserves filters across a reveal on the same stack', async () => { + const user = userEvent.setup(); + const revealMock = { + stackName: 'demo', renderable: true, partial: false, generatedAt: Date.now(), + services: [{ + service: 'web', declaredLabels: [], + replicas: [{ + id: 'w1', name: 'demo-web-1', state: 'running', + runtimeLabels: [ + { key: 'api.token', value: '[redacted]', source: 'runtime' as const, redacted: true }, + { key: 'plain.label', value: 'v', source: 'runtime' as const }, + ], + onlyInCompose: [], onlyOnContainer: ['api.token', 'plain.label'], inBoth: [], changed: [], + }], + }], + }; + vi.mocked(apiFetch).mockResolvedValue({ ok: true, json: async () => revealMock } as Response); + render(); + await user.type(await screen.findByTestId('compose-label-search'), 'api.token'); + expect(screen.getByText('api.token')).toBeInTheDocument(); + expect(screen.queryByText('plain.label')).toBeNull(); + + await user.click(screen.getByRole('button', { name: /reveal/i })); + // The reset effect is keyed on stackName only, so the reveal-driven refetch keeps the filter. + expect((screen.getByTestId('compose-label-search') as HTMLInputElement).value).toBe('api.token'); + expect(screen.queryByText('plain.label')).toBeNull(); + }); + + it('resets search, source, and service filters when the stack changes', async () => { + const user = userEvent.setup(); + const twoServiceMock = { + stackName: 'demo', renderable: true, partial: false, generatedAt: Date.now(), + services: [ + { service: 'web', declaredLabels: [{ key: 'web.owner', value: '1', source: 'compose' as const }], replicas: [] }, + { service: 'db', declaredLabels: [{ key: 'db.owner', value: '2', source: 'compose' as const }], replicas: [] }, + ], + }; + vi.mocked(apiFetch).mockResolvedValue({ ok: true, json: async () => twoServiceMock } as Response); + const { rerender } = render(); + const input = await screen.findByTestId('compose-label-search') as HTMLInputElement; + await user.type(input, 'zzz'); + // Hide the db service and exclude the Compose File source via the Filters popover. + await user.click(screen.getByRole('button', { name: /Filters/ })); + await user.click(screen.getByRole('button', { name: 'db' })); + await user.click(screen.getByRole('button', { name: 'Compose File' })); + expect(input.value).toBe('zzz'); + expect(screen.queryByText('web.owner')).toBeNull(); + expect(screen.queryByText('db.owner')).toBeNull(); + + rerender(); + // Filters reset: search cleared, both services and the source shown again. + expect((await screen.findByTestId('compose-label-search') as HTMLInputElement).value).toBe(''); + expect(await screen.findByText('web.owner')).toBeInTheDocument(); + expect(screen.getByText('db.owner')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/lib/__tests__/labelInventory.test.ts b/frontend/src/lib/__tests__/labelInventory.test.ts new file mode 100644 index 00000000..e37921c2 --- /dev/null +++ b/frontend/src/lib/__tests__/labelInventory.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest'; +import { sourcesPresent, matchesSearch, type LabelSource } from '@/lib/labelInventory'; + +describe('sourcesPresent', () => { + it('returns distinct sources in the fixed display order regardless of input order', () => { + const input: LabelSource[] = ['runtime', 'unknown', 'compose', 'image', 'compose-system']; + expect(sourcesPresent(input)).toEqual(['compose', 'image', 'runtime', 'compose-system', 'unknown']); + }); + + it('de-duplicates and omits sources not present', () => { + expect(sourcesPresent(['runtime', 'runtime', 'image'])).toEqual(['image', 'runtime']); + expect(sourcesPresent([])).toEqual([]); + }); +}); + +describe('matchesSearch', () => { + it('matches when any part contains the (case-insensitive) query', () => { + expect(matchesSearch('WATCH', 'com.centurylinklabs.watchtower.enable')).toBe(true); + expect(matchesSearch('nope', 'traefik.enable', 'true')).toBe(false); + }); + + it('treats an empty or whitespace query as matching everything', () => { + expect(matchesSearch('', 'anything')).toBe(true); + expect(matchesSearch(' ', null)).toBe(true); + }); + + it('trims the query and tolerates null/undefined parts', () => { + expect(matchesSearch(' enable ', 'traefik.enable')).toBe(true); + expect(matchesSearch('x', null, undefined)).toBe(false); + }); +}); diff --git a/frontend/src/lib/capabilities.ts b/frontend/src/lib/capabilities.ts index d0eda468..e09d8843 100644 --- a/frontend/src/lib/capabilities.ts +++ b/frontend/src/lib/capabilities.ts @@ -28,6 +28,7 @@ export const CAPABILITIES = [ 'update-guard', 'compose-networking', 'env-inventory', + 'container-label-inventory', 'project-env-files', 'compose-storage', 'cross-node-rbac', diff --git a/frontend/src/lib/events.ts b/frontend/src/lib/events.ts index 78bcce06..2234ac0d 100644 --- a/frontend/src/lib/events.ts +++ b/frontend/src/lib/events.ts @@ -42,6 +42,7 @@ export type FleetTab = | 'snapshots' | 'configuration' | 'dependencies' + | 'container-labels' | 'deployments' | 'routing' | 'federation' diff --git a/frontend/src/lib/labelInventory.ts b/frontend/src/lib/labelInventory.ts new file mode 100644 index 00000000..1fc28ed6 --- /dev/null +++ b/frontend/src/lib/labelInventory.ts @@ -0,0 +1,117 @@ +export type 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 FleetLabelInventoryResponse { + nodes: Array<{ + nodeId: number; + nodeName: string; + status: 'ok' | 'error'; + inventory: { + nodeId: number; + containers: ContainerLabelRow[]; + byLabel: LabelIndexRow[]; + partial: boolean; + generatedAt: number; + } | null; + error: string | null; + }>; + aggregatedByLabel: LabelIndexRow[]; + nodeErrors: Record; + generatedAt: number; +} + +export interface StackLabelReplica { + id: string; + name: string; + state: string; + runtimeLabels: LabelValue[]; + onlyInCompose: string[]; + onlyOnContainer: string[]; + inBoth: string[]; + // Optional so older nodes (pre-provenance) still typecheck during mixed-version operation. + changed?: string[]; + inspectFailed?: boolean; +} + +export interface StackServiceLabelRow { + service: string; + declaredLabels: LabelValue[]; + replicas: StackLabelReplica[]; +} + +export interface StackLabelInventory { + stackName: string; + renderable: boolean; + services: StackServiceLabelRow[]; + // Optional so older nodes still typecheck during mixed-version operation. + partial?: boolean; + generatedAt: number; +} + +export const LABEL_DISAMBIGUATION_COPY = + 'Docker labels are metadata declared on Compose services or attached to running containers. They are different from Sencho Stack Labels used for organizing stacks and Node Labels used for Blueprint placement.'; + +export const SOURCE_LABELS: Record = { + compose: 'Declared in Compose', + runtime: 'Present at runtime', + image: 'Image', + 'compose-system': 'Docker Compose system label', + unknown: 'Unknown', +}; + +/** Concise facet-chip labels (concept-aligned with SOURCE_LABELS, not a rename of the badges). */ +export const SOURCE_FACET_LABELS: Record = { + compose: 'Compose File', + runtime: 'Runtime', + image: 'Image', + 'compose-system': 'System', + unknown: 'Unknown', +}; + +/** Stable display order for source facets. */ +const SOURCE_ORDER: LabelSource[] = ['compose', 'image', 'runtime', 'compose-system', 'unknown']; + +/** The distinct sources present, in stable display order, for building data-driven facet rows. */ +export function sourcesPresent(sources: Iterable): LabelSource[] { + const present = new Set(sources); + return SOURCE_ORDER.filter(s => present.has(s)); +} + +/** Case-insensitive substring match across a variable list of fields; empty query matches all. */ +export function matchesSearch(query: string, ...parts: (string | null | undefined)[]): boolean { + const q = query.trim().toLowerCase(); + if (!q) return true; + return parts.some(p => (p ?? '').toLowerCase().includes(q)); +}