/** * Backend-local networking helpers and the normalized network model that lets * the Inspector (rendered EffectiveModel) and Drift (raw DeclaredCompose) feed * one comparison. The rendered model already resolves resource names; the raw * declared model does not, so each shape gets its own adapter and both emit the * same key-space NormalizedNetworkModel. Do not import the frontend's access-url * parser here; this is the backend copy. */ import type { EffectiveModel } from '../preflight/effectiveModel'; import type { DeclaredCompose } from '../../helpers/composeDependencyParse'; import type { DependencyContainer, DependencySnapshot } from '../DockerController'; import type { NetworkDriftFacts } from './types'; import { SENCHO_MESH_NETWORK } from '../MeshComposeOverride'; /** Container states that count as "deployed" for drift, matching DriftDetectionService. */ const RUNNING_STATES = new Set(['running', 'restarting']); const SYSTEM_NETWORK_NAMES = new Set(['bridge', 'host', 'none']); export function isAllInterfaces(ip: string): boolean { return ip === '' || ip === '0.0.0.0' || ip === '::' || ip === '[::]'; } export function isLoopback(ip: string): boolean { return ip.startsWith('127.') || ip === '::1' || ip === '[::1]'; } /** True for `network_mode: host`, which publishes every container port directly * on the host regardless of any declared `ports:` (so it is always exposed * beyond loopback). Other modes (none, bridge, service:, container:) are not. */ export function isHostNetwork(mode: string | undefined): boolean { return mode === 'host'; } /** Resolved runtime name of a top-level network/volume: a `name:` override wins, * 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 { const defaultName = external ? key : `${projectName}_${key}`; if (declaredName && declaredName !== defaultName) return declaredName; return defaultName; } /** Extract host port numbers referenced by free-text access URLs, for the * port-vs-documented finding. Heuristic: matches `:PORT` boundaries. */ export function parseAccessUrlPorts(text: string): Set { const ports = new Set(); const re = /:(\d{1,5})(?=[/\s)\];]|$)/g; let m: RegExpExecArray | null; while ((m = re.exec(text)) !== null) { const p = parseInt(m[1], 10); if (p > 0 && p <= 65535) ports.add(p); } return ports; } /** Key-space network model both adapters emit, so the comparison is shape-agnostic. */ export interface NormalizedNetworkModel { projectName: string; /** By network key → resolved runtime name + external flag. */ networks: Record; services: { name: string; networkKeys: string[]; networkMode?: string }[]; } export type ManagedNetworkAttachmentPredicate = ( container: DependencyContainer, networkName: string, ) => boolean; /** Rendered model: resource names are already resolved by `docker compose config`. */ export function fromEffectiveModel(m: EffectiveModel): NormalizedNetworkModel { const networks: NormalizedNetworkModel['networks'] = {}; for (const [key, res] of Object.entries(m.networks)) { networks[key] = { runtimeName: res.name, external: res.external }; } return { projectName: m.projectName, networks, services: m.services.map(s => ({ name: s.name, networkKeys: s.networks.map(n => n.key), networkMode: s.networkMode })), }; } /** Raw declared model: resolve runtime names here (project prefix / `name:` override). */ 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, res.external), external: res.external }; } return { projectName, networks, services: m.services.map(s => ({ name: s.name, networkKeys: s.networks })), }; } /** * Compare declared networks against the live snapshot. Only running/restarting * containers of this stack count; system networks (bridge/host/none), the * implicit default network, and external (shared) networks are not flagged. */ export function compareStackNetworks( declared: NormalizedNetworkModel, snapshot: DependencySnapshot, stackName: string, isManagedAttachment: ManagedNetworkAttachmentPredicate = () => false, ): NetworkDriftFacts { const runtimeOnlyAttachments: NetworkDriftFacts['runtimeOnlyAttachments'] = []; const foreignNetworkAttachments: NetworkDriftFacts['foreignNetworkAttachments'] = []; // Every declared network (external included) resolves into this set, so an // attachment to a declared external/shared network is treated as declared // below, not as foreign. const declaredRuntimeNames = new Set(); for (const net of Object.values(declared.networks)) declaredRuntimeNames.add(net.runtimeName); // The implicit default network compose always provisions counts as declared. declaredRuntimeNames.add(`${declared.projectName}_default`); const networkByName = new Map(snapshot.networks.map(n => [n.name, n])); const stackContainers = snapshot.containers.filter(c => c.stack === stackName && RUNNING_STATES.has(c.state)); const usedRuntimeNames = new Set(); for (const c of stackContainers) { for (const attached of c.networks) { const net = networkByName.get(attached.name); if (SYSTEM_NETWORK_NAMES.has(attached.name) || net?.isSystem) continue; if (declaredRuntimeNames.has(attached.name)) { usedRuntimeNames.add(attached.name); continue; } if (attached.name === SENCHO_MESH_NETWORK && isManagedAttachment(c, attached.name)) continue; if (net?.stack === stackName || attached.name.startsWith(`${declared.projectName}_`)) { runtimeOnlyAttachments.push({ container: c.name, service: c.service, network: attached.name }); } else { foreignNetworkAttachments.push({ container: c.name, network: attached.name }); } } } const runtimeNetworkNames = new Set(snapshot.networks.map(n => n.name)); const declaredButUnused: string[] = []; const missingFromRuntime: string[] = []; for (const [key, net] of Object.entries(declared.networks)) { if (net.external || key === 'default') continue; if (!runtimeNetworkNames.has(net.runtimeName)) { missingFromRuntime.push(net.runtimeName); continue; } if (!usedRuntimeNames.has(net.runtimeName)) declaredButUnused.push(key); } return { runtimeOnlyAttachments, declaredButUnused, missingFromRuntime, foreignNetworkAttachments }; }