Files
sencho/backend/src/services/network/normalize.ts
T

148 lines
6.9 KiB
TypeScript

/**
* 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 (`<project>_<key>`). 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 `<project>_<key>` 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<number> {
const ports = new Set<number>();
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<string, { runtimeName: string; external: boolean }>;
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<string>();
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<string>();
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 };
}