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();
});
});
@@ -0,0 +1,199 @@
import YAML from 'yaml';
// Refuse to parse anything beyond this bound so a malformed (or adversarial)
// compose file cannot exhaust heap while building the dependency map. Mirrors
// the cap in composePreview.ts.
const MAX_COMPOSE_PARSE_BYTES = 1_048_576; // 1 MiB
/** A host-published port declared in a compose service. */
export interface DeclaredPort {
/** Host interface ('' means all interfaces). */
hostIp: string;
publishedPort: number;
protocol: string;
}
export interface DeclaredService {
name: string;
/** depends_on targets (list or map form, normalized to names). */
dependsOn: string[];
/** Service-level network keys (list or map form). */
networks: string[];
/** Named-volume source keys only (bind mounts and anonymous volumes excluded). */
volumes: string[];
ports: DeclaredPort[];
}
/** A top-level networks:/volumes: entry. */
export interface DeclaredResource {
/** Explicit `name:` override, when set. */
name?: string;
external: boolean;
}
export interface DeclaredCompose {
services: DeclaredService[];
networks: Record<string, DeclaredResource>;
volumes: Record<string, DeclaredResource>;
/** Set when the file could not be parsed; the other fields are then empty. */
parseError?: string;
}
function asString(value: unknown): string | undefined {
if (typeof value === 'string') return value;
if (typeof value === 'number') return String(value);
return undefined;
}
/** Extracts keys from a depends_on / networks block in list or map form. */
function collectKeys(value: unknown): string[] {
if (Array.isArray(value)) {
return value.map(asString).filter((v): v is string => v !== undefined);
}
if (value && typeof value === 'object') {
return Object.keys(value as Record<string, unknown>);
}
return [];
}
/** True when a volume source is a named volume (not a bind mount or anonymous). */
function isNamedVolumeSource(source: string): boolean {
if (!source) return false;
if (source.includes('/')) return false; // bind mount path
if (source.startsWith('.') || source.startsWith('~')) return false;
if (/^[a-zA-Z]:[\\/]/.test(source)) return false; // Windows drive path
return true;
}
/** Pulls the named-volume source from a service volume entry, or null. */
function namedVolumeSource(entry: unknown): string | null {
const short = asString(entry);
if (short !== undefined) {
const source = short.split(':')[0];
return isNamedVolumeSource(source) ? source : null;
}
if (entry && typeof entry === 'object') {
const obj = entry as Record<string, unknown>;
if (obj.type === 'volume') {
const source = asString(obj.source);
return source ? source : null;
}
}
return null;
}
/** Parses a service `ports:` entry into a host-published port, or null. */
function declaredPort(entry: unknown): DeclaredPort | null {
const short = asString(entry);
if (short !== undefined) {
const [spec, proto] = short.split('/');
const parts = spec.split(':');
let hostIp = '';
let hostPart: string | undefined;
if (parts.length >= 3) {
hostIp = parts[0];
hostPart = parts[1];
} else if (parts.length === 2) {
hostPart = parts[0];
} else {
return null; // container-only EXPOSE, not host-published
}
const published = parseInt((hostPart ?? '').split('-')[0], 10);
if (!Number.isFinite(published) || published <= 0) return null;
return { hostIp, publishedPort: published, protocol: proto || 'tcp' };
}
if (entry && typeof entry === 'object') {
const obj = entry as Record<string, unknown>;
const publishedRaw = asString(obj.published);
if (publishedRaw === undefined) return null; // not host-published
const published = parseInt(publishedRaw.split('-')[0], 10);
if (!Number.isFinite(published) || published <= 0) return null;
return {
hostIp: asString(obj.host_ip) ?? '',
publishedPort: published,
protocol: asString(obj.protocol) ?? 'tcp',
};
}
return null;
}
/** Normalizes a top-level networks:/volumes: entry to { name?, external }. */
function declaredResource(entry: unknown): DeclaredResource {
if (!entry || typeof entry !== 'object') return { external: false };
const obj = entry as Record<string, unknown>;
const name = asString(obj.name);
// `external` may be a boolean or the legacy `{ name: ... }` object form.
let external = false;
let externalName: string | undefined;
if (obj.external === true) {
external = true;
} else if (obj.external && typeof obj.external === 'object') {
external = true;
externalName = asString((obj.external as Record<string, unknown>).name);
}
const resolved = name ?? externalName;
return resolved ? { name: resolved, external } : { external };
}
function collectResources(value: unknown): Record<string, DeclaredResource> {
const out: Record<string, DeclaredResource> = {};
if (value && typeof value === 'object' && !Array.isArray(value)) {
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
out[key] = declaredResource(entry);
}
}
return out;
}
/**
* Parse a compose file's declared dependency metadata: per-service depends_on,
* networks, named volumes, and host-published ports, plus the top-level
* networks:/volumes: declarations with their name: overrides and external
* flags. Never throws: parse failures are reported through `parseError`.
*
* Runtime edges in the dependency map come from the live container snapshot;
* this declared view is used only to flag missing dependencies and to gather
* declared port claimants for conflict detection.
*/
export function parseComposeDependencies(content: string): DeclaredCompose {
if (content.length > MAX_COMPOSE_PARSE_BYTES) {
return { services: [], networks: {}, volumes: {}, parseError: 'Compose file is too large to parse.' };
}
let parsed: unknown;
try {
parsed = YAML.parse(content);
} catch (error) {
return { services: [], networks: {}, volumes: {}, parseError: `Could not parse compose file: ${(error as Error).message}` };
}
const root = (parsed ?? {}) as Record<string, unknown>;
const rawServices = root.services;
if (!rawServices || typeof rawServices !== 'object') {
return { services: [], networks: {}, volumes: {}, parseError: 'No services found in this file.' };
}
const services: DeclaredService[] = [];
for (const [name, raw] of Object.entries(rawServices as Record<string, unknown>)) {
const svc = (raw ?? {}) as Record<string, unknown>;
const volumes = Array.isArray(svc.volumes)
? svc.volumes.map(namedVolumeSource).filter((v): v is string => v !== null)
: [];
const ports = Array.isArray(svc.ports)
? svc.ports.map(declaredPort).filter((p): p is DeclaredPort => p !== null)
: [];
services.push({
name,
dependsOn: collectKeys(svc.depends_on),
networks: collectKeys(svc.networks),
volumes,
ports,
});
}
return {
services,
networks: collectResources(root.networks),
volumes: collectResources(root.volumes),
};
}
+2
View File
@@ -54,6 +54,7 @@ import { fileExplorerMetricsRouter } from './routes/fileExplorerMetrics';
import { stackActivityMetricsRouter } from './routes/stackActivityMetrics';
import { secretsRouter } from './routes/secrets';
import { diagnosticsRouter } from './routes/diagnostics';
import { dependencyMapRouter } from './routes/dependencyMap';
// Suppress [DEP0060] DeprecationWarning emitted by http-proxy@1.18.1 which calls
// util._extend internally. The warning fires at runtime when createProxyServer() is
@@ -143,6 +144,7 @@ app.use('/api/containers', containersRouter);
app.use('/api/ports', portsRouter);
app.use('/api/dashboard', dashboardRouter);
app.use('/api/diagnostics', diagnosticsRouter);
app.use('/api/dependency-map', dependencyMapRouter);
app.use('/api/nodes', nodesRouter);
app.use('/api/stacks', stackActivityRouter);
app.use('/api/stacks', stacksRouter);
+23
View File
@@ -0,0 +1,23 @@
import { Router, type Request, type Response } from 'express';
import { authMiddleware } from '../middleware/auth';
import { DatabaseService } from '../services/DatabaseService';
import { buildLocalGraph } from '../services/DependencyGraphService';
export const dependencyMapRouter = Router();
/**
* Per-node dependency graph. Auth-only (never tier-gated) so the hub's
* fleet-wide fan-out can reach this route on every node, including Community
* remotes. Served against the local Docker of whichever node handles it.
*/
dependencyMapRouter.get('/node-graph', authMiddleware, async (req: Request, res: Response): Promise<void> => {
try {
const nodeId = req.nodeId;
const name = DatabaseService.getInstance().getNodes().find((n) => n.id === nodeId)?.name ?? 'This node';
const graph = await buildLocalGraph(nodeId, name);
res.json(graph);
} catch (error) {
console.error('[DependencyMap] node-graph error:', error);
res.status(500).json({ error: 'Failed to build dependency graph' });
}
});
+60
View File
@@ -37,6 +37,7 @@ import { invalidateNodeCaches, invalidateRemoteMetaCache } from '../helpers/cach
import { activeBulkActions } from './labels';
import { runLocalLabelStop, type LabelLocalStopResponse, type StackStopResult } from '../helpers/fleetLabelStop';
import { buildLocalConfigurationStatus, type ConfigurationStatus } from './dashboard';
import { buildLocalGraph, mergeFleetGraph, isLocalDependencyGraph, type FleetNodeGraphResult } from '../services/DependencyGraphService';
import { PROXY_TIER_HEADER } from '../services/license-headers';
import { LicenseService } from '../services/LicenseService';
@@ -642,6 +643,65 @@ fleetRouter.get('/configuration', authMiddleware, async (req: Request, res: Resp
}
});
/**
* Fleet-wide dependency map. Auth-only (read-only visibility, Community). Fans
* out to every node, building each node's local graph in-process for the hub
* and via its auth-only per-node route for remotes, then merges with per-node
* attribution. Unreachable nodes degrade to nodeErrors so the rest still draws.
*/
fleetRouter.get('/dependency-map', authMiddleware, async (_req: Request, res: Response): Promise<void> => {
try {
const db = DatabaseService.getInstance();
const nodes = db.getNodes();
const results = await Promise.allSettled(
nodes.map(async (node: Node): Promise<FleetNodeGraphResult> => {
if (node.type === 'local') {
const graph = await buildLocalGraph(node.id, node.name);
return { nodeId: node.id, nodeName: node.name, status: 'ok', graph, error: null };
}
const target = NodeRegistry.getInstance().getProxyTarget(node.id);
if (!target) {
return { nodeId: node.id, nodeName: node.name, status: 'error', graph: null, error: formatNoTargetError(node) };
}
const resp = await fetch(
`${target.apiUrl.replace(/\/$/, '')}/api/dependency-map/node-graph`,
{
headers: { ...(target.apiToken ? { Authorization: `Bearer ${target.apiToken}` } : {}) },
signal: AbortSignal.timeout(15000),
},
);
if (!resp.ok) {
const errBody = await resp.json().catch(() => null) as { error?: string } | null;
return { nodeId: node.id, nodeName: node.name, status: 'error', graph: null, error: errBody?.error ?? `Remote returned ${resp.status}` };
}
const graph = await resp.json().catch(() => null);
// Shape-guard a reachable-but-malformed payload (proxy HTML, version
// drift) so one bad node degrades to a nodeError instead of crashing
// mergeFleetGraph and 500-ing the whole fleet map.
if (!isLocalDependencyGraph(graph)) {
console.error(`[Fleet] Dependency map: node ${sanitizeForLog(node.name)} returned a payload that failed shape validation (status ${resp.status})`);
return { nodeId: node.id, nodeName: node.name, status: 'error', graph: null, error: 'Remote returned an unexpected dependency-graph payload' };
}
return { nodeId: node.id, nodeName: node.name, status: 'ok', graph, error: null };
}),
);
const perNode: FleetNodeGraphResult[] = results.map((result, i) => {
if (result.status === 'fulfilled') return result.value;
console.error(`[Fleet] Dependency map fetch failed for node ${nodes[i].name}:`, result.reason);
return { nodeId: nodes[i].id, nodeName: nodes[i].name, status: 'error', graph: null, error: getErrorMessage(result.reason, 'Failed to reach node') };
});
res.json(mergeFleetGraph(perNode));
} catch (error) {
console.error('[Fleet] Dependency map error:', error);
res.status(500).json({ error: 'Failed to build fleet dependency map' });
}
});
fleetRouter.get('/node/:nodeId/stacks', authMiddleware, async (req: Request, res: Response): Promise<void> => {
try {
const nodeId = parseIntParam(req, res, 'nodeId', 'node ID');
@@ -0,0 +1,576 @@
import DockerController from './DockerController';
import type {
DependencySnapshot,
DependencyContainer,
DependencyNetwork,
DependencyVolume,
} from './DockerController';
import { FileSystemService } from './FileSystemService';
import { parseComposeDependencies } from '../helpers/composeDependencyParse';
import type { DeclaredCompose } from '../helpers/composeDependencyParse';
export type DepNodeKind = 'host' | 'stack' | 'service' | 'network' | 'volume' | 'port';
export type DepFlagKind = 'missing-dependency' | 'port-conflict' | 'orphan' | 'cross-stack-shared';
export type DepEdgeKind =
| 'stack-node'
| 'stack-service'
| 'depends-on'
| 'service-network'
| 'service-volume'
| 'service-port';
export interface DepNode {
id: string;
kind: DepNodeKind;
label: string;
nodeId: number;
nodeName: string;
/** Owning stack, or null for the host and shared resources. */
stack: string | null;
managedStatus?: 'managed' | 'unmanaged' | 'system';
/** Container/service state when applicable (e.g. running, exited, absent). */
state?: string;
flags: DepFlagKind[];
}
export interface DepEdge {
id: string;
source: string;
target: string;
kind: DepEdgeKind;
/** Declared in compose but not observed at runtime. */
declaredOnly?: boolean;
}
export interface DepFlag {
kind: DepFlagKind;
nodeId: number;
nodeName: string;
/** Graph-node ids the flag applies to. */
subjects: string[];
detail: string;
}
export interface LocalDependencyGraph {
nodeId: number;
nodeName: string;
nodes: DepNode[];
edges: DepEdge[];
flags: DepFlag[];
parseErrors: { stack: string; error: string }[];
}
export type FleetNodeGraphResult =
| { nodeId: number; nodeName: string; status: 'ok'; graph: LocalDependencyGraph; error: null }
| { nodeId: number; nodeName: string; status: 'error'; graph: null; error: string };
export interface FleetDependencyMap {
nodes: DepNode[];
edges: DepEdge[];
flags: DepFlag[];
nodeErrors: { nodeId: number; nodeName: string; error: string }[];
parseErrors: { nodeId: number; nodeName: string; stack: string; error: string }[];
}
// --- ID helpers (local, pre-merge namespace) ---------------------------------
const hostId = (): string => 'host';
const stackId = (stack: string): string => `stack:${stack}`;
const serviceId = (stack: string, service: string): string => `svc:${stack}:${service}`;
const networkId = (name: string): string => `net:${name}`;
const volumeId = (name: string): string => `vol:${name}`;
/** Normalizes an all-interfaces host IP to '*' so equivalent binds collapse. */
const portScope = (ip: string): string => (ip === '' || ip === '0.0.0.0' || ip === '::' ? '*' : ip);
const portId = (ip: string, port: number, proto: string): string => `port:${portScope(ip)}:${port}/${proto}`;
/** Container states that count as "up" for depends_on satisfaction. */
const RUNNING_STATES = new Set(['running', 'restarting']);
// --- Pure flag detectors (unit-tested directly) ------------------------------
export interface PortClaim {
stack: string;
service: string;
hostIp: string;
publishedPort: number;
protocol: string;
}
export interface PortConflict {
port: number;
protocol: string;
/** Normalized host scopes that actually clash ('*' or a specific IP). */
scopes: string[];
claimants: { stack: string; service: string }[];
}
/**
* Groups port claims by published port + protocol, then does pairwise
* scope-overlap detection within each group. Two binds overlap when they share
* a specific host IP or at least one binds all interfaces ('*'). Only the
* claimants and scopes that genuinely clash are returned, so a same-stack
* two-service collision is caught while tcp-vs-udp, distinct specific-IP binds,
* and the two bindings of one service's single publish (IPv4 + IPv6) are not
* over-flagged.
*/
export function detectPortConflicts(claims: PortClaim[]): PortConflict[] {
const groups = new Map<string, PortClaim[]>();
for (const claim of claims) {
const key = `${claim.publishedPort}/${claim.protocol}`;
const list = groups.get(key) ?? [];
list.push(claim);
groups.set(key, list);
}
const claimantKey = (c: PortClaim): string => JSON.stringify([c.stack, c.service]);
const overlaps = (a: PortClaim, b: PortClaim): boolean => {
const sa = portScope(a.hostIp);
const sb = portScope(b.hostIp);
return sa === sb || sa === '*' || sb === '*';
};
const conflicts: PortConflict[] = [];
for (const list of groups.values()) {
const conflicting = new Set<number>();
for (let i = 0; i < list.length; i++) {
for (let j = i + 1; j < list.length; j++) {
if (claimantKey(list[i]) === claimantKey(list[j])) continue;
if (overlaps(list[i], list[j])) { conflicting.add(i); conflicting.add(j); }
}
}
if (conflicting.size === 0) continue;
const clashing = [...conflicting].map((i) => list[i]);
const claimants = [...new Map(clashing.map((c) => [claimantKey(c), { stack: c.stack, service: c.service }])).values()];
const scopes = [...new Set(clashing.map((c) => portScope(c.hostIp)))];
conflicts.push({ port: clashing[0].publishedPort, protocol: clashing[0].protocol, scopes, claimants });
}
return conflicts;
}
/**
* A declared network/volume key is present when a matching runtime resource
* exists. External resources are operator-managed and not project-prefixed, so
* their resolved name (a `name:` override or the key) must exist somewhere on
* the host. Stack-owned resources match by exact `name:` or the `<project>_<key>`
* suffix, which tolerates a top-level `name:` project override without false
* positives.
*/
function declaredPresent(
key: string,
resource: { name?: string; external: boolean } | undefined,
stackResourceNames: string[],
allResourceNames: Set<string>,
): boolean {
const explicit = resource?.name;
if (resource?.external) return allResourceNames.has(explicit ?? key);
if (explicit) return stackResourceNames.includes(explicit) || allResourceNames.has(explicit);
return stackResourceNames.some((n) => n === key || n.endsWith(`_${key}`));
}
export interface MissingDepInput {
stack: string;
declared: DeclaredCompose;
/** Service names with a running (or restarting) container in this stack. */
runningServices: Set<string>;
/** Whether this stack has any container at all (deployed). */
hasContainers: boolean;
/** Runtime network names owned by this stack. */
stackNetworkNames: string[];
/** Runtime volume names owned by this stack. */
stackVolumeNames: string[];
/** All runtime network names on the node. */
allNetworkNames: Set<string>;
/** All runtime volume names on the node. */
allVolumeNames: Set<string>;
}
/**
* Flags compose-declared dependencies that are not present at runtime: a
* running service whose depends_on target is not running, or a declared
* (non-external) network/volume that does not exist. Only evaluated for deployed
* stacks (hasContainers), so a never-deployed stack flags nothing. The depends_on
* check is additionally gated on the depending service itself running, so a
* deliberately stopped stack does not flag its own internal service dependencies;
* network/volume presence is still checked for any deployed stack.
*/
export function detectMissingDependencies(
input: MissingDepInput,
): { kind: 'service' | 'network' | 'volume'; service: string; target: string; detail: string }[] {
const out: { kind: 'service' | 'network' | 'volume'; service: string; target: string; detail: string }[] = [];
if (!input.hasContainers) return out;
for (const svc of input.declared.services) {
if (input.runningServices.has(svc.name)) {
for (const dep of svc.dependsOn) {
if (!input.runningServices.has(dep)) {
out.push({ kind: 'service', service: svc.name, target: dep, detail: `Service "${svc.name}" depends on "${dep}", which is not running.` });
}
}
}
for (const net of svc.networks) {
if (!declaredPresent(net, input.declared.networks[net], input.stackNetworkNames, input.allNetworkNames)) {
out.push({ kind: 'network', service: svc.name, target: net, detail: `Service "${svc.name}" references network "${net}", which does not exist.` });
}
}
for (const vol of svc.volumes) {
if (!declaredPresent(vol, input.declared.volumes[vol], input.stackVolumeNames, input.allVolumeNames)) {
out.push({ kind: 'volume', service: svc.name, target: vol, detail: `Service "${svc.name}" references volume "${vol}", which does not exist.` });
}
}
}
return out;
}
// --- Local graph builder -----------------------------------------------------
/**
* Builds the dependency graph for a single node from one Docker snapshot plus
* the declared compose metadata of each stack. Edges and resource nodes come
* from runtime (what is actually deployed); declared data drives the
* missing-dependency and port-conflict flags. Per-stack compose parsing fails
* soft: a malformed file is recorded in parseErrors and never aborts the graph.
* Note: getStacks() itself fails soft (returns []), so a node whose compose
* directory is unreadable or misconfigured shows no managed stacks rather than
* an error; only Docker failures here surface as a node-level error.
*/
export async function buildLocalGraph(nodeId: number, nodeName: string): Promise<LocalDependencyGraph> {
const docker = DockerController.getInstance(nodeId);
const fs = FileSystemService.getInstance(nodeId);
const stacks = await fs.getStacks();
const snapshot = await docker.getDependencySnapshot(stacks);
const declaredByStack = new Map<string, DeclaredCompose>();
const parseErrors: { stack: string; error: string }[] = [];
for (const stack of stacks) {
try {
const content = await fs.getStackContent(stack);
const declared = parseComposeDependencies(content);
if (declared.parseError) parseErrors.push({ stack, error: declared.parseError });
declaredByStack.set(stack, declared);
} catch (error) {
parseErrors.push({ stack, error: (error as Error)?.message ?? 'Failed to read compose file' });
declaredByStack.set(stack, { services: [], networks: {}, volumes: {} });
}
}
return assembleGraph({ nodeId, nodeName, stacks, snapshot, declaredByStack, parseErrors });
}
interface AssembleInput {
nodeId: number;
nodeName: string;
stacks: string[];
snapshot: DependencySnapshot;
declaredByStack: Map<string, DeclaredCompose>;
parseErrors: { stack: string; error: string }[];
}
/** Pure assembly step (no Docker / FS access) so it is directly unit-testable. */
export function assembleGraph(input: AssembleInput): LocalDependencyGraph {
const { nodeId, nodeName, stacks, snapshot } = input;
const knownStacks = new Set(stacks);
const nodes: DepNode[] = [];
const edges: DepEdge[] = [];
const flags: DepFlag[] = [];
const nodeById = new Map<string, DepNode>();
const addNode = (n: DepNode): DepNode => {
const existing = nodeById.get(n.id);
if (existing) return existing;
nodeById.set(n.id, n);
nodes.push(n);
return n;
};
const addEdge = (e: DepEdge): void => {
if (!edges.some((x) => x.id === e.id)) edges.push(e);
};
const addFlagToNode = (id: string, kind: DepFlagKind): void => {
const n = nodeById.get(id);
if (n && !n.flags.includes(kind)) n.flags.push(kind);
};
// Host root.
addNode({ id: hostId(), kind: 'host', label: nodeName, nodeId, nodeName, stack: null, flags: [] });
// Index snapshot.
const networksById = new Map<string, DependencyNetwork>();
const networksByName = new Map<string, DependencyNetwork>();
for (const net of snapshot.networks) {
networksById.set(net.id, net);
networksByName.set(net.name, net);
}
const volumesByName = new Map<string, DependencyVolume>();
for (const vol of snapshot.volumes) volumesByName.set(vol.name, vol);
const knownContainers = snapshot.containers.filter((c) => c.stack && knownStacks.has(c.stack));
const orphanContainers = snapshot.containers.filter((c) => c.composeProject && !c.stack);
// Runtime reference maps for cross-stack and orphan detection.
const networkStacks = new Map<string, Set<string>>();
const volumeStacks = new Map<string, Set<string>>();
const referencedNetworkIds = new Set<string>();
const referencedNetworkNames = new Set<string>();
const referencedVolumeNames = new Set<string>();
const runtimeServicesByStack = new Map<string, Set<string>>();
const runningServicesByStack = new Map<string, Set<string>>();
const portClaims: PortClaim[] = [];
const trackRef = (map: Map<string, Set<string>>, key: string, stack: string): void => {
const set = map.get(key) ?? new Set<string>();
set.add(stack);
map.set(key, set);
};
for (const c of snapshot.containers) {
for (const n of c.networks) {
referencedNetworkIds.add(n.id);
referencedNetworkNames.add(n.name);
}
for (const v of c.volumes) referencedVolumeNames.add(v);
}
// Stack and service nodes (union of runtime + declared services).
for (const stack of stacks) {
const declared = input.declaredByStack.get(stack) ?? { services: [], networks: {}, volumes: {} };
const containers = knownContainers.filter((c) => c.stack === stack);
const runtimeServices = new Set<string>();
const runningServices = new Set<string>();
runtimeServicesByStack.set(stack, runtimeServices);
runningServicesByStack.set(stack, runningServices);
const hasContainers = containers.length > 0;
addNode({ id: stackId(stack), kind: 'stack', label: stack, nodeId, nodeName, stack, managedStatus: 'managed', state: hasContainers ? 'deployed' : 'not deployed', flags: [] });
addEdge({ id: `e:host-${stackId(stack)}`, source: hostId(), target: stackId(stack), kind: 'stack-node' });
// Service nodes from runtime containers.
for (const c of containers) {
const svcName = c.service ?? c.name;
runtimeServices.add(svcName);
if (RUNNING_STATES.has(c.state)) runningServices.add(svcName);
const sid = serviceId(stack, svcName);
addNode({ id: sid, kind: 'service', label: svcName, nodeId, nodeName, stack, managedStatus: 'managed', state: c.state, flags: [] });
addEdge({ id: `e:${stackId(stack)}-${sid}`, source: stackId(stack), target: sid, kind: 'stack-service' });
buildResourceEdges(c, stack, sid, { addNode, addEdge, networksById, networksByName, volumesByName, nodeId, nodeName, networkStacks, volumeStacks, trackRef, portClaims });
}
// Declared-only service nodes (in compose, no running container).
for (const svc of declared.services) {
if (runtimeServices.has(svc.name)) continue;
const sid = serviceId(stack, svc.name);
addNode({ id: sid, kind: 'service', label: svc.name, nodeId, nodeName, stack, managedStatus: 'managed', state: 'absent', flags: [] });
addEdge({ id: `e:${stackId(stack)}-${sid}`, source: stackId(stack), target: sid, kind: 'stack-service' });
// Declared ports still count as conflict claimants.
for (const p of svc.ports) portClaims.push({ stack, service: svc.name, hostIp: p.hostIp, publishedPort: p.publishedPort, protocol: p.protocol });
}
// depends-on edges (declared).
for (const svc of declared.services) {
for (const dep of svc.dependsOn) {
const from = serviceId(stack, svc.name);
const to = serviceId(stack, dep);
if (nodeById.has(from) && nodeById.has(to)) {
addEdge({ id: `e:dep:${from}->${to}`, source: from, target: to, kind: 'depends-on', declaredOnly: true });
}
}
}
}
// --- Flags ---
// Missing dependencies.
const allNetworkNames = new Set(snapshot.networks.map((n) => n.name));
const allVolumeNames = new Set(snapshot.volumes.map((v) => v.name));
for (const stack of stacks) {
const declared = input.declaredByStack.get(stack) ?? { services: [], networks: {}, volumes: {} };
const missing = detectMissingDependencies({
stack,
declared,
runningServices: runningServicesByStack.get(stack) ?? new Set(),
hasContainers: (runtimeServicesByStack.get(stack)?.size ?? 0) > 0,
stackNetworkNames: snapshot.networks.filter((n) => n.stack === stack).map((n) => n.name),
stackVolumeNames: snapshot.volumes.filter((v) => v.stack === stack).map((v) => v.name),
allNetworkNames,
allVolumeNames,
});
for (const m of missing) {
const subject = serviceId(stack, m.service);
addFlagToNode(subject, 'missing-dependency');
flags.push({ kind: 'missing-dependency', nodeId, nodeName, subjects: [subject], detail: m.detail });
}
}
// Port conflicts. Flag only the port node(s) whose scope actually clashes,
// by rebuilding the exact scope-prefixed port id from the conflict scopes.
for (const conflict of detectPortConflicts(portClaims)) {
const portNodeIds = conflict.scopes
.map((scope) => `port:${scope}:${conflict.port}/${conflict.protocol}`)
.filter((id) => nodeById.has(id));
const serviceSubjects = conflict.claimants.map((c) => serviceId(c.stack, c.service)).filter((id) => nodeById.has(id));
const stacksInvolved = [...new Set(conflict.claimants.map((c) => c.stack))];
for (const id of portNodeIds) addFlagToNode(id, 'port-conflict');
for (const s of serviceSubjects) addFlagToNode(s, 'port-conflict');
flags.push({ kind: 'port-conflict', nodeId, nodeName, subjects: [...portNodeIds, ...serviceSubjects], detail: `Port ${conflict.port}/${conflict.protocol} is claimed by ${stacksInvolved.join(', ')}.` });
}
// Cross-stack shared networks/volumes.
for (const [name, stackSet] of networkStacks) {
if (stackSet.size >= 2) {
addFlagToNode(networkId(name), 'cross-stack-shared');
flags.push({ kind: 'cross-stack-shared', nodeId, nodeName, subjects: [networkId(name)], detail: `Network "${name}" is shared by ${[...stackSet].join(', ')}.` });
}
}
for (const [name, stackSet] of volumeStacks) {
if (stackSet.size >= 2) {
addFlagToNode(volumeId(name), 'cross-stack-shared');
flags.push({ kind: 'cross-stack-shared', nodeId, nodeName, subjects: [volumeId(name)], detail: `Volume "${name}" is shared by ${[...stackSet].join(', ')}.` });
}
}
// Orphaned networks/volumes (unmanaged, unreferenced, non-system).
for (const net of snapshot.networks) {
if (net.isSystem || net.stack) continue;
if (referencedNetworkIds.has(net.id) || referencedNetworkNames.has(net.name)) continue;
const id = networkId(net.name);
addNode({ id, kind: 'network', label: net.name, nodeId, nodeName, stack: null, managedStatus: 'unmanaged', flags: ['orphan'] });
flags.push({ kind: 'orphan', nodeId, nodeName, subjects: [id], detail: `Network "${net.name}" is not used by any container.` });
}
for (const vol of snapshot.volumes) {
if (vol.stack) continue;
if (referencedVolumeNames.has(vol.name)) continue;
const id = volumeId(vol.name);
addNode({ id, kind: 'volume', label: vol.name, nodeId, nodeName, stack: null, managedStatus: 'unmanaged', flags: ['orphan'] });
flags.push({ kind: 'orphan', nodeId, nodeName, subjects: [id], detail: `Volume "${vol.name}" is not used by any container.` });
}
// Orphan containers (compose project not owned by any known stack).
const orphanByProject = new Map<string, DependencyContainer[]>();
for (const c of orphanContainers) {
const project = c.composeProject as string;
const list = orphanByProject.get(project) ?? [];
list.push(c);
orphanByProject.set(project, list);
}
for (const [project, containers] of orphanByProject) {
const sId = `stack:__orphan__:${project}`;
addNode({ id: sId, kind: 'stack', label: `${project} (unmanaged)`, nodeId, nodeName, stack: null, managedStatus: 'unmanaged', flags: ['orphan'] });
addEdge({ id: `e:host-${sId}`, source: hostId(), target: sId, kind: 'stack-node' });
flags.push({ kind: 'orphan', nodeId, nodeName, subjects: [sId], detail: `Stack "${project}" is running but not managed by Sencho.` });
for (const c of containers) {
const svcName = c.service ?? c.name;
const svcNodeId = `svc:__orphan__:${project}:${svcName}`;
addNode({ id: svcNodeId, kind: 'service', label: svcName, nodeId, nodeName, stack: null, managedStatus: 'unmanaged', state: c.state, flags: ['orphan'] });
addEdge({ id: `e:${sId}-${svcNodeId}`, source: sId, target: svcNodeId, kind: 'stack-service' });
}
}
return { nodeId, nodeName, nodes, edges, flags, parseErrors: input.parseErrors };
}
interface ResourceEdgeCtx {
addNode: (n: DepNode) => DepNode;
addEdge: (e: DepEdge) => void;
networksById: Map<string, DependencyNetwork>;
networksByName: Map<string, DependencyNetwork>;
volumesByName: Map<string, DependencyVolume>;
nodeId: number;
nodeName: string;
networkStacks: Map<string, Set<string>>;
volumeStacks: Map<string, Set<string>>;
trackRef: (map: Map<string, Set<string>>, key: string, stack: string) => void;
portClaims: PortClaim[];
}
/** Emits a running container's network / volume / port nodes and edges. */
function buildResourceEdges(c: DependencyContainer, stack: string, sid: string, ctx: ResourceEdgeCtx): void {
for (const n of c.networks) {
const meta = ctx.networksById.get(n.id) ?? ctx.networksByName.get(n.name);
if (meta?.isSystem) continue; // skip bridge/host/none noise
const id = networkId(n.name);
ctx.addNode({ id, kind: 'network', label: n.name, nodeId: ctx.nodeId, nodeName: ctx.nodeName, stack: meta?.stack ?? null, managedStatus: meta?.stack ? 'managed' : 'unmanaged', flags: [] });
ctx.addEdge({ id: `e:${sid}-${id}`, source: sid, target: id, kind: 'service-network' });
ctx.trackRef(ctx.networkStacks, n.name, stack);
}
for (const volName of c.volumes) {
const meta = ctx.volumesByName.get(volName);
const id = volumeId(volName);
ctx.addNode({ id, kind: 'volume', label: volName, nodeId: ctx.nodeId, nodeName: ctx.nodeName, stack: meta?.stack ?? null, managedStatus: meta?.stack ? 'managed' : 'unmanaged', flags: [] });
ctx.addEdge({ id: `e:${sid}-${id}`, source: sid, target: id, kind: 'service-volume' });
ctx.trackRef(ctx.volumeStacks, volName, stack);
}
const svcName = c.service ?? c.name;
for (const p of c.ports) {
const id = portId(p.ip, p.publishedPort, p.protocol);
ctx.addNode({ id, kind: 'port', label: `${portScope(p.ip)}:${p.publishedPort}/${p.protocol}`, nodeId: ctx.nodeId, nodeName: ctx.nodeName, stack, flags: [] });
ctx.addEdge({ id: `e:${sid}-${id}`, source: sid, target: id, kind: 'service-port' });
ctx.portClaims.push({ stack, service: svcName, hostIp: p.ip, publishedPort: p.publishedPort, protocol: p.protocol });
}
}
// --- Fleet merge -------------------------------------------------------------
const isObject = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object';
/**
* Validates a parsed remote node-graph payload deeply enough that mergeFleetGraph
* cannot throw on it: nodes/edges/flags are arrays of the right shallow shape
* (each flag carries a subjects array, each node a flags array), and parseErrors
* is an array when present. A reachable-but-malformed remote thus degrades to a
* single node error instead of crashing the whole fleet map.
*/
export function isLocalDependencyGraph(g: unknown): g is LocalDependencyGraph {
if (!isObject(g)) return false;
if (!Array.isArray(g.nodes) || !Array.isArray(g.edges) || !Array.isArray(g.flags)) return false;
if (g.parseErrors !== undefined && !Array.isArray(g.parseErrors)) return false;
if (!g.nodes.every((n) => isObject(n) && typeof n.id === 'string' && Array.isArray(n.flags))) return false;
if (!g.edges.every((e) => isObject(e) && typeof e.id === 'string' && typeof e.source === 'string' && typeof e.target === 'string')) return false;
// subjects elements must be strings (merge concatenates them) and parseErrors
// elements must be {stack,error} objects (merge dereferences them), or a
// payload could pass the array check yet still throw / corrupt ids in merge.
if (!g.flags.every((f) => isObject(f) && Array.isArray(f.subjects) && f.subjects.every((s) => typeof s === 'string'))) return false;
if (Array.isArray(g.parseErrors) && !g.parseErrors.every((pe) => isObject(pe) && typeof pe.stack === 'string' && typeof pe.error === 'string')) return false;
return true;
}
/**
* Merges each node's local graph into one fleet map. Every id is namespaced by
* the node id so identically named stacks/resources on different nodes stay
* distinct, and node attribution is re-stamped from the hub's node records (so
* a remote's view of its own name does not leak through).
*/
export function mergeFleetGraph(results: FleetNodeGraphResult[]): FleetDependencyMap {
const nodes: DepNode[] = [];
const edges: DepEdge[] = [];
const flags: DepFlag[] = [];
const nodeErrors: { nodeId: number; nodeName: string; error: string }[] = [];
const parseErrors: { nodeId: number; nodeName: string; stack: string; error: string }[] = [];
for (const result of results) {
if (result.status !== 'ok') {
nodeErrors.push({ nodeId: result.nodeId, nodeName: result.nodeName, error: result.error });
continue;
}
const prefix = `n${result.nodeId}:`;
const g = result.graph;
for (const n of g.nodes) {
// The host node's label is the node's name; take it from the hub's
// node records, never from the remote's self-reported name.
const label = n.kind === 'host' ? result.nodeName : n.label;
nodes.push({ ...n, id: prefix + n.id, label, nodeId: result.nodeId, nodeName: result.nodeName });
}
for (const e of g.edges) {
edges.push({ ...e, id: prefix + e.id, source: prefix + e.source, target: prefix + e.target });
}
for (const f of g.flags) {
flags.push({ ...f, nodeId: result.nodeId, nodeName: result.nodeName, subjects: f.subjects.map((s) => prefix + s) });
}
for (const pe of g.parseErrors ?? []) {
parseErrors.push({ nodeId: result.nodeId, nodeName: result.nodeName, stack: pe.stack, error: pe.error });
}
}
return { nodes, edges, flags, nodeErrors, parseErrors };
}
+144
View File
@@ -91,6 +91,61 @@ export interface TopologyNetwork {
containers: TopologyContainer[];
}
/** A host-published port binding on a container. */
export interface DependencyPort {
/** Host interface the port binds to ('' or '0.0.0.0'/'::' means all interfaces). */
ip: string;
publishedPort: number;
privatePort: number | null;
protocol: string;
}
/** A container as seen by the dependency map: compose identity plus its real
* runtime network/volume names and published ports. */
export interface DependencyContainer {
id: string;
name: string;
/** com.docker.compose.service label, or null for non-compose containers. */
service: string | null;
/** Raw com.docker.compose.project label (may not map to a known stack). */
composeProject: string | null;
/** Resolved Sencho stack, or null when the container is not Sencho-managed. */
stack: string | null;
state: string;
image: string;
networks: { name: string; id: string; ip: string }[];
/** Named-volume sources mounted by the container (bind mounts excluded). */
volumes: string[];
ports: DependencyPort[];
}
export interface DependencyNetwork {
id: string;
name: string;
driver: string;
scope: string;
isSystem: boolean;
/** Raw com.docker.compose.project label (may not map to a known stack). */
composeProject: string | null;
/** Resolved Sencho stack this network belongs to, or null. */
stack: string | null;
}
export interface DependencyVolume {
name: string;
driver: string;
composeProject: string | null;
/** Resolved Sencho stack this volume belongs to, or null. */
stack: string | null;
}
/** One node-scoped snapshot of everything the dependency map needs. */
export interface DependencySnapshot {
containers: DependencyContainer[];
networks: DependencyNetwork[];
volumes: DependencyVolume[];
}
export type NetworkDriver = 'bridge' | 'overlay' | 'macvlan' | 'host' | 'none';
export interface CreateNetworkOptions {
@@ -846,6 +901,95 @@ class DockerController {
return result;
}
/**
* One-shot, node-scoped snapshot for the dependency map: every container's
* compose service identity, real network/volume names, and protocol/IP-typed
* published ports, plus the full network and volume inventory. Three Docker
* list calls and no per-container inspect keep it cheap at fleet scale.
*/
public async getDependencySnapshot(knownStackNames: string[]): Promise<DependencySnapshot> {
const knownSet = new Set(knownStackNames);
const [rawContainers, rawNetworks, rawVolumeData, projectToStack] = await Promise.all([
this.docker.listContainers({ all: true }),
this.docker.listNetworks(),
this.docker.listVolumes(),
DockerController.resolveProjectNameMap(knownStackNames),
]);
const absDirToStack = DockerController.buildAbsDirMap(knownStackNames);
const resolvedBase = path.resolve(COMPOSE_DIR);
const containersRaw = this.validateApiData<any[]>(rawContainers);
const networksRaw = this.validateApiData<any[]>(rawNetworks);
const volumesRaw: any[] = (this.validateApiData<any>(rawVolumeData)).Volumes || [];
const containers: DependencyContainer[] = containersRaw.map((c: any) => {
const netSettings: Record<string, { NetworkID?: string; IPAddress?: string }> =
c.NetworkSettings?.Networks ?? {};
const networks = Object.entries(netSettings).map(([name, info]) => ({
name,
id: info.NetworkID ?? '',
ip: info.IPAddress ?? '',
}));
const volumes: string[] = Array.isArray(c.Mounts)
? c.Mounts
.filter((m: any) => m?.Type === 'volume' && typeof m.Name === 'string' && m.Name)
.map((m: any) => m.Name as string)
: [];
const ports: DependencyPort[] = Array.isArray(c.Ports)
? c.Ports
.filter((p: any) => typeof p.PublicPort === 'number' && p.PublicPort > 0)
.map((p: any) => ({
ip: typeof p.IP === 'string' ? p.IP : '',
publishedPort: p.PublicPort as number,
privatePort: typeof p.PrivatePort === 'number' ? p.PrivatePort : null,
protocol: typeof p.Type === 'string' ? p.Type : 'tcp',
}))
: [];
return {
id: c.Id,
name: (c.Names?.[0] ?? '').replace(/^\//, '') || (c.Id ?? '').substring(0, 12),
service: c.Labels?.['com.docker.compose.service'] ?? null,
composeProject: c.Labels?.['com.docker.compose.project'] ?? null,
stack: DockerController.resolveContainerStack(c.Labels, projectToStack, knownSet, absDirToStack, resolvedBase),
state: c.State ?? 'unknown',
image: c.Image ?? '',
networks,
volumes,
ports,
};
});
const networks: DependencyNetwork[] = networksRaw.map((net: any) => {
const project = net.Labels?.['com.docker.compose.project'] ?? null;
return {
id: net.Id,
name: net.Name,
driver: net.Driver ?? 'bridge',
scope: net.Scope ?? 'local',
isSystem: DockerController.SYSTEM_NETWORKS.has(net.Name),
composeProject: project,
stack: DockerController.resolveProjectLabel(project ?? undefined, knownSet, projectToStack),
};
});
const volumes: DependencyVolume[] = volumesRaw.map((vol: any) => {
const project = vol.Labels?.['com.docker.compose.project'] ?? null;
return {
name: vol.Name,
driver: vol.Driver ?? 'local',
composeProject: project,
stack: DockerController.resolveProjectLabel(project ?? undefined, knownSet, projectToStack),
};
});
return { containers, networks, volumes };
}
/** Resolves a Docker Compose project label to a known Sencho stack name, or null. */
private static resolveProjectLabel(
project: string | undefined,