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

160 lines
6.3 KiB
TypeScript

/**
* Compose Network Inspector: renders the authored effective model and pairs it
* with the live Docker snapshot to produce the per-stack networking facts a
* Community user reads (network map, membership, published ports/bindings,
* network_mode, extra_hosts, and runtime drift). Advisory and read-only; it
* renders the AUTHORED model only (no Mesh overrides) and never returns or logs
* raw docker stderr, env values, or label values. One caveat: a secret
* interpolated into a structural field (a network name, published port, or
* `extra_hosts` entry built from a `${VAR}`) is resolved by `docker compose
* config` before this reads the model, so its value does appear; that value is
* already readable at the same `stack:read` scope via the stack's files
* (documented caveat, see docs/features/environment-guardrails).
*/
import DockerController, { type DependencySnapshot } from '../DockerController';
import { ComposeService } from '../ComposeService';
import { FileSystemService } from '../FileSystemService';
import { parseEffectiveModel, type EffectiveModel } from '../preflight/effectiveModel';
import { parseMissingRequiredVars } from '../../helpers/envVarParse';
import {
compareStackNetworks, fromEffectiveModel, isAllInterfaces, isLoopback,
} from './normalize';
import type {
NetworkDriftFacts, NetworkFactNetwork, NetworkFactService, NetworkRuntimeState, StackNetworkFacts,
} from './types';
import { classifyMissingExternalNetworks, type MissingExternalNetwork } from './missingExternalNetworks';
import { resolveManagedMeshAttachment } from './managedMeshAttachment';
import type { ManagedNetworkAttachmentPredicate } from './normalize';
import { getErrorMessage } from '../../utils/errors';
import { redactSensitiveText, sanitizeForLog } from '../../utils/safeLog';
const MAX_RENDER_ERROR = 600;
const EMPTY_DRIFT: NetworkDriftFacts = {
runtimeOnlyAttachments: [], declaredButUnused: [], missingFromRuntime: [], foreignNetworkAttachments: [],
};
/**
* Pure assembler: turns a rendered model plus an optional runtime snapshot into
* the facts payload. A null model means the render failed (renderError carries a
* redacted reason); a null snapshot means the runtime is unavailable, so drift
* is left empty rather than computed against an empty snapshot.
*/
export function assembleStackNetworkFacts(
stackName: string,
model: EffectiveModel | null,
renderError: string | null,
snapshot: DependencySnapshot | null,
managedNetworkAttachment?: ManagedNetworkAttachmentPredicate,
): StackNetworkFacts {
const runtime: NetworkRuntimeState = snapshot ? 'available' : 'unavailable';
if (!model) {
return {
stack: stackName,
renderable: false,
renderError,
runtime,
networks: [],
services: [],
drift: EMPTY_DRIFT,
missingExternalNetworks: [],
};
}
const networks: NetworkFactNetwork[] = Object.entries(model.networks).map(([key, res]) => ({
key,
name: res.name,
external: res.external,
internal: res.internal,
createdByStack: !res.external && key !== 'default',
}));
const services: NetworkFactService[] = model.services.map(s => ({
name: s.name,
networks: s.networks.map(n => ({ key: n.key, aliases: n.aliases })),
publishedPorts: s.ports.map(p => ({
hostIp: p.hostIp,
startPort: p.startPort,
endPort: p.endPort,
protocol: p.protocol,
allInterfaces: isAllInterfaces(p.hostIp),
loopbackOnly: isLoopback(p.hostIp),
})),
networkMode: s.networkMode,
extraHosts: s.extraHosts,
}));
const drift = snapshot
? compareStackNetworks(fromEffectiveModel(model), snapshot, stackName, managedNetworkAttachment)
: EMPTY_DRIFT;
const missingExternalNetworks: MissingExternalNetwork[] = snapshot
? classifyMissingExternalNetworks(
model,
new Set(snapshot.networks.map((n) => n.name)),
)
: [];
return {
stack: stackName,
renderable: true,
renderError: null,
runtime,
networks,
services,
drift,
missingExternalNetworks,
};
}
/** Render the effective model and assemble facts, optionally reusing a snapshot. */
export async function buildStackNetworkFacts(
nodeId: number,
stackName: string,
sharedSnapshot?: DependencySnapshot | null,
): Promise<StackNetworkFacts> {
const fsSvc = FileSystemService.getInstance(nodeId);
let model: EffectiveModel | null = null;
let renderError: string | null = null;
try {
const result = await ComposeService.getInstance(nodeId).renderConfig(stackName);
if (result.rendered !== null) {
try {
model = parseEffectiveModel(JSON.parse(result.rendered), stackName);
} catch (parseErr) {
console.warn('[NetworkInspector] Effective model parse failed for %s:',
sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(parseErr, 'unknown')));
renderError = 'Sencho could not parse the rendered Compose model.';
}
} else {
const missing = parseMissingRequiredVars(result.stderr);
renderError = missing.length
? `Required variable${missing.length > 1 ? 's' : ''} ${missing.join(', ')} ${missing.length > 1 ? 'have' : 'has'} no value, so the effective model cannot be rendered.`
: 'Sencho could not render the effective Compose model. Check the compose and env files for a YAML syntax error, an unresolved include or merge, or a required variable with no value.';
}
} catch (err) {
renderError = redactSensitiveText(getErrorMessage(err, 'docker compose could not be started.')).slice(0, MAX_RENDER_ERROR).trim()
|| 'Sencho could not run docker compose on this node.';
}
let snapshot: DependencySnapshot | null;
if (sharedSnapshot !== undefined) {
snapshot = sharedSnapshot;
} else {
try {
const knownStacks = await fsSvc.getStacks();
snapshot = await DockerController.getInstance(nodeId).getDependencySnapshot(knownStacks);
} catch (error) {
console.warn('[NetworkInspector] Node snapshot unavailable for %s; runtime facts skipped:',
sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(error, 'unknown')));
snapshot = null;
}
}
const managedNetworkAttachment = snapshot && model
? await resolveManagedMeshAttachment(nodeId, stackName)
: undefined;
return assembleStackNetworkFacts(stackName, model, renderError, snapshot, managedNetworkAttachment);
}