mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-07 01:14:14 +00:00
35bb74425b
* feat: guide missing external network creation during deploy Detect missing external networks before Compose runs, prompt or auto-create safe bridge networks, and keep unsupported declarations blocked with trusted deploy provenance. * test: align deploy context and settings fixtures with missing-network gate Update caller spies, EffResource expectations, StacksSection save keys, and git-source spy cleanup so CI matches the new deployStack context and auto-create setting. * fix: drop unused renderError binding in missing-network resolver Satisfies no-unused-vars so backend ESLint CI passes; callers already key only on model presence. * fix: use HTTP-safe clipboard helper in missing-network dialog navigator.clipboard fails on plain HTTP LAN hosts; route copy actions through copyToClipboard so Docker and Compose copy buttons work on self-hosted instances. * fix: simplify missing-network dialog actions and copy label Drop the Compose snippet escape hatch, move secondary actions under More, and rename the terminal copy action to Copy create command so the footer is a clear Cancel / Create decision.
152 lines
5.9 KiB
TypeScript
152 lines
5.9 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 { 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,
|
|
): 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) : 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;
|
|
}
|
|
}
|
|
|
|
return assembleStackNetworkFacts(stackName, model, renderError, snapshot);
|
|
}
|