diff --git a/backend/src/__tests__/compose-network-inspector.test.ts b/backend/src/__tests__/compose-network-inspector.test.ts index aa5a37b6..5cf33345 100644 --- a/backend/src/__tests__/compose-network-inspector.test.ts +++ b/backend/src/__tests__/compose-network-inspector.test.ts @@ -41,6 +41,9 @@ describe('normalized-model adapters', () => { 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: {}, }; @@ -50,6 +53,7 @@ describe('normalized-model adapters', () => { backend: { external: false }, shared: { external: true, name: 'shared_net' }, custom: { external: false, name: 'custom_name' }, + extnet: { external: true }, }, volumes: {}, }; @@ -126,6 +130,16 @@ describe('runtimeResourceName', () => { expect(runtimeResourceName('myapp', 'backend', 'backend')).toBe('myapp_backend'); // name == key is not an override expect(runtimeResourceName('myapp', 'shared', 'shared_net')).toBe('shared_net'); }); + + 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', () => { @@ -207,6 +221,24 @@ describe('compareStackNetworks', () => { 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: [ diff --git a/backend/src/__tests__/drift-detection.test.ts b/backend/src/__tests__/drift-detection.test.ts index e669ff85..69f42ad2 100644 --- a/backend/src/__tests__/drift-detection.test.ts +++ b/backend/src/__tests__/drift-detection.test.ts @@ -349,6 +349,21 @@ describe('assembleStackDrift - network drift', () => { }); expect(report.findings.filter(f => f.kind.startsWith('network-'))).toEqual([]); }); + + it('does not flag an attachment to a declared external network (regression: shared arr-net)', () => { + // arr-net is declared `external: true` with no name override, so Docker attaches + // the container to the pre-existing network named "arr-net" (no project prefix). + // The runtime matches the compose, so this must read as in-sync rather than a + // foreign/undeclared-network finding. + const report = assembleStackDrift({ + stack: 'plex', + declared: { services: [service({ name: 'plex', networks: ['arr-net'] })], networks: { 'arr-net': { external: true } }, volumes: {} }, + containers: [container({ id: 'plex', service: 'plex', stack: 'plex', networks: [{ name: 'arr-net', id: 'a', ip: '' }] })], + networks: [depNet('arr-net', { composeProject: null, stack: null })], + }); + expect(report.findings.filter(f => f.kind.startsWith('network-'))).toEqual([]); + expect(report.status).toBe('in-sync'); + }); }); // ── normalizeImageRef ───────────────────────────────────────────────────── diff --git a/backend/src/services/network/normalize.ts b/backend/src/services/network/normalize.ts index ba4666d1..6e2d74c9 100644 --- a/backend/src/services/network/normalize.ts +++ b/backend/src/services/network/normalize.ts @@ -24,9 +24,13 @@ export function isLoopback(ip: string): boolean { } /** Resolved runtime name of a top-level network/volume: a `name:` override wins, - * otherwise compose prefixes the project (`_`). */ -export function runtimeResourceName(projectName: string, key: string, declaredName: string | undefined): string { - return declaredName && declaredName !== key ? declaredName : `${projectName}_${key}`; + * otherwise compose prefixes the project (`_`). An external + * resource is never project-prefixed: it references a pre-existing network/volume + * by its real name (the key, or a `name:` override), so prefixing it would invent + * a `_` that no runtime resource matches and read as foreign drift. */ +export function runtimeResourceName(projectName: string, key: string, declaredName: string | undefined, external = false): string { + if (declaredName && declaredName !== key) return declaredName; + return external ? key : `${projectName}_${key}`; } /** Extract host port numbers referenced by free-text access URLs, for the @@ -67,7 +71,7 @@ export function fromEffectiveModel(m: EffectiveModel): NormalizedNetworkModel { export function fromDeclaredCompose(m: DeclaredCompose, projectName: string): NormalizedNetworkModel { const networks: NormalizedNetworkModel['networks'] = {}; for (const [key, res] of Object.entries(m.networks)) { - networks[key] = { runtimeName: runtimeResourceName(projectName, key, res.name), external: res.external }; + networks[key] = { runtimeName: runtimeResourceName(projectName, key, res.name, res.external), external: res.external }; } return { projectName,