feat(fleet): add read-only dependency map tab (#1324)

* feat(fleet): add read-only dependency map tab

Add a fleet-wide Dependencies tab to Fleet view that maps how stacks,
services, networks, volumes, and ports relate, with flags for missing
dependencies, port conflicts, orphaned resources, and cross-stack shared
resources. Read-only; filterable by stack, node, and flag; collapsed by
default with a list-view fallback at scale.

The graph is derived at request time from Docker and compose metadata, so
no new table or persisted state is introduced. A per-node graph endpoint
feeds a hub aggregation endpoint that fans out across the fleet and
degrades gracefully, surfacing unreachable or unparseable nodes inline
while the rest of the map still renders.

* fix(fleet): harden dependency map flag detection and remote merge

Address review findings on the dependency map:
- Port-conflict detection now does pairwise host-scope overlap, so an
  unrelated bind on the same port and protocol but a different specific host
  IP is no longer flagged, and the flag lands on the exact scoped port node.
- A running service's depends_on target is only considered satisfied when it
  is actually running, so a crashed (exited) dependency is surfaced while a
  deliberately stopped stack stays quiet.
- Declared external networks and volumes are reported missing when they do
  not exist on the host instead of being assumed present.
- The hub deep-validates each remote node-graph payload before merging, so a
  reachable-but-malformed remote degrades to a single node error rather than
  failing the whole fleet map, and the validation failure is logged.
- Searching or filtering on a network, volume, or port now also reveals the
  services that claim it and their stacks.
This commit is contained in:
Anso
2026-06-06 11:18:36 -04:00
committed by GitHub
parent 13b6acab16
commit af4083175c
14 changed files with 2386 additions and 2 deletions
@@ -0,0 +1,96 @@
/**
* Unit tests for the dependency-map compose parser: depends_on / network /
* named-volume / declared-port extraction, external + name: resolution, and
* the fail-soft parseError paths.
*/
import { describe, it, expect } from 'vitest';
import { parseComposeDependencies } from '../helpers/composeDependencyParse';
const svc = (name: string, body: string) => `services:\n ${name}:\n${body.split('\n').map((l) => l ? ' ' + l : l).join('\n')}\n`;
describe('parseComposeDependencies - services and depends_on', () => {
it('extracts depends_on in list form', () => {
const r = parseComposeDependencies(svc('web', 'image: nginx\ndepends_on:\n - db\n - cache'));
expect(r.services[0].dependsOn).toEqual(['db', 'cache']);
});
it('extracts depends_on in map form', () => {
const r = parseComposeDependencies('services:\n web:\n depends_on:\n db:\n condition: service_healthy\n');
expect(r.services[0].dependsOn).toEqual(['db']);
});
it('extracts service networks in list and map form', () => {
const list = parseComposeDependencies(svc('web', 'networks:\n - frontend\n - backend'));
expect(list.services[0].networks).toEqual(['frontend', 'backend']);
const map = parseComposeDependencies('services:\n web:\n networks:\n frontend:\n aliases: [w]\n');
expect(map.services[0].networks).toEqual(['frontend']);
});
});
describe('parseComposeDependencies - volumes', () => {
it('keeps named volumes and drops binds and anonymous volumes', () => {
const r = parseComposeDependencies(svc('web', 'volumes:\n - db_data:/var/lib\n - ./local:/app\n - /abs/path:/data\n - /anon-target'));
expect(r.services[0].volumes).toEqual(['db_data']);
});
it('keeps a long-form named volume and drops a long-form bind', () => {
const r = parseComposeDependencies('services:\n web:\n volumes:\n - type: volume\n source: data\n target: /d\n - type: bind\n source: /host\n target: /b\n');
expect(r.services[0].volumes).toEqual(['data']);
});
});
describe('parseComposeDependencies - ports', () => {
it('parses short-form host:container with default tcp', () => {
const r = parseComposeDependencies(svc('web', 'ports:\n - "8080:80"'));
expect(r.services[0].ports).toEqual([{ hostIp: '', publishedPort: 8080, protocol: 'tcp' }]);
});
it('parses ip:host:container and the /udp protocol', () => {
const r = parseComposeDependencies(svc('web', 'ports:\n - "127.0.0.1:5353:53/udp"'));
expect(r.services[0].ports).toEqual([{ hostIp: '127.0.0.1', publishedPort: 5353, protocol: 'udp' }]);
});
it('drops a container-only port (no host publish)', () => {
const r = parseComposeDependencies(svc('web', 'ports:\n - "80"'));
expect(r.services[0].ports).toEqual([]);
});
it('takes the low end of a published range', () => {
const r = parseComposeDependencies(svc('web', 'ports:\n - "8000-8002:80"'));
expect(r.services[0].ports[0].publishedPort).toBe(8000);
});
it('parses long-form ports with host_ip and protocol', () => {
const r = parseComposeDependencies('services:\n web:\n ports:\n - target: 80\n published: 8080\n host_ip: 10.0.0.5\n protocol: udp\n');
expect(r.services[0].ports).toEqual([{ hostIp: '10.0.0.5', publishedPort: 8080, protocol: 'udp' }]);
});
});
describe('parseComposeDependencies - top-level resources', () => {
it('normalizes external (bool), legacy external object, and name: override', () => {
const r = parseComposeDependencies('services:\n web:\n image: nginx\nnetworks:\n a:\n b:\n external: true\n c:\n external:\n name: legacy_net\n d:\n name: custom_net\nvolumes:\n v:\n external: true\n');
expect(r.networks.a).toEqual({ external: false });
expect(r.networks.b).toEqual({ external: true });
expect(r.networks.c).toEqual({ name: 'legacy_net', external: true });
expect(r.networks.d).toEqual({ name: 'custom_net', external: false });
expect(r.volumes.v).toEqual({ external: true });
});
});
describe('parseComposeDependencies - fail soft', () => {
it('reports a parseError for invalid YAML and never throws', () => {
const r = parseComposeDependencies('services:\n web:\n - this: : is broken\n :::');
expect(r.parseError).toBeTruthy();
expect(r.services).toEqual([]);
});
it('reports a parseError when there are no services', () => {
const r = parseComposeDependencies('networks:\n a:\n');
expect(r.parseError).toBe('No services found in this file.');
});
it('reports a parseError for an oversized file', () => {
const r = parseComposeDependencies('x'.repeat(1_048_577));
expect(r.parseError).toBe('Compose file is too large to parse.');
});
});
@@ -0,0 +1,398 @@
/**
* Unit tests for the dependency-map graph builder: runtime edge derivation,
* the four anomaly flags (missing dependency, port conflict, orphan,
* cross-stack shared), and fail-soft compose parsing.
*/
import { describe, it, expect, vi } from 'vitest';
import {
assembleGraph,
detectPortConflicts,
detectMissingDependencies,
buildLocalGraph,
type PortClaim,
} from '../services/DependencyGraphService';
import DockerController from '../services/DockerController';
import type {
DependencySnapshot,
DependencyContainer,
DependencyNetwork,
DependencyVolume,
} from '../services/DockerController';
import { FileSystemService } from '../services/FileSystemService';
import type { DeclaredCompose } from '../helpers/composeDependencyParse';
// ── builders ────────────────────────────────────────────────────────────
const emptyDeclared = (): DeclaredCompose => ({ services: [], networks: {}, volumes: {} });
function snap(partial: Partial<DependencySnapshot>): DependencySnapshot {
return { containers: [], networks: [], volumes: [], ...partial };
}
function container(p: Partial<DependencyContainer> & { id: string }): DependencyContainer {
return {
name: p.id, service: null, composeProject: null, stack: null,
state: 'running', image: 'img:latest', networks: [], volumes: [], ports: [], ...p,
};
}
function network(p: Partial<DependencyNetwork> & { name: string }): DependencyNetwork {
return { id: p.name, driver: 'bridge', scope: 'local', isSystem: false, composeProject: null, stack: null, ...p };
}
function volume(p: Partial<DependencyVolume> & { name: string }): DependencyVolume {
return { driver: 'local', composeProject: null, stack: null, ...p };
}
const declMap = (entries: [string, DeclaredCompose][]): Map<string, DeclaredCompose> => new Map(entries);
// ── assembleGraph: runtime edges + identity ──────────────────────────────
describe('assembleGraph - runtime structure', () => {
it('emits host, stack, service nodes with compose service identity and runtime edges', () => {
const snapshot = snap({
containers: [
container({
id: 'c1', name: 'web-1', service: 'web', composeProject: 'web', stack: 'web',
networks: [{ name: 'web_frontend', id: 'net1', ip: '172.18.0.2' }],
volumes: ['web_data'],
ports: [{ ip: '', publishedPort: 8080, privatePort: 80, protocol: 'tcp' }],
}),
],
networks: [network({ name: 'web_frontend', id: 'net1', composeProject: 'web', stack: 'web' })],
volumes: [volume({ name: 'web_data', composeProject: 'web', stack: 'web' })],
});
const g = assembleGraph({ nodeId: 1, nodeName: 'hub', stacks: ['web'], snapshot, declaredByStack: declMap([['web', emptyDeclared()]]), parseErrors: [] });
expect(g.nodes.find((n) => n.kind === 'host')?.label).toBe('hub');
const svc = g.nodes.find((n) => n.id === 'svc:web:web');
expect(svc?.kind).toBe('service');
expect(svc?.label).toBe('web');
expect(g.nodes.some((n) => n.id === 'net:web_frontend')).toBe(true);
expect(g.nodes.some((n) => n.id === 'vol:web_data')).toBe(true);
expect(g.nodes.some((n) => n.kind === 'port' && n.id === 'port:*:8080/tcp')).toBe(true);
const kinds = g.edges.map((e) => e.kind);
expect(kinds).toContain('stack-node');
expect(kinds).toContain('stack-service');
expect(kinds).toContain('service-network');
expect(kinds).toContain('service-volume');
expect(kinds).toContain('service-port');
});
it('skips system networks (bridge/host/none)', () => {
const snapshot = snap({
containers: [container({ id: 'c1', service: 'app', stack: 'app', networks: [{ name: 'bridge', id: 'b', ip: '' }] })],
networks: [network({ name: 'bridge', id: 'b', isSystem: true })],
});
const g = assembleGraph({ nodeId: 1, nodeName: 'hub', stacks: ['app'], snapshot, declaredByStack: declMap([['app', emptyDeclared()]]), parseErrors: [] });
expect(g.nodes.some((n) => n.id === 'net:bridge')).toBe(false);
expect(g.edges.some((e) => e.kind === 'service-network')).toBe(false);
});
it('passes compose parse errors through unchanged', () => {
const g = assembleGraph({ nodeId: 1, nodeName: 'hub', stacks: ['web'], snapshot: snap({}), declaredByStack: declMap([['web', emptyDeclared()]]), parseErrors: [{ stack: 'web', error: 'bad yaml' }] });
expect(g.parseErrors).toEqual([{ stack: 'web', error: 'bad yaml' }]);
});
it('returns only the host node for an empty node', () => {
const g = assembleGraph({ nodeId: 1, nodeName: 'hub', stacks: [], snapshot: snap({}), declaredByStack: new Map(), parseErrors: [] });
expect(g.nodes).toHaveLength(1);
expect(g.nodes[0].kind).toBe('host');
expect(g.flags).toHaveLength(0);
});
it('scales to a large stack without error', () => {
const containers: DependencyContainer[] = [];
for (let i = 0; i < 500; i++) {
containers.push(container({ id: `c${i}`, service: `svc${i}`, stack: 'big' }));
}
const g = assembleGraph({ nodeId: 1, nodeName: 'hub', stacks: ['big'], snapshot: snap({ containers }), declaredByStack: declMap([['big', emptyDeclared()]]), parseErrors: [] });
expect(g.nodes.filter((n) => n.kind === 'service')).toHaveLength(500);
});
});
// ── assembleGraph: declared-only services + depends_on ───────────────────
describe('assembleGraph - declared services and depends_on', () => {
it('adds declared-only service nodes (absent) with a depends-on edge and missing-dependency flag', () => {
const snapshot = snap({
containers: [container({ id: 'c1', service: 'web', stack: 'web' })],
});
const declared: DeclaredCompose = {
services: [
{ name: 'web', dependsOn: ['db'], networks: [], volumes: [], ports: [] },
{ name: 'db', dependsOn: [], networks: [], volumes: [], ports: [] },
],
networks: {}, volumes: {},
};
const g = assembleGraph({ nodeId: 1, nodeName: 'hub', stacks: ['web'], snapshot, declaredByStack: declMap([['web', declared]]), parseErrors: [] });
const db = g.nodes.find((n) => n.id === 'svc:web:db');
expect(db?.state).toBe('absent');
expect(g.edges.some((e) => e.kind === 'depends-on' && e.declaredOnly && e.source === 'svc:web:web' && e.target === 'svc:web:db')).toBe(true);
expect(g.flags.some((f) => f.kind === 'missing-dependency')).toBe(true);
expect(g.nodes.find((n) => n.id === 'svc:web:web')?.flags).toContain('missing-dependency');
});
it('flags a depends_on target whose container is exited (not just absent)', () => {
const snapshot = snap({
containers: [
container({ id: 'c1', service: 'web', stack: 'web', state: 'running' }),
container({ id: 'c2', service: 'db', stack: 'web', state: 'exited' }),
],
});
const declared: DeclaredCompose = {
services: [
{ name: 'web', dependsOn: ['db'], networks: [], volumes: [], ports: [] },
{ name: 'db', dependsOn: [], networks: [], volumes: [], ports: [] },
],
networks: {}, volumes: {},
};
const g = assembleGraph({ nodeId: 1, nodeName: 'hub', stacks: ['web'], snapshot, declaredByStack: declMap([['web', declared]]), parseErrors: [] });
expect(g.flags.some((f) => f.kind === 'missing-dependency')).toBe(true);
expect(g.nodes.find((n) => n.id === 'svc:web:web')?.flags).toContain('missing-dependency');
});
it('does not flag internal dependencies of a fully stopped stack', () => {
const snapshot = snap({
containers: [
container({ id: 'c1', service: 'web', stack: 'web', state: 'exited' }),
container({ id: 'c2', service: 'db', stack: 'web', state: 'exited' }),
],
});
const declared: DeclaredCompose = {
services: [
{ name: 'web', dependsOn: ['db'], networks: [], volumes: [], ports: [] },
{ name: 'db', dependsOn: [], networks: [], volumes: [], ports: [] },
],
networks: {}, volumes: {},
};
const g = assembleGraph({ nodeId: 1, nodeName: 'hub', stacks: ['web'], snapshot, declaredByStack: declMap([['web', declared]]), parseErrors: [] });
expect(g.flags.some((f) => f.kind === 'missing-dependency')).toBe(false);
});
});
// ── assembleGraph: flags ─────────────────────────────────────────────────
describe('assembleGraph - flags', () => {
it('flags a cross-stack shared network', () => {
const snapshot = snap({
containers: [
container({ id: 'c1', service: 'a', stack: 'alpha', networks: [{ name: 'shared', id: 'sh', ip: '' }] }),
container({ id: 'c2', service: 'b', stack: 'beta', networks: [{ name: 'shared', id: 'sh', ip: '' }] }),
],
networks: [network({ name: 'shared', id: 'sh' })],
});
const g = assembleGraph({ nodeId: 1, nodeName: 'hub', stacks: ['alpha', 'beta'], snapshot, declaredByStack: declMap([['alpha', emptyDeclared()], ['beta', emptyDeclared()]]), parseErrors: [] });
expect(g.nodes.find((n) => n.id === 'net:shared')?.flags).toContain('cross-stack-shared');
expect(g.flags.some((f) => f.kind === 'cross-stack-shared')).toBe(true);
});
it('flags an orphaned (unmanaged, unreferenced) network and volume', () => {
const snapshot = snap({
networks: [network({ name: 'dangling_net', id: 'dn', stack: null })],
volumes: [volume({ name: 'dangling_vol', stack: null })],
});
const g = assembleGraph({ nodeId: 1, nodeName: 'hub', stacks: [], snapshot, declaredByStack: new Map(), parseErrors: [] });
expect(g.nodes.find((n) => n.id === 'net:dangling_net')?.flags).toContain('orphan');
expect(g.nodes.find((n) => n.id === 'vol:dangling_vol')?.flags).toContain('orphan');
expect(g.flags.filter((f) => f.kind === 'orphan')).toHaveLength(2);
});
it('does not flag a system network as orphan', () => {
const snapshot = snap({ networks: [network({ name: 'bridge', id: 'b', isSystem: true })] });
const g = assembleGraph({ nodeId: 1, nodeName: 'hub', stacks: [], snapshot, declaredByStack: new Map(), parseErrors: [] });
expect(g.flags.some((f) => f.kind === 'orphan')).toBe(false);
});
it('represents an orphan container as a flagged synthetic stack', () => {
const snapshot = snap({
containers: [container({ id: 'c1', name: 'ghost-1', service: 'ghost', composeProject: 'ghoststack', stack: null })],
});
const g = assembleGraph({ nodeId: 1, nodeName: 'hub', stacks: ['web'], snapshot, declaredByStack: declMap([['web', emptyDeclared()]]), parseErrors: [] });
const orphanStack = g.nodes.find((n) => n.id === 'stack:__orphan__:ghoststack');
expect(orphanStack?.flags).toContain('orphan');
expect(g.nodes.some((n) => n.id === 'svc:__orphan__:ghoststack:ghost')).toBe(true);
});
it('groups multiple orphan containers under their compose project', () => {
const snapshot = snap({
containers: [
container({ id: 'c1', name: 'g-1', service: 'svcA', composeProject: 'ghost', stack: null }),
container({ id: 'c2', name: 'g-2', service: 'svcB', composeProject: 'ghost', stack: null }),
container({ id: 'c3', name: 'h-1', service: 'svcC', composeProject: 'other', stack: null }),
],
});
const g = assembleGraph({ nodeId: 1, nodeName: 'hub', stacks: [], snapshot, declaredByStack: new Map(), parseErrors: [] });
expect(g.nodes.filter((n) => n.id.startsWith('stack:__orphan__:')).length).toBe(2);
expect(g.nodes.filter((n) => n.id.startsWith('svc:__orphan__:ghost:')).length).toBe(2);
});
it('attaches the port-conflict flag to the port node and the claiming services', () => {
const snapshot = snap({
containers: [
container({ id: 'c1', service: 'a', stack: 'alpha', ports: [{ ip: '', publishedPort: 8080, privatePort: 80, protocol: 'tcp' }] }),
container({ id: 'c2', service: 'b', stack: 'beta', ports: [{ ip: '', publishedPort: 8080, privatePort: 80, protocol: 'tcp' }] }),
],
});
const g = assembleGraph({ nodeId: 1, nodeName: 'hub', stacks: ['alpha', 'beta'], snapshot, declaredByStack: declMap([['alpha', emptyDeclared()], ['beta', emptyDeclared()]]), parseErrors: [] });
expect(g.nodes.find((n) => n.id === 'port:*:8080/tcp')?.flags).toContain('port-conflict');
expect(g.nodes.find((n) => n.id === 'svc:alpha:a')?.flags).toContain('port-conflict');
const flag = g.flags.find((f) => f.kind === 'port-conflict');
expect(flag?.subjects).toContain('port:*:8080/tcp');
});
it('flags a declared volume that does not exist at runtime', () => {
const declared: DeclaredCompose = { services: [{ name: 'web', dependsOn: [], networks: [], volumes: ['data'], ports: [] }], networks: {}, volumes: { data: { external: false } } };
const snapshot = snap({ containers: [container({ id: 'c1', service: 'web', stack: 'web' })] });
const g = assembleGraph({ nodeId: 1, nodeName: 'hub', stacks: ['web'], snapshot, declaredByStack: declMap([['web', declared]]), parseErrors: [] });
const missing = g.flags.filter((f) => f.kind === 'missing-dependency');
expect(missing.length).toBe(1);
expect(g.nodes.find((n) => n.id === 'svc:web:web')?.flags).toContain('missing-dependency');
});
});
// ── detectPortConflicts ──────────────────────────────────────────────────
describe('detectPortConflicts', () => {
const claim = (p: Partial<PortClaim>): PortClaim => ({ stack: 's', service: 'svc', hostIp: '', publishedPort: 80, protocol: 'tcp', ...p });
it('flags two stacks claiming the same host port', () => {
const conflicts = detectPortConflicts([claim({ stack: 'a', publishedPort: 8080 }), claim({ stack: 'b', publishedPort: 8080 })]);
expect(conflicts).toHaveLength(1);
expect(conflicts[0].port).toBe(8080);
expect(conflicts[0].protocol).toBe('tcp');
});
it('flags only the clashing specific-IP claimants, not an unrelated bind on the same port', () => {
const conflicts = detectPortConflicts([
claim({ stack: 'a', service: 'x', hostIp: '10.0.0.1', publishedPort: 443 }),
claim({ stack: 'b', service: 'y', hostIp: '10.0.0.1', publishedPort: 443 }),
claim({ stack: 'c', service: 'z', hostIp: '10.0.0.2', publishedPort: 443 }),
]);
expect(conflicts).toHaveLength(1);
expect(conflicts[0].scopes).toEqual(['10.0.0.1']);
const stacks = conflicts[0].claimants.map((c) => c.stack).sort();
expect(stacks).toEqual(['a', 'b']);
});
it('does not flag IPv4 + IPv6 bindings of one service publish', () => {
const conflicts = detectPortConflicts([
claim({ stack: 'a', service: 'x', hostIp: '0.0.0.0', publishedPort: 8080 }),
claim({ stack: 'a', service: 'x', hostIp: '::', publishedPort: 8080 }),
]);
expect(conflicts).toHaveLength(0);
});
it('flags two services in the same stack claiming the same port', () => {
const conflicts = detectPortConflicts([claim({ stack: 'a', service: 'x', publishedPort: 9000 }), claim({ stack: 'a', service: 'y', publishedPort: 9000 })]);
expect(conflicts).toHaveLength(1);
});
it('does not flag tcp vs udp on the same port', () => {
const conflicts = detectPortConflicts([claim({ stack: 'a', publishedPort: 53, protocol: 'tcp' }), claim({ stack: 'b', publishedPort: 53, protocol: 'udp' })]);
expect(conflicts).toHaveLength(0);
});
it('does not flag the same port bound to different specific host IPs', () => {
const conflicts = detectPortConflicts([claim({ stack: 'a', hostIp: '10.0.0.1', publishedPort: 443 }), claim({ stack: 'b', hostIp: '10.0.0.2', publishedPort: 443 })]);
expect(conflicts).toHaveLength(0);
});
it('flags the same port bound to the same specific host IP', () => {
const conflicts = detectPortConflicts([claim({ stack: 'a', hostIp: '10.0.0.1', publishedPort: 443 }), claim({ stack: 'b', hostIp: '10.0.0.1', publishedPort: 443 })]);
expect(conflicts).toHaveLength(1);
});
it('flags a wildcard bind clashing with a specific-IP bind', () => {
const conflicts = detectPortConflicts([claim({ stack: 'a', hostIp: '', publishedPort: 443 }), claim({ stack: 'b', hostIp: '10.0.0.2', publishedPort: 443 })]);
expect(conflicts).toHaveLength(1);
});
it('does not flag a single claimant', () => {
expect(detectPortConflicts([claim({ publishedPort: 80 })])).toHaveLength(0);
});
});
// ── detectMissingDependencies ────────────────────────────────────────────
describe('detectMissingDependencies', () => {
const base = (declared: DeclaredCompose, over: Partial<Parameters<typeof detectMissingDependencies>[0]> = {}) => ({
stack: 'web', declared, runningServices: new Set<string>(['web']), hasContainers: true,
stackNetworkNames: ['web_frontend'], stackVolumeNames: ['web_data'],
allNetworkNames: new Set(['web_frontend']), allVolumeNames: new Set(['web_data']), ...over,
});
it('returns nothing for a stack with no containers', () => {
const declared: DeclaredCompose = { services: [{ name: 'web', dependsOn: ['db'], networks: [], volumes: [], ports: [] }], networks: {}, volumes: {} };
expect(detectMissingDependencies(base(declared, { hasContainers: false }))).toHaveLength(0);
});
it('flags a depends_on target that is not running', () => {
const declared: DeclaredCompose = { services: [{ name: 'web', dependsOn: ['db'], networks: [], volumes: [], ports: [] }], networks: {}, volumes: {} };
const out = detectMissingDependencies(base(declared));
expect(out).toHaveLength(1);
expect(out[0].kind).toBe('service');
expect(out[0].target).toBe('db');
});
it('does not flag an external network that exists on the host', () => {
const declared: DeclaredCompose = { services: [{ name: 'web', dependsOn: [], networks: ['proxy'], volumes: [], ports: [] }], networks: { proxy: { external: true } }, volumes: {} };
expect(detectMissingDependencies(base(declared, { allNetworkNames: new Set(['proxy']) }))).toHaveLength(0);
});
it('flags an external network that does not exist on the host', () => {
const declared: DeclaredCompose = { services: [{ name: 'web', dependsOn: [], networks: ['proxy'], volumes: [], ports: [] }], networks: { proxy: { external: true } }, volumes: {} };
const out = detectMissingDependencies(base(declared, { allNetworkNames: new Set() }));
expect(out).toHaveLength(1);
expect(out[0].kind).toBe('network');
});
it('does not flag a network present via the project-prefix suffix match', () => {
const declared: DeclaredCompose = { services: [{ name: 'web', dependsOn: [], networks: ['frontend'], volumes: [], ports: [] }], networks: { frontend: { external: false } }, volumes: {} };
expect(detectMissingDependencies(base(declared))).toHaveLength(0);
});
it('matches a network by its explicit name: override', () => {
const declared: DeclaredCompose = { services: [{ name: 'web', dependsOn: [], networks: ['proxy'], volumes: [], ports: [] }], networks: { proxy: { name: 'shared_net', external: false } }, volumes: {} };
expect(detectMissingDependencies(base(declared, { stackNetworkNames: [], allNetworkNames: new Set(['shared_net']) }))).toHaveLength(0);
});
it('flags a name:-override network that is absent at runtime', () => {
const declared: DeclaredCompose = { services: [{ name: 'web', dependsOn: [], networks: ['proxy'], volumes: [], ports: [] }], networks: { proxy: { name: 'shared_net', external: false } }, volumes: {} };
const out = detectMissingDependencies(base(declared, { stackNetworkNames: [], allNetworkNames: new Set() }));
expect(out).toHaveLength(1);
expect(out[0].kind).toBe('network');
});
it('flags a declared network that does not exist at runtime', () => {
const declared: DeclaredCompose = { services: [{ name: 'web', dependsOn: [], networks: ['backend'], volumes: [], ports: [] }], networks: { backend: { external: false } }, volumes: {} };
const out = detectMissingDependencies(base(declared));
expect(out).toHaveLength(1);
expect(out[0].kind).toBe('network');
});
});
// ── buildLocalGraph: fail-soft orchestration (mocked) ────────────────────
describe('buildLocalGraph - fail-soft', () => {
it('records a parse error for an unreadable stack and still builds the rest', async () => {
vi.spyOn(DockerController, 'getInstance').mockReturnValue({
getDependencySnapshot: vi.fn().mockResolvedValue(snap({})),
} as unknown as DockerController);
vi.spyOn(FileSystemService, 'getInstance').mockReturnValue({
getStacks: vi.fn().mockResolvedValue(['web', 'broken']),
getStackContent: vi.fn().mockImplementation(async (s: string) => {
if (s === 'broken') throw new Error('EISDIR');
return 'services:\n web:\n image: nginx';
}),
} as unknown as FileSystemService);
const g = await buildLocalGraph(1, 'hub');
expect(g.parseErrors.some((p) => p.stack === 'broken')).toBe(true);
expect(g.nodes.some((n) => n.id === 'stack:web')).toBe(true);
vi.restoreAllMocks();
});
});
@@ -0,0 +1,91 @@
/**
* Unit tests for the fleet dependency-map merge: per-node id namespacing,
* authoritative node attribution, and graceful partial-failure handling.
*/
import { describe, it, expect } from 'vitest';
import { mergeFleetGraph, isLocalDependencyGraph, type FleetNodeGraphResult, type LocalDependencyGraph } from '../services/DependencyGraphService';
function localGraph(nodeId: number, nodeName: string, stack: string): LocalDependencyGraph {
return {
nodeId,
nodeName,
nodes: [
{ id: 'host', kind: 'host', label: nodeName, nodeId, nodeName, stack: null, flags: [] },
{ id: `stack:${stack}`, kind: 'stack', label: stack, nodeId, nodeName, stack, flags: [] },
],
edges: [{ id: `e:host-stack:${stack}`, source: 'host', target: `stack:${stack}`, kind: 'stack-node' }],
flags: [{ kind: 'orphan', nodeId, nodeName, subjects: [`stack:${stack}`], detail: 'x' }],
parseErrors: [{ stack, error: 'bad' }],
};
}
const ok = (nodeId: number, nodeName: string, stack: string): FleetNodeGraphResult => ({
nodeId, nodeName, status: 'ok', graph: localGraph(nodeId, nodeName, stack), error: null,
});
const err = (nodeId: number, nodeName: string, error: string): FleetNodeGraphResult => ({
nodeId, nodeName, status: 'error', graph: null, error,
});
describe('mergeFleetGraph', () => {
it('namespaces ids by node so identical stack names stay distinct', () => {
const merged = mergeFleetGraph([ok(1, 'hub', 'web'), ok(2, 'edge', 'web')]);
const stackNodes = merged.nodes.filter((n) => n.kind === 'stack');
expect(stackNodes.map((n) => n.id).sort()).toEqual(['n1:stack:web', 'n2:stack:web']);
expect(merged.edges.map((e) => e.id).sort()).toEqual(['n1:e:host-stack:web', 'n2:e:host-stack:web']);
});
it('re-stamps host label and attribution from the hub registry', () => {
const graph = localGraph(1, 'wrong-self-name', 'web');
const merged = mergeFleetGraph([{ nodeId: 7, nodeName: 'authoritative', status: 'ok', graph, error: null }]);
const host = merged.nodes.find((n) => n.kind === 'host');
expect(host?.label).toBe('authoritative');
expect(host?.nodeName).toBe('authoritative');
expect(host?.id).toBe('n7:host');
});
it('namespaces flag subjects and carries parse errors with node attribution', () => {
const merged = mergeFleetGraph([ok(3, 'edge', 'api')]);
expect(merged.flags[0].subjects).toEqual(['n3:stack:api']);
expect(merged.parseErrors).toEqual([{ nodeId: 3, nodeName: 'edge', stack: 'api', error: 'bad' }]);
});
it('degrades a failed node to nodeErrors while keeping healthy nodes', () => {
const merged = mergeFleetGraph([ok(1, 'hub', 'web'), err(2, 'edge', 'unreachable')]);
expect(merged.nodeErrors).toEqual([{ nodeId: 2, nodeName: 'edge', error: 'unreachable' }]);
expect(merged.nodes.some((n) => n.id === 'n1:stack:web')).toBe(true);
expect(merged.nodes.some((n) => n.id.startsWith('n2:'))).toBe(false);
});
it('returns an empty graph with full nodeErrors when every node fails', () => {
const merged = mergeFleetGraph([err(1, 'hub', 'down'), err(2, 'edge', 'down')]);
expect(merged.nodes).toHaveLength(0);
expect(merged.edges).toHaveLength(0);
expect(merged.nodeErrors).toHaveLength(2);
});
});
describe('isLocalDependencyGraph', () => {
it('accepts a well-formed graph and one with parseErrors absent', () => {
expect(isLocalDependencyGraph({ nodes: [{ id: 'host', flags: [] }], edges: [{ id: 'e', source: 'a', target: 'b' }], flags: [{ subjects: ['a'] }], parseErrors: [{ stack: 's', error: 'x' }] })).toBe(true);
expect(isLocalDependencyGraph({ nodes: [], edges: [], flags: [] })).toBe(true);
});
it('rejects null and non-array core fields', () => {
expect(isLocalDependencyGraph(null)).toBe(false);
expect(isLocalDependencyGraph({ nodes: {}, edges: [], flags: [] })).toBe(false);
});
it('rejects a node missing its flags array', () => {
expect(isLocalDependencyGraph({ nodes: [{ id: 'x' }], edges: [], flags: [] })).toBe(false);
});
it('rejects flags whose subjects are missing or non-string (would corrupt merge)', () => {
expect(isLocalDependencyGraph({ nodes: [], edges: [], flags: [{}] })).toBe(false);
expect(isLocalDependencyGraph({ nodes: [], edges: [], flags: [{ subjects: [1] }] })).toBe(false);
});
it('rejects malformed parseErrors elements (would throw in merge)', () => {
expect(isLocalDependencyGraph({ nodes: [], edges: [], flags: [], parseErrors: [null] })).toBe(false);
expect(isLocalDependencyGraph({ nodes: [], edges: [], flags: [], parseErrors: ['oops'] })).toBe(false);
});
});
@@ -44,6 +44,7 @@ vi.mock('util', () => ({
}));
import DockerController from '../services/DockerController';
import { CacheService } from '../services/CacheService';
beforeEach(() => {
vi.clearAllMocks();
@@ -1111,3 +1112,70 @@ describe('DockerController - disconnectContainerFromNetwork', () => {
});
});
});
// ── getDependencySnapshot ──────────────────────────────────────────────
describe('DockerController - getDependencySnapshot', () => {
beforeEach(() => {
// resolveProjectNameMap caches under a constant key; clear it so each test
// resolves stack ownership from its own mocked compose set.
CacheService.getInstance().invalidate('project-name-map');
});
it('maps service identity, networks, volume mounts, and published ports', async () => {
mockDocker.listContainers.mockResolvedValue([
{
Id: 'abc123def456',
Names: ['/web-1'],
Image: 'nginx:alpine',
State: 'running',
Labels: { 'com.docker.compose.project': 'web', 'com.docker.compose.service': 'api' },
NetworkSettings: { Networks: { web_frontend: { NetworkID: 'net1', IPAddress: '172.18.0.2' } } },
Mounts: [
{ Type: 'volume', Name: 'web_data', Destination: '/data' },
{ Type: 'bind', Source: '/host/path', Destination: '/app' },
],
Ports: [
{ IP: '0.0.0.0', PrivatePort: 80, PublicPort: 8080, Type: 'tcp' },
{ PrivatePort: 9090, Type: 'tcp' },
],
},
]);
mockDocker.listNetworks.mockResolvedValue([
{ Id: 'b', Name: 'bridge' },
{ Id: 'net1', Name: 'web_frontend', Driver: 'bridge', Scope: 'local', Labels: { 'com.docker.compose.project': 'web' } },
]);
mockDocker.listVolumes.mockResolvedValue({ Volumes: [{ Name: 'web_data', Driver: 'local', Labels: { 'com.docker.compose.project': 'web' } }] });
const dc = DockerController.getInstance(1);
const snap = await dc.getDependencySnapshot(['web']);
const c = snap.containers[0];
expect(c.service).toBe('api');
expect(c.stack).toBe('web');
expect(c.networks).toEqual([{ name: 'web_frontend', id: 'net1', ip: '172.18.0.2' }]);
expect(c.volumes).toEqual(['web_data']); // bind mount dropped
expect(c.ports).toEqual([{ ip: '0.0.0.0', publishedPort: 8080, privatePort: 80, protocol: 'tcp' }]); // unpublished 9090 dropped
expect(snap.networks.find((n) => n.name === 'bridge')?.isSystem).toBe(true);
const frontend = snap.networks.find((n) => n.name === 'web_frontend');
expect(frontend?.isSystem).toBe(false);
expect(frontend?.stack).toBe('web');
expect(snap.volumes[0]).toMatchObject({ name: 'web_data', stack: 'web', composeProject: 'web' });
});
it('classifies a non-compose container as having no service or stack', async () => {
mockDocker.listContainers.mockResolvedValue([
{ Id: 'x', Names: ['/manual'], Image: 'redis', State: 'running', Labels: {}, NetworkSettings: { Networks: {} }, Mounts: [], Ports: [] },
]);
mockDocker.listNetworks.mockResolvedValue([]);
mockDocker.listVolumes.mockResolvedValue({ Volumes: [] });
const dc = DockerController.getInstance(1);
const snap = await dc.getDependencySnapshot([]);
expect(snap.containers[0].service).toBeNull();
expect(snap.containers[0].stack).toBeNull();
expect(snap.containers[0].composeProject).toBeNull();
});
});