/** * The Compose Network Inspector: the normalized-model adapters (rendered vs raw * declared produce the same shape), the pure facts assembler, and the * runtime-vs-Compose drift comparison (system/default/external networks and * stopped containers are not flagged). */ import { describe, it, expect } from 'vitest'; import type { EffectiveModel, EffService } from '../services/preflight/effectiveModel'; import type { DeclaredCompose } from '../helpers/composeDependencyParse'; import type { DependencySnapshot, DependencyContainer, DependencyNetwork } from '../services/DockerController'; import { fromEffectiveModel, fromDeclaredCompose, compareStackNetworks, runtimeResourceName, parseAccessUrlPorts, } from '../services/network/normalize'; import { assembleStackNetworkFacts } from '../services/network/composeNetworkInspector'; function effSvc(over: Partial = {}): EffService { return { name: 'web', image: 'nginx:1.27', ports: [], binds: [], namedVolumes: [], storageMounts: [], privileged: false, hasHealthcheck: true, restart: 'unless-stopped', envKeys: [], networks: [], extraHosts: [], labelKeys: [], ...over, }; } function container(over: Partial = {}): DependencyContainer { return { id: 'c1', name: 'web1', service: 'web', composeProject: 'myapp', stack: 'myapp', state: 'running', image: 'nginx:1.27', networks: [], volumes: [], ports: [], ...over, }; } function depNet(over: Partial = {}): DependencyNetwork { return { id: 'n', name: 'myapp_backend', driver: 'bridge', scope: 'local', isSystem: false, composeProject: 'myapp', stack: 'myapp', ...over }; } describe('normalized-model adapters', () => { it('rendered and raw declared models normalize to the same shape', () => { const eff: EffectiveModel = { projectName: 'myapp', services: [effSvc({ name: 'web', networks: [{ key: 'backend', aliases: [] }, { key: 'shared', aliases: [] }] })], networks: { backend: { name: 'myapp_backend', external: false, internal: false }, shared: { name: 'shared_net', external: true, internal: false }, custom: { name: 'custom_name', external: false, internal: false }, // External with no name override: the rendered model resolves the runtime // name to the key verbatim, so the raw adapter must agree (not myapp_extnet). extnet: { name: 'extnet', external: true, internal: false }, }, volumes: {}, }; const declared: DeclaredCompose = { services: [{ name: 'web', dependsOn: [], networks: ['backend', 'shared'], volumes: [], ports: [] }], networks: { backend: { external: false }, shared: { external: true, name: 'shared_net' }, custom: { external: false, name: 'custom_name' }, extnet: { external: true }, }, volumes: {}, }; expect(fromDeclaredCompose(declared, 'myapp')).toEqual(fromEffectiveModel(eff)); }); }); describe('assembleStackNetworkFacts', () => { const model: EffectiveModel = { projectName: 'myapp', services: [effSvc({ name: 'web', networks: [{ key: 'backend', aliases: ['www'] }], extraHosts: ['host.docker.internal:host-gateway'], ports: [ { startPort: 8080, endPort: 8080, hostIp: '0.0.0.0', protocol: 'tcp' }, { startPort: 9000, endPort: 9000, hostIp: '127.0.0.1', protocol: 'tcp' }, ], })], networks: { default: { name: 'myapp_default', external: false, internal: false }, backend: { name: 'myapp_backend', external: false, internal: true }, shared: { name: 'shared_net', external: true, internal: false }, }, volumes: {}, }; it('reports networks with external/internal/createdByStack flags', () => { const facts = assembleStackNetworkFacts('myapp', model, null, null); expect(facts.renderable).toBe(true); expect(facts.networks).toEqual([ { key: 'default', name: 'myapp_default', external: false, internal: false, createdByStack: false }, { key: 'backend', name: 'myapp_backend', external: false, internal: true, createdByStack: true }, { key: 'shared', name: 'shared_net', external: true, internal: false, createdByStack: false }, ]); }); it('reports service membership, aliases, extra_hosts, and port binding flags', () => { const svc = assembleStackNetworkFacts('myapp', model, null, null).services[0]; expect(svc.networks).toEqual([{ key: 'backend', aliases: ['www'] }]); expect(svc.extraHosts).toEqual(['host.docker.internal:host-gateway']); expect(svc.publishedPorts[0]).toMatchObject({ startPort: 8080, allInterfaces: true, loopbackOnly: false }); expect(svc.publishedPorts[1]).toMatchObject({ startPort: 9000, allInterfaces: false, loopbackOnly: true }); }); it('marks the runtime unavailable and leaves drift empty when there is no snapshot', () => { const facts = assembleStackNetworkFacts('myapp', model, null, null); expect(facts.runtime).toBe('unavailable'); expect(facts.drift.runtimeOnlyAttachments).toEqual([]); }); it('returns a non-renderable facts payload when the model is null', () => { const facts = assembleStackNetworkFacts('myapp', null, 'render failed', null); expect(facts.renderable).toBe(false); expect(facts.renderError).toBe('render failed'); expect(facts.networks).toEqual([]); }); it('computes real drift through to the payload when a snapshot is present', () => { const snapshot: DependencySnapshot = { containers: [container({ networks: [{ name: 'myapp_backend', id: 'a', ip: '' }, { name: 'myapp_rogue', id: 'b', ip: '' }] })], networks: [depNet({ name: 'myapp_backend' }), depNet({ name: 'myapp_rogue' })], volumes: [], }; const facts = assembleStackNetworkFacts('myapp', model, null, snapshot); expect(facts.runtime).toBe('available'); expect(facts.drift.runtimeOnlyAttachments).toEqual([{ container: 'web1', service: 'web', network: 'myapp_rogue' }]); }); }); describe('runtimeResourceName', () => { it('uses a name override, else the project prefix', () => { expect(runtimeResourceName('myapp', 'backend', undefined)).toBe('myapp_backend'); expect(runtimeResourceName('myapp', 'backend', 'myapp_backend')).toBe('myapp_backend'); expect(runtimeResourceName('myapp', 'backend', 'backend')).toBe('backend'); expect(runtimeResourceName('myapp', 'shared', 'shared_net')).toBe('shared_net'); }); it('resolves explicit name equal to the compose key (regression: #1581)', () => { expect(runtimeResourceName('network', 'tailscale', 'tailscale')).toBe('tailscale'); expect(runtimeResourceName('network', 'proxy', 'proxy')).toBe('proxy'); }); it('never project-prefixes an external resource (runtime name is the key, or a name override)', () => { // Compose references an external network/volume by its real name and never // prefixes the project, so an external resource with no name override keeps // its key verbatim. Prefixing it invents a phantom `_` that no // runtime resource matches, which then reads as foreign-network drift. expect(runtimeResourceName('myapp', 'arr-net', undefined, true)).toBe('arr-net'); expect(runtimeResourceName('myapp', 'arr-net', 'arr-net', true)).toBe('arr-net'); expect(runtimeResourceName('myapp', 'shared', 'shared-prod', true)).toBe('shared-prod'); }); }); describe('parseAccessUrlPorts', () => { it('extracts host ports from access-URL text', () => { expect([...parseAccessUrlPorts('http://host:8080/path and https://host:443')].sort((a, b) => a - b)).toEqual([443, 8080]); }); it('finds no port when the URL has none (implicit scheme port)', () => { expect([...parseAccessUrlPorts('https://app.example.com/dashboard')]).toEqual([]); }); it('rejects out-of-range numbers and returns an empty set for empty input', () => { expect([...parseAccessUrlPorts('http://host:99999')]).toEqual([]); expect([...parseAccessUrlPorts('')]).toEqual([]); }); }); describe('compareStackNetworks', () => { const declared = fromEffectiveModel({ projectName: 'myapp', services: [], networks: { backend: { name: 'myapp_backend', external: false, internal: false }, shared: { name: 'shared_net', external: true, internal: false }, }, volumes: {}, }); function snapshot(containers: DependencyContainer[], networks: DependencyNetwork[]): DependencySnapshot { return { containers, networks, volumes: [] }; } it('flags a runtime-only attachment to a stack-owned undeclared network', () => { const snap = snapshot( [container({ networks: [{ name: 'myapp_backend', id: 'a', ip: '' }, { name: 'myapp_extra', id: 'b', ip: '' }] })], [depNet({ name: 'myapp_backend' }), depNet({ name: 'myapp_extra' }), depNet({ name: 'shared_net', stack: null })], ); const drift = compareStackNetworks(declared, snap, 'myapp'); expect(drift.runtimeOnlyAttachments).toEqual([{ container: 'web1', service: 'web', network: 'myapp_extra' }]); }); it('flags a foreign network owned by another stack', () => { const snap = snapshot( [container({ networks: [{ name: 'other_net', id: 'x', ip: '' }] })], [depNet({ name: 'other_net', stack: 'other', composeProject: 'other' })], ); const drift = compareStackNetworks(declared, snap, 'myapp'); expect(drift.foreignNetworkAttachments).toEqual([{ container: 'web1', network: 'other_net' }]); }); it('treats a stack-owned network with no project prefix as runtime-only (ownership via snapshot.stack)', () => { const snap = snapshot( [container({ networks: [{ name: 'odd-named-net', id: 'x', ip: '' }] })], [depNet({ name: 'odd-named-net', stack: 'myapp', composeProject: 'myapp' })], ); const drift = compareStackNetworks(declared, snap, 'myapp'); expect(drift.runtimeOnlyAttachments).toEqual([{ container: 'web1', service: 'web', network: 'odd-named-net' }]); expect(drift.foreignNetworkAttachments).toEqual([]); }); it('treats an attachment to a network absent from the snapshot as foreign', () => { const snap = snapshot( [container({ networks: [{ name: 'ghost-net', id: 'x', ip: '' }] })], [], // ghost-net is not in the snapshot network list ); const drift = compareStackNetworks(declared, snap, 'myapp'); expect(drift.foreignNetworkAttachments).toEqual([{ container: 'web1', network: 'ghost-net' }]); }); it('drives the declared-compose adapter through the comparison (name override resolves)', () => { const declaredFromCompose = fromDeclaredCompose({ services: [{ name: 'web', dependsOn: [], networks: ['edge'], volumes: [], ports: [] }], networks: { edge: { external: false, name: 'edge_override' } }, volumes: {}, }, 'myapp'); const snap = snapshot( [container({ networks: [{ name: 'myapp_default', id: 'd', ip: '' }] })], [depNet({ name: 'myapp_default' })], ); // edge_override is declared but missing from the runtime. expect(compareStackNetworks(declaredFromCompose, snap, 'myapp').missingFromRuntime).toEqual(['edge_override']); }); it('does not flag an external network declared without a name override through the raw adapter', () => { // The plex/arr-net case: an external network declared `external: true` with no // name override. Its real runtime name is the key (arr-net), unprefixed, so an // attachment to it reads as in-sync, not as a foreign network owned elsewhere. const declaredFromCompose = fromDeclaredCompose({ services: [{ name: 'plex', dependsOn: [], networks: ['arr-net'], volumes: [], ports: [] }], networks: { 'arr-net': { external: true } }, volumes: {}, }, 'plex'); const snap = snapshot( [container({ name: 'plex', service: 'plex', composeProject: 'plex', stack: 'plex', networks: [{ name: 'arr-net', id: 'a', ip: '' }] })], [depNet({ name: 'arr-net', composeProject: null, stack: null })], ); const drift = compareStackNetworks(declaredFromCompose, snap, 'plex'); expect(drift.foreignNetworkAttachments).toEqual([]); expect(drift.runtimeOnlyAttachments).toEqual([]); }); it('ignores system networks, the default network, and external networks', () => { const snap = snapshot( [container({ networks: [ { name: 'bridge', id: 's', ip: '' }, { name: 'myapp_default', id: 'd', ip: '' }, { name: 'shared_net', id: 'e', ip: '' }, ] })], [depNet({ name: 'bridge', isSystem: true, stack: null }), depNet({ name: 'myapp_default' }), depNet({ name: 'shared_net', stack: null })], ); const drift = compareStackNetworks(declared, snap, 'myapp'); expect(drift.runtimeOnlyAttachments).toEqual([]); expect(drift.foreignNetworkAttachments).toEqual([]); }); it('does not flag attachments from stopped containers', () => { const snap = snapshot( [container({ state: 'exited', networks: [{ name: 'myapp_extra', id: 'b', ip: '' }] })], [depNet({ name: 'myapp_extra' })], ); expect(compareStackNetworks(declared, snap, 'myapp').runtimeOnlyAttachments).toEqual([]); }); it('reports a declared network no running service uses', () => { const snap = snapshot( [container({ networks: [{ name: 'myapp_default', id: 'd', ip: '' }] })], [depNet({ name: 'myapp_backend' }), depNet({ name: 'myapp_default' })], ); expect(compareStackNetworks(declared, snap, 'myapp').declaredButUnused).toEqual(['backend']); }); it('reports a declared network missing from the runtime', () => { const snap = snapshot( [container({ networks: [{ name: 'myapp_default', id: 'd', ip: '' }] })], [depNet({ name: 'myapp_default' })], ); expect(compareStackNetworks(declared, snap, 'myapp').missingFromRuntime).toEqual(['myapp_backend']); }); });