mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 14:33:19 +00:00
160 lines
6.3 KiB
TypeScript
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);
|
|
}
|