mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-21 07:36:40 +00:00
feat: add Docker label audit across Fleet and Stack views (#1531)
This commit is contained in:
@@ -978,6 +978,59 @@ describe('DockerController - inspectImage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// --- label / image inspection for the label inventory --------------------------
|
||||
|
||||
describe('DockerController - inspectImageLabels', () => {
|
||||
it('returns the image label map', async () => {
|
||||
mockDocker.getImage.mockReturnValue({
|
||||
inspect: vi.fn().mockResolvedValue({ Config: { Labels: { 'org.opencontainers.image.title': 'Plex' } } }),
|
||||
});
|
||||
const dc = DockerController.getInstance(1);
|
||||
const result = await dc.inspectImageLabels('sha256:img');
|
||||
expect(result).toEqual({ labels: { 'org.opencontainers.image.title': 'Plex' } });
|
||||
expect(mockDocker.getImage).toHaveBeenCalledWith('sha256:img');
|
||||
});
|
||||
|
||||
it('returns null (not a silent empty map) and logs when the image inspect fails', async () => {
|
||||
mockDocker.getImage.mockReturnValue({
|
||||
inspect: vi.fn().mockRejectedValue(new Error('No such image')),
|
||||
});
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const dc = DockerController.getInstance(1);
|
||||
expect(await dc.inspectImageLabels('missing')).toBeNull();
|
||||
expect(errSpy).toHaveBeenCalled();
|
||||
errSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('short-circuits an empty image id without inspecting', async () => {
|
||||
const dc = DockerController.getInstance(1);
|
||||
expect(await dc.inspectImageLabels('')).toBeNull();
|
||||
expect(mockDocker.getImage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DockerController - inspectContainerLabelsAndImage', () => {
|
||||
it('returns labels and the image ref from container inspect', async () => {
|
||||
mockDocker.getContainer.mockReturnValue({
|
||||
inspect: vi.fn().mockResolvedValue({ Config: { Labels: { 'traefik.enable': 'true' } }, Image: 'sha256:imgref' }),
|
||||
});
|
||||
const dc = DockerController.getInstance(1);
|
||||
const result = await dc.inspectContainerLabelsAndImage('c1');
|
||||
expect(result).toEqual({ labels: { 'traefik.enable': 'true' }, imageId: 'sha256:imgref' });
|
||||
});
|
||||
|
||||
it('returns null and logs when the container inspect fails', async () => {
|
||||
mockDocker.getContainer.mockReturnValue({
|
||||
inspect: vi.fn().mockRejectedValue(new Error('no such container')),
|
||||
});
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const dc = DockerController.getInstance(1);
|
||||
expect(await dc.inspectContainerLabelsAndImage('gone')).toBeNull();
|
||||
expect(errSpy).toHaveBeenCalled();
|
||||
errSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// --- createNetwork validation --------------------------------------------------
|
||||
|
||||
describe('createNetwork', () => {
|
||||
|
||||
@@ -21,6 +21,7 @@ const NODE_READ_ROUTES = [
|
||||
'/api/fleet/dependency-map',
|
||||
'/api/fleet/networking-summary',
|
||||
'/api/fleet/update-status',
|
||||
'/api/fleet/container-labels',
|
||||
];
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -61,4 +62,20 @@ describe('fleet topology reads require node:read', () => {
|
||||
const res = await request(app).get('/api/fleet/overview');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('denies ?reveal=1 on container-labels for a non-admin (viewer)', async () => {
|
||||
const res = await request(app).get('/api/fleet/container-labels?reveal=1').set('Authorization', `Bearer ${viewerToken}`);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('denies the node-local /api/system/container-labels for a role without node:read (deployer)', async () => {
|
||||
const res = await request(app).get('/api/system/container-labels').set('Authorization', `Bearer ${deployerToken}`);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PERMISSION_DENIED');
|
||||
});
|
||||
|
||||
it('denies ?reveal=1 on the node-local container-labels for a non-admin (viewer)', async () => {
|
||||
const res = await request(app).get('/api/system/container-labels?reveal=1').set('Authorization', `Bearer ${viewerToken}`);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -909,7 +909,9 @@ describe('GitSourceService.createStackFromGit', () => {
|
||||
sha,
|
||||
});
|
||||
const svc = GitSourceService.getInstance();
|
||||
const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true });
|
||||
|
||||
try {
|
||||
const result = await svc.createStackFromGit({
|
||||
stackName: 'create-happy',
|
||||
repoUrl: 'https://github.com/example/repo.git',
|
||||
@@ -934,6 +936,9 @@ describe('GitSourceService.createStackFromGit', () => {
|
||||
expect(onDisk).toContain('image: nginx');
|
||||
|
||||
await cleanupStackDir('create-happy');
|
||||
} finally {
|
||||
validateSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('multi-file create then pull reports no local changes (hash is path-independent)', async () => {
|
||||
|
||||
@@ -0,0 +1,574 @@
|
||||
/**
|
||||
* Label inventory service, provenance, redaction, and GET routes.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import request from 'supertest';
|
||||
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
|
||||
import { ComposeService } from '../services/ComposeService';
|
||||
import DockerController from '../services/DockerController';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { NodeRegistry } from '../services/NodeRegistry';
|
||||
import {
|
||||
buildNodeLabelInventory,
|
||||
buildStackLabelInventory,
|
||||
} from '../services/LabelInventoryService';
|
||||
import { REDACTED_SENTINEL } from '../helpers/labelValueRedaction';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
let authCookie: string;
|
||||
let nodeId: number;
|
||||
|
||||
function composeDir(): string { return process.env.COMPOSE_DIR as string; }
|
||||
|
||||
function writeStack(stack: string, files: Record<string, string>): void {
|
||||
const dir = path.join(composeDir(), stack);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
for (const [name, content] of Object.entries(files)) fs.writeFileSync(path.join(dir, name), content);
|
||||
}
|
||||
|
||||
function stubRender(serviceLabels: Record<string, Record<string, string>> | null): void {
|
||||
const rendered = serviceLabels === null
|
||||
? null
|
||||
: JSON.stringify({
|
||||
name: 'proj',
|
||||
services: Object.fromEntries(
|
||||
Object.entries(serviceLabels).map(([s, labels]) => [s, { labels }]),
|
||||
),
|
||||
});
|
||||
vi.spyOn(ComposeService, 'getInstance').mockReturnValue({
|
||||
renderConfig: vi.fn().mockResolvedValue({ rendered, stderr: '', code: rendered === null ? 1 : 0, timedOut: false }),
|
||||
} as unknown as ComposeService);
|
||||
}
|
||||
|
||||
interface StubRow {
|
||||
id: string;
|
||||
name: string;
|
||||
state: string;
|
||||
stack: string | null;
|
||||
service: string | null;
|
||||
labels: Record<string, string>;
|
||||
inspectFailed?: boolean;
|
||||
imageId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub DockerController for the label inventory. `images` maps an image id to its label
|
||||
* map, or to `null` to simulate an image inspect failure. An image id absent from the map
|
||||
* inspects successfully with no labels. Returns the `inspectImageLabels` spy so tests can
|
||||
* assert deduplication.
|
||||
*/
|
||||
function stubDockerList(
|
||||
rows: StubRow[],
|
||||
opts: { images?: Record<string, Record<string, string> | null> } = {},
|
||||
): { inspectImageLabels: ReturnType<typeof vi.fn> } {
|
||||
const withDefaults = rows.map(r => ({ inspectFailed: false, imageId: 'img-default', ...r }));
|
||||
const images = opts.images ?? {};
|
||||
const inspectImageLabels = vi.fn(async (imageId: string) => {
|
||||
if (imageId in images) {
|
||||
const labels = images[imageId];
|
||||
return labels === null ? null : { labels };
|
||||
}
|
||||
return { labels: {} };
|
||||
});
|
||||
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
|
||||
listContainersForLabelInventory: vi.fn().mockResolvedValue(withDefaults),
|
||||
getContainersByStack: vi.fn().mockImplementation(async (stack: string) =>
|
||||
withDefaults.filter(r => r.stack === stack).map(r => ({
|
||||
Id: r.id,
|
||||
Names: [`/${r.name}`],
|
||||
State: r.state,
|
||||
Service: r.service,
|
||||
})),
|
||||
),
|
||||
inspectContainerLabelsAndImage: vi.fn().mockImplementation(async (id: string) => {
|
||||
const row = withDefaults.find(r => r.id === id);
|
||||
if (!row || row.inspectFailed) return null;
|
||||
return { labels: row.labels, imageId: row.imageId };
|
||||
}),
|
||||
inspectImageLabels,
|
||||
} as unknown as DockerController);
|
||||
return { inspectImageLabels };
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ app } = await import('../index'));
|
||||
authCookie = await loginAsTestAdmin(app);
|
||||
nodeId = (DatabaseService.getInstance().getDb().prepare('SELECT id FROM nodes WHERE is_default = 1').get() as { id: number }).id;
|
||||
});
|
||||
|
||||
afterAll(() => cleanupTestDb(tmpDir));
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe('buildNodeLabelInventory', () => {
|
||||
it('builds inverted index and compose-system provenance', async () => {
|
||||
stubDockerList([
|
||||
{
|
||||
id: 'c1',
|
||||
name: 'web-1',
|
||||
state: 'running',
|
||||
stack: 'mys',
|
||||
service: 'web',
|
||||
imageId: 'img1',
|
||||
labels: {
|
||||
'com.docker.compose.service': 'web',
|
||||
'traefik.enable': 'true',
|
||||
},
|
||||
},
|
||||
]);
|
||||
const inv = await buildNodeLabelInventory(nodeId);
|
||||
expect(inv.containers).toHaveLength(1);
|
||||
expect(inv.byLabel).toHaveLength(2);
|
||||
const svc = inv.containers[0].labels.find(l => l.key === 'com.docker.compose.service');
|
||||
expect(svc?.source).toBe('compose-system');
|
||||
const traefik = inv.containers[0].labels.find(l => l.key === 'traefik.enable');
|
||||
expect(traefik?.source).toBe('runtime');
|
||||
expect(inv.partial).toBe(false);
|
||||
});
|
||||
|
||||
it('attributes image-inherited labels to the image, but runtime overrides stay runtime', async () => {
|
||||
stubDockerList([
|
||||
{
|
||||
id: 'c1',
|
||||
name: 'plex-1',
|
||||
state: 'running',
|
||||
stack: 'media',
|
||||
service: 'plex',
|
||||
imageId: 'plex-img',
|
||||
labels: {
|
||||
'org.opencontainers.image.title': 'Plex',
|
||||
'traefik.enable': 'true',
|
||||
},
|
||||
},
|
||||
], { images: { 'plex-img': { 'org.opencontainers.image.title': 'Plex', 'traefik.enable': 'false' } } });
|
||||
const inv = await buildNodeLabelInventory(nodeId);
|
||||
const oci = inv.containers[0].labels.find(l => l.key === 'org.opencontainers.image.title');
|
||||
expect(oci?.source).toBe('image');
|
||||
// Same key on the image but a different value: the container overrides it, so runtime.
|
||||
const traefik = inv.containers[0].labels.find(l => l.key === 'traefik.enable');
|
||||
expect(traefik?.source).toBe('runtime');
|
||||
expect(inv.partial).toBe(false);
|
||||
});
|
||||
|
||||
it('marks labels unknown and the inventory partial when the image inspect fails', async () => {
|
||||
stubDockerList([
|
||||
{ id: 'c1', name: 'a-1', state: 'running', stack: 's', service: 'a', imageId: 'broken', labels: { 'custom.label': 'v' } },
|
||||
], { images: { broken: null } });
|
||||
const inv = await buildNodeLabelInventory(nodeId);
|
||||
expect(inv.containers[0].labels.find(l => l.key === 'custom.label')?.source).toBe('unknown');
|
||||
expect(inv.partial).toBe(true);
|
||||
});
|
||||
|
||||
it('treats an empty image id as unknown and partial without inspecting an empty id', async () => {
|
||||
const { inspectImageLabels } = stubDockerList([
|
||||
{ id: 'c1', name: 'a-1', state: 'running', stack: 's', service: 'a', imageId: '', labels: { 'custom.label': 'v' } },
|
||||
]);
|
||||
const inv = await buildNodeLabelInventory(nodeId);
|
||||
expect(inv.containers[0].labels.find(l => l.key === 'custom.label')?.source).toBe('unknown');
|
||||
expect(inv.partial).toBe(true);
|
||||
expect(inspectImageLabels).not.toHaveBeenCalledWith('');
|
||||
});
|
||||
|
||||
it('inspects each shared image only once (dedup)', async () => {
|
||||
const { inspectImageLabels } = stubDockerList([
|
||||
{ id: 'c1', name: 'a-1', state: 'running', stack: 's', service: 'a', imageId: 'shared', labels: { a: '1' } },
|
||||
{ id: 'c2', name: 'a-2', state: 'running', stack: 's', service: 'a', imageId: 'shared', labels: { a: '1' } },
|
||||
], { images: { shared: {} } });
|
||||
await buildNodeLabelInventory(nodeId);
|
||||
const sharedCalls = inspectImageLabels.mock.calls.filter(c => c[0] === 'shared');
|
||||
expect(sharedCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('redacts secret-like label values by default', async () => {
|
||||
stubDockerList([
|
||||
{
|
||||
id: 'c1',
|
||||
name: 'app-1',
|
||||
state: 'running',
|
||||
stack: 'sec',
|
||||
service: 'app',
|
||||
imageId: 'img1',
|
||||
labels: { 'my.api.token': 'super-secret', 'traefik.enable': 'true' },
|
||||
},
|
||||
]);
|
||||
const inv = await buildNodeLabelInventory(nodeId);
|
||||
const token = inv.containers[0].labels.find(l => l.key === 'my.api.token');
|
||||
expect(token?.value).toBe(REDACTED_SENTINEL);
|
||||
expect(token?.redacted).toBe(true);
|
||||
const plain = inv.containers[0].labels.find(l => l.key === 'traefik.enable');
|
||||
expect(plain?.value).toBe('true');
|
||||
expect(plain?.redacted).toBeUndefined();
|
||||
});
|
||||
|
||||
it('redacts Traefik basicauth and digestauth label values', async () => {
|
||||
stubDockerList([
|
||||
{
|
||||
id: 'c1', name: 'web-1', state: 'running', stack: 's', service: 'web', imageId: 'img1',
|
||||
labels: {
|
||||
'traefik.http.middlewares.foo.basicauth.users': 'admin:$apr1$abc123',
|
||||
'traefik.http.middlewares.bar.digestauth.users': 'admin:realm:deadbeef',
|
||||
'traefik.enable': 'true',
|
||||
},
|
||||
},
|
||||
]);
|
||||
const inv = await buildNodeLabelInventory(nodeId);
|
||||
const basic = inv.containers[0].labels.find(l => l.key.endsWith('basicauth.users'));
|
||||
const digest = inv.containers[0].labels.find(l => l.key.endsWith('digestauth.users'));
|
||||
expect(basic?.value).toBe(REDACTED_SENTINEL);
|
||||
expect(basic?.redacted).toBe(true);
|
||||
expect(digest?.value).toBe(REDACTED_SENTINEL);
|
||||
expect(digest?.redacted).toBe(true);
|
||||
expect(inv.containers[0].labels.find(l => l.key === 'traefik.enable')?.redacted).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reveals secret-like values when revealSecrets is true', async () => {
|
||||
stubDockerList([
|
||||
{
|
||||
id: 'c1',
|
||||
name: 'app-1',
|
||||
state: 'running',
|
||||
stack: 'sec',
|
||||
service: 'app',
|
||||
imageId: 'img1',
|
||||
labels: { 'api.token': 'visible-when-revealed' },
|
||||
},
|
||||
]);
|
||||
const inv = await buildNodeLabelInventory(nodeId, { revealSecrets: true });
|
||||
expect(inv.containers[0].labels[0].value).toBe('visible-when-revealed');
|
||||
expect(inv.containers[0].labels[0].redacted).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildStackLabelInventory', () => {
|
||||
it('reconciles declared and runtime labels', async () => {
|
||||
writeStack('lbl1', {
|
||||
'compose.yaml': 'services:\n web:\n image: nginx\n labels:\n traefik.enable: "true"\n compose.only: "1"\n',
|
||||
});
|
||||
stubRender({ web: { 'traefik.enable': 'true', 'compose.only': '1' } });
|
||||
stubDockerList([
|
||||
{
|
||||
id: 'c1',
|
||||
name: 'lbl1-web-1',
|
||||
state: 'running',
|
||||
stack: 'lbl1',
|
||||
service: 'web',
|
||||
imageId: 'img1',
|
||||
labels: {
|
||||
'traefik.enable': 'true',
|
||||
'runtime.only': '1',
|
||||
'com.docker.compose.service': 'web',
|
||||
},
|
||||
},
|
||||
]);
|
||||
const inv = await buildStackLabelInventory(nodeId, 'lbl1');
|
||||
expect(inv.renderable).toBe(true);
|
||||
expect(inv.partial).toBe(false);
|
||||
const web = inv.services.find(s => s.service === 'web');
|
||||
expect(web?.declaredLabels.map(l => l.key)).toEqual(['compose.only', 'traefik.enable']);
|
||||
expect(web?.replicas[0].onlyInCompose).toEqual(['compose.only']);
|
||||
expect(web?.replicas[0].onlyOnContainer).toContain('runtime.only');
|
||||
expect(web?.replicas[0].inBoth).toContain('traefik.enable');
|
||||
expect(web?.replicas[0].changed).toEqual([]);
|
||||
});
|
||||
|
||||
it('flags a value that drifted between compose and runtime as changed, not inBoth', async () => {
|
||||
writeStack('drift1', {
|
||||
'compose.yaml': 'services:\n web:\n image: nginx\n labels:\n watchtower.enable: "true"\n',
|
||||
});
|
||||
stubRender({ web: { 'watchtower.enable': 'true' } });
|
||||
stubDockerList([
|
||||
{
|
||||
id: 'c1',
|
||||
name: 'drift1-web-1',
|
||||
state: 'running',
|
||||
stack: 'drift1',
|
||||
service: 'web',
|
||||
imageId: 'img1',
|
||||
labels: { 'watchtower.enable': 'false' },
|
||||
},
|
||||
]);
|
||||
const inv = await buildStackLabelInventory(nodeId, 'drift1');
|
||||
const web = inv.services.find(s => s.service === 'web');
|
||||
expect(web?.replicas[0].changed).toEqual(['watchtower.enable']);
|
||||
expect(web?.replicas[0].inBoth).not.toContain('watchtower.enable');
|
||||
});
|
||||
|
||||
it('detects drift on a secret-like key while its value stays redacted', async () => {
|
||||
writeStack('drift2', {
|
||||
'compose.yaml': 'services:\n web:\n image: nginx\n labels:\n auth.token: "declared"\n',
|
||||
});
|
||||
stubRender({ web: { 'auth.token': 'declared' } });
|
||||
stubDockerList([
|
||||
{ id: 'c1', name: 'drift2-web-1', state: 'running', stack: 'drift2', service: 'web', imageId: 'img1', labels: { 'auth.token': 'runtime' } },
|
||||
]);
|
||||
const inv = await buildStackLabelInventory(nodeId, 'drift2');
|
||||
const web = inv.services.find(s => s.service === 'web');
|
||||
expect(web?.replicas[0].changed).toEqual(['auth.token']);
|
||||
const rt = web?.replicas[0].runtimeLabels.find(l => l.key === 'auth.token');
|
||||
expect(rt?.value).toBe(REDACTED_SENTINEL);
|
||||
expect(rt?.redacted).toBe(true);
|
||||
});
|
||||
|
||||
it('marks a replica inspectFailed and skips reconciliation instead of reporting false drift', async () => {
|
||||
writeStack('fail1', {
|
||||
'compose.yaml': 'services:\n web:\n image: nginx\n labels:\n traefik.enable: "true"\n',
|
||||
});
|
||||
stubRender({ web: { 'traefik.enable': 'true' } });
|
||||
stubDockerList([
|
||||
{ id: 'c1', name: 'fail1-web-1', state: 'running', stack: 'fail1', service: 'web', imageId: 'img1', labels: {}, inspectFailed: true },
|
||||
]);
|
||||
const inv = await buildStackLabelInventory(nodeId, 'fail1');
|
||||
const web = inv.services.find(s => s.service === 'web');
|
||||
expect(web?.replicas[0].inspectFailed).toBe(true);
|
||||
expect(web?.replicas[0].onlyInCompose).toEqual([]);
|
||||
expect(web?.replicas[0].runtimeLabels).toEqual([]);
|
||||
expect(inv.partial).toBe(true);
|
||||
});
|
||||
|
||||
it('inspects each shared image only once across replicas (dedup)', async () => {
|
||||
writeStack('ddup', { 'compose.yaml': 'services:\n web:\n image: nginx\n' });
|
||||
stubRender({ web: {} });
|
||||
const { inspectImageLabels } = stubDockerList([
|
||||
{ id: 'c1', name: 'ddup-web-1', state: 'running', stack: 'ddup', service: 'web', imageId: 'shared', labels: { a: '1' } },
|
||||
{ id: 'c2', name: 'ddup-web-2', state: 'running', stack: 'ddup', service: 'web', imageId: 'shared', labels: { a: '1' } },
|
||||
], { images: { shared: {} } });
|
||||
await buildStackLabelInventory(nodeId, 'ddup');
|
||||
expect(inspectImageLabels.mock.calls.filter(c => c[0] === 'shared')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('attributes provenance on the stack path: compose wins over image, image labels tagged image', async () => {
|
||||
writeStack('prov1', {
|
||||
'compose.yaml': 'services:\n web:\n image: nginx\n labels:\n foo: "bar"\n',
|
||||
});
|
||||
stubRender({ web: { foo: 'bar' } });
|
||||
stubDockerList([
|
||||
{ id: 'c1', name: 'prov1-web-1', state: 'running', stack: 'prov1', service: 'web', imageId: 'img1', labels: { foo: 'bar', 'org.opencontainers.image.title': 'Nginx' } },
|
||||
], { images: { img1: { foo: 'bar', 'org.opencontainers.image.title': 'Nginx' } } });
|
||||
const inv = await buildStackLabelInventory(nodeId, 'prov1');
|
||||
const rep = inv.services.find(s => s.service === 'web')?.replicas[0];
|
||||
// foo is on both the image and the Compose file with the same value: Compose wins.
|
||||
expect(rep?.runtimeLabels.find(l => l.key === 'foo')?.source).toBe('compose');
|
||||
expect(rep?.runtimeLabels.find(l => l.key === 'org.opencontainers.image.title')?.source).toBe('image');
|
||||
expect(inv.partial).toBe(false);
|
||||
});
|
||||
|
||||
it('marks stack runtime labels unknown and the inventory partial when the image inspect fails', async () => {
|
||||
writeStack('prov2', { 'compose.yaml': 'services:\n web:\n image: nginx\n' });
|
||||
stubRender({ web: {} });
|
||||
stubDockerList([
|
||||
{ id: 'c1', name: 'prov2-web-1', state: 'running', stack: 'prov2', service: 'web', imageId: 'broken', labels: { 'custom.label': 'v' } },
|
||||
], { images: { broken: null } });
|
||||
const inv = await buildStackLabelInventory(nodeId, 'prov2');
|
||||
const rep = inv.services.find(s => s.service === 'web')?.replicas[0];
|
||||
expect(rep?.runtimeLabels.find(l => l.key === 'custom.label')?.source).toBe('unknown');
|
||||
expect(inv.partial).toBe(true);
|
||||
});
|
||||
|
||||
it('parses list-form compose labels', async () => {
|
||||
writeStack('lbl2', {
|
||||
'compose.yaml': 'services:\n web:\n image: nginx\n labels:\n - "watchtower.enable=true"\n',
|
||||
});
|
||||
stubRender({ web: { 'watchtower.enable': 'true' } });
|
||||
stubDockerList([]);
|
||||
const inv = await buildStackLabelInventory(nodeId, 'lbl2');
|
||||
expect(inv.services[0].declaredLabels[0]).toMatchObject({ key: 'watchtower.enable', value: 'true', source: 'compose' });
|
||||
});
|
||||
|
||||
it('sets renderable false when compose render fails', async () => {
|
||||
writeStack('lbl3', { 'compose.yaml': 'services:\n web:\n image: nginx\n' });
|
||||
stubRender(null);
|
||||
stubDockerList([]);
|
||||
const inv = await buildStackLabelInventory(nodeId, 'lbl3');
|
||||
expect(inv.renderable).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves non-system labels to unknown and skips reconciliation when render fails', async () => {
|
||||
writeStack('rf1', { 'compose.yaml': 'services:\n web:\n image: nginx\n' });
|
||||
stubRender(null);
|
||||
stubDockerList([
|
||||
{
|
||||
id: 'c1', name: 'rf1-web-1', state: 'running', stack: 'rf1', service: 'web', imageId: 'img1',
|
||||
labels: { 'traefik.enable': 'true', 'com.docker.compose.service': 'web' },
|
||||
},
|
||||
]);
|
||||
const inv = await buildStackLabelInventory(nodeId, 'rf1');
|
||||
expect(inv.renderable).toBe(false);
|
||||
// Render failure is signalled by renderable, not partial (which is for inspect failures).
|
||||
expect(inv.partial).toBe(false);
|
||||
const rep = inv.services.find(s => s.service === 'web')?.replicas[0];
|
||||
expect(rep?.runtimeLabels.find(l => l.key === 'traefik.enable')?.source).toBe('unknown');
|
||||
expect(rep?.runtimeLabels.find(l => l.key === 'com.docker.compose.service')?.source).toBe('compose-system');
|
||||
expect(rep?.onlyOnContainer).toEqual([]);
|
||||
expect(rep?.changed).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/system/container-labels', () => {
|
||||
it('requires authentication', async () => {
|
||||
const res = await request(app).get('/api/system/container-labels');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns node inventory', async () => {
|
||||
stubDockerList([
|
||||
{ id: 'c1', name: 'a', state: 'running', stack: 's', service: 'web', imageId: 'img1', labels: { foo: 'bar' } },
|
||||
]);
|
||||
const res = await request(app).get('/api/system/container-labels').set('Cookie', authCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.nodeId).toBe(nodeId);
|
||||
expect(res.body.containers).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('redacts secrets by default and reveals them for an admin with reveal=1', async () => {
|
||||
stubDockerList([
|
||||
{ id: 'c1', name: 'a', state: 'running', stack: 's', service: 'web', imageId: 'img1', labels: { 'api.token': 's3cr3t' } },
|
||||
]);
|
||||
const redacted = await request(app).get('/api/system/container-labels').set('Cookie', authCookie);
|
||||
const rLabel = redacted.body.containers[0].labels.find((l: { key: string }) => l.key === 'api.token');
|
||||
expect(rLabel.value).toBe(REDACTED_SENTINEL);
|
||||
expect(rLabel.redacted).toBe(true);
|
||||
|
||||
stubDockerList([
|
||||
{ id: 'c1', name: 'a', state: 'running', stack: 's', service: 'web', imageId: 'img1', labels: { 'api.token': 's3cr3t' } },
|
||||
]);
|
||||
const revealed = await request(app).get('/api/system/container-labels?reveal=1').set('Cookie', authCookie);
|
||||
const vLabel = revealed.body.containers[0].labels.find((l: { key: string }) => l.key === 'api.token');
|
||||
expect(vLabel.value).toBe('s3cr3t');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/stacks/:stackName/label-inventory', () => {
|
||||
it('returns 404 for unknown stack', async () => {
|
||||
const res = await request(app).get('/api/stacks/missing-stack/label-inventory').set('Cookie', authCookie);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('returns stack inventory', async () => {
|
||||
writeStack('route1', { 'compose.yaml': 'services:\n web:\n image: nginx\n' });
|
||||
stubRender({ web: {} });
|
||||
stubDockerList([]);
|
||||
const res = await request(app).get('/api/stacks/route1/label-inventory').set('Cookie', authCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.stackName).toBe('route1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/fleet/container-labels', () => {
|
||||
it('requires authentication', async () => {
|
||||
const res = await request(app).get('/api/fleet/container-labels');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('aggregates the local node with no node errors', async () => {
|
||||
stubDockerList([
|
||||
{ id: 'c1', name: 'a-1', state: 'running', stack: 's', service: 'a', imageId: 'img1', labels: { 'shared.label': 'v' } },
|
||||
{ id: 'c2', name: 'b-1', state: 'running', stack: 's', service: 'b', imageId: 'img1', labels: { 'shared.label': 'v' } },
|
||||
]);
|
||||
const res = await request(app).get('/api/fleet/container-labels').set('Cookie', authCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.nodeErrors).toEqual({});
|
||||
const shared = res.body.aggregatedByLabel.filter((r: { key: string }) => r.key === 'shared.label');
|
||||
expect(shared).toHaveLength(1);
|
||||
expect(shared[0].containers).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('keeps the same key=value distinct when the source differs', async () => {
|
||||
stubDockerList([
|
||||
{ id: 'c1', name: 'a-1', state: 'running', stack: 's', service: 'a', imageId: 'ia', labels: { 'dup.label': 'v' } },
|
||||
{ id: 'c2', name: 'b-1', state: 'running', stack: 's', service: 'b', imageId: 'ib', labels: { 'dup.label': 'v' } },
|
||||
], { images: { ia: { 'dup.label': 'v' }, ib: {} } });
|
||||
const res = await request(app).get('/api/fleet/container-labels').set('Cookie', authCookie);
|
||||
const dup = res.body.aggregatedByLabel.filter((r: { key: string }) => r.key === 'dup.label');
|
||||
expect(dup).toHaveLength(2);
|
||||
// The server sorts by key, value, then source; assert that order directly (no re-sort).
|
||||
expect(dup.map((r: { source: string }) => r.source)).toEqual(['image', 'runtime']);
|
||||
});
|
||||
|
||||
it('degrades an unreachable remote into nodeErrors without failing the whole request', async () => {
|
||||
stubDockerList([
|
||||
{ id: 'c1', name: 'a-1', state: 'running', stack: 's', service: 'a', imageId: 'img1', labels: { foo: 'bar' } },
|
||||
]);
|
||||
const db = DatabaseService.getInstance();
|
||||
const remoteId = db.addNode({ name: 'remote-lbl', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://remote.invalid', api_token: 't' });
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't' } : null);
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('nope', { status: 502 }));
|
||||
try {
|
||||
const res = await request(app).get('/api/fleet/container-labels').set('Cookie', authCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.nodeErrors[remoteId]).toBeDefined();
|
||||
expect(res.body.aggregatedByLabel.some((r: { key: string }) => r.key === 'foo')).toBe(true);
|
||||
} finally {
|
||||
db.deleteNode(remoteId);
|
||||
}
|
||||
});
|
||||
|
||||
it('degrades a malformed remote payload into nodeErrors, not a 500', async () => {
|
||||
stubDockerList([
|
||||
{ id: 'c1', name: 'a-1', state: 'running', stack: 's', service: 'a', imageId: 'img1', labels: { foo: 'bar' } },
|
||||
]);
|
||||
const db = DatabaseService.getInstance();
|
||||
const remoteId = db.addNode({ name: 'remote-bad', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://remote.invalid', api_token: 't' });
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't' } : null);
|
||||
// byLabel row missing valid key/value/source: must be rejected by the deep guard.
|
||||
const malformed = JSON.stringify({ nodeId: remoteId, containers: [], byLabel: [{ key: 123, containers: [] }], partial: false, generatedAt: 0 });
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(malformed, { status: 200, headers: { 'content-type': 'application/json' } }));
|
||||
try {
|
||||
const res = await request(app).get('/api/fleet/container-labels').set('Cookie', authCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.nodeErrors[remoteId]).toBeDefined();
|
||||
} finally {
|
||||
db.deleteNode(remoteId);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a remote row with an invalid source value via the source allowlist', async () => {
|
||||
stubDockerList([]);
|
||||
const db = DatabaseService.getInstance();
|
||||
const remoteId = db.addNode({ name: 'remote-src', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://remote.invalid', api_token: 't' });
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't' } : null);
|
||||
const badSource = JSON.stringify({ nodeId: remoteId, containers: [], partial: false, generatedAt: 0, byLabel: [{ key: 'k', value: 'v', source: 'not-a-source', containers: [{ id: 'c', name: 'n' }] }] });
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(badSource, { status: 200, headers: { 'content-type': 'application/json' } }));
|
||||
try {
|
||||
const res = await request(app).get('/api/fleet/container-labels').set('Cookie', authCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.nodeErrors[remoteId]).toBe('Remote returned an unexpected label-inventory payload');
|
||||
} finally {
|
||||
db.deleteNode(remoteId);
|
||||
}
|
||||
});
|
||||
|
||||
it('degrades a remote with a malformed inventory container into nodeErrors', async () => {
|
||||
stubDockerList([]);
|
||||
const db = DatabaseService.getInstance();
|
||||
const remoteId = db.addNode({ name: 'remote-cont', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://remote.invalid', api_token: 't' });
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't' } : null);
|
||||
const badContainer = JSON.stringify({ nodeId: remoteId, byLabel: [], partial: false, generatedAt: 0, containers: [{}] });
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(badContainer, { status: 200, headers: { 'content-type': 'application/json' } }));
|
||||
try {
|
||||
const res = await request(app).get('/api/fleet/container-labels').set('Cookie', authCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.nodeErrors[remoteId]).toBeDefined();
|
||||
} finally {
|
||||
db.deleteNode(remoteId);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a remote row with a malformed nested container ref', async () => {
|
||||
stubDockerList([]);
|
||||
const db = DatabaseService.getInstance();
|
||||
const remoteId = db.addNode({ name: 'remote-ref', type: 'remote', compose_dir: '/app/compose', is_default: false, api_url: 'http://remote.invalid', api_token: 't' });
|
||||
vi.spyOn(NodeRegistry.getInstance(), 'getProxyTarget').mockImplementation((id: number) => id === remoteId ? { apiUrl: 'http://remote.invalid', apiToken: 't' } : null);
|
||||
const badRef = JSON.stringify({ nodeId: remoteId, containers: [], partial: false, generatedAt: 0, byLabel: [{ key: 'k', value: 'v', source: 'runtime', containers: [{ id: 5 }] }] });
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(badRef, { status: 200, headers: { 'content-type': 'application/json' } }));
|
||||
try {
|
||||
const res = await request(app).get('/api/fleet/container-labels').set('Cookie', authCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.nodeErrors[remoteId]).toBeDefined();
|
||||
} finally {
|
||||
db.deleteNode(remoteId);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user