feat: guide missing external network creation during deploy (#1645)

* 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.
This commit is contained in:
Anso
2026-07-17 14:51:00 -04:00
committed by GitHub
parent 66ec4ebdd2
commit 35bb74425b
61 changed files with 2291 additions and 141 deletions
@@ -22,6 +22,7 @@ import {
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';
@@ -46,7 +47,16 @@ export function assembleStackNetworkFacts(
const runtime: NetworkRuntimeState = snapshot ? 'available' : 'unavailable';
if (!model) {
return { stack: stackName, renderable: false, renderError, runtime, networks: [], services: [], drift: EMPTY_DRIFT };
return {
stack: stackName,
renderable: false,
renderError,
runtime,
networks: [],
services: [],
drift: EMPTY_DRIFT,
missingExternalNetworks: [],
};
}
const networks: NetworkFactNetwork[] = Object.entries(model.networks).map(([key, res]) => ({
@@ -73,8 +83,23 @@ export function assembleStackNetworkFacts(
}));
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 };
return {
stack: stackName,
renderable: true,
renderError: null,
runtime,
networks,
services,
drift,
missingExternalNetworks,
};
}
/** Render the effective model and assemble facts, optionally reusing a snapshot. */
@@ -0,0 +1,15 @@
/**
* Shared Docker network name predicate. Must stay aligned with
* DockerController.createNetwork validation.
*/
const DOCKER_NETWORK_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
export function isValidDockerNetworkName(name: string): boolean {
return DOCKER_NETWORK_NAME_RE.test(name);
}
export const RESERVED_SYSTEM_NETWORK_NAMES: ReadonlySet<string> = new Set([
'bridge',
'host',
'none',
]);
@@ -0,0 +1,159 @@
/**
* Pure classifier for missing Compose external networks.
* Async rendering/snapshot resolution lives at API/deploy boundaries only.
*/
import type { EffectiveModel, EffDriverKind, EffResource } from '../preflight/effectiveModel';
import {
isValidDockerNetworkName,
RESERVED_SYSTEM_NETWORK_NAMES,
} from './dockerNetworkName';
export type DriverKind = EffDriverKind;
export type UnsupportedFeature =
| 'driver_opts'
| 'custom_ipam'
| 'labels'
| 'internal'
| 'attachable'
| 'ipv4_disabled'
| 'ipv6_enabled';
export type BlockReason =
| 'invalid_name'
| 'reserved_system'
| 'unsupported_driver'
| 'unsupported_options';
export interface SafeCreationSpec {
driver: 'bridge';
options: 'default';
}
export interface KeyDeclaration {
key: string;
driverKind: DriverKind;
unsupportedFeatures: UnsupportedFeature[];
}
export interface MissingExternalNetwork {
name: string;
keys: string[];
declarations: KeyDeclaration[];
safe: boolean;
blockReason?: BlockReason;
unsupportedFeatures: UnsupportedFeature[];
creationSpec: SafeCreationSpec | null;
}
const UNSUPPORTED_FEATURE_ORDER: readonly UnsupportedFeature[] = [
'driver_opts',
'custom_ipam',
'labels',
'internal',
'attachable',
'ipv4_disabled',
'ipv6_enabled',
];
const SAFE_CREATION_SPEC: SafeCreationSpec = { driver: 'bridge', options: 'default' };
function stableSortFeatures(features: Iterable<UnsupportedFeature>): UnsupportedFeature[] {
const set = new Set(features);
return UNSUPPORTED_FEATURE_ORDER.filter((f) => set.has(f));
}
function featuresForResource(net: EffResource): UnsupportedFeature[] {
const features: UnsupportedFeature[] = [];
if (net.hasDriverOpts) features.push('driver_opts');
if (net.hasCustomIpam) features.push('custom_ipam');
if (net.hasLabels) features.push('labels');
if (net.internal) features.push('internal');
if (net.attachable) features.push('attachable');
if (net.ipv4Enabled === false) features.push('ipv4_disabled');
if (net.ipv6Enabled === true) features.push('ipv6_enabled');
return features;
}
function isSafeDriverKind(kind: DriverKind): boolean {
return kind === 'default' || kind === 'bridge';
}
function classifyKey(key: string, net: EffResource): KeyDeclaration {
return {
key,
driverKind: net.driverKind ?? 'default',
unsupportedFeatures: featuresForResource(net),
};
}
function blockReasonForGroup(
name: string,
declarations: KeyDeclaration[],
): { safe: boolean; blockReason?: BlockReason; unsupportedFeatures: UnsupportedFeature[] } {
if (!isValidDockerNetworkName(name)) {
return { safe: false, blockReason: 'invalid_name', unsupportedFeatures: [] };
}
if (RESERVED_SYSTEM_NETWORK_NAMES.has(name)) {
return { safe: false, blockReason: 'reserved_system', unsupportedFeatures: [] };
}
const unsupportedFeatures = stableSortFeatures(
declarations.flatMap((d) => d.unsupportedFeatures),
);
const hasUnsafeDriver = declarations.some((d) => !isSafeDriverKind(d.driverKind));
if (hasUnsafeDriver) {
return {
safe: false,
blockReason: 'unsupported_driver',
unsupportedFeatures,
};
}
if (unsupportedFeatures.length > 0) {
return {
safe: false,
blockReason: 'unsupported_options',
unsupportedFeatures,
};
}
return { safe: true, unsupportedFeatures: [] };
}
/**
* Classify missing external networks from an already-rendered effective model
* and a set of live Docker network names. Synchronous; no I/O.
*/
export function classifyMissingExternalNetworks(
model: EffectiveModel,
existingNetworkNames: ReadonlySet<string>,
): MissingExternalNetwork[] {
const byRuntimeName = new Map<string, KeyDeclaration[]>();
for (const [key, net] of Object.entries(model.networks)) {
if (!net.external) continue;
if (existingNetworkNames.has(net.name)) continue;
const declaration = classifyKey(key, net);
const list = byRuntimeName.get(net.name) ?? [];
list.push(declaration);
byRuntimeName.set(net.name, list);
}
const groups: MissingExternalNetwork[] = [];
for (const [name, declarations] of byRuntimeName) {
declarations.sort((a, b) => a.key.localeCompare(b.key));
const { safe, blockReason, unsupportedFeatures } = blockReasonForGroup(name, declarations);
groups.push({
name,
keys: declarations.map((d) => d.key),
declarations,
safe,
blockReason,
unsupportedFeatures,
creationSpec: safe ? SAFE_CREATION_SPEC : null,
});
}
groups.sort((a, b) => a.name.localeCompare(b.name));
return groups;
}
@@ -0,0 +1,57 @@
import type { MissingExternalNetwork } from '../network/missingExternalNetworks';
export type MissingExternalNetworksKind =
| 'prompt'
| 'unsupported'
| 'unavailable'
| 'create_failed';
/**
* Typed pre-Compose gate error. Must be thrown before atomic backup so it is
* never wrapped in ComposeRollbackError.
*/
export class MissingExternalNetworksError extends Error {
readonly code: 'missing_external_networks' | 'external_network_create_failed';
readonly kind: MissingExternalNetworksKind;
readonly networks: MissingExternalNetwork[];
readonly createdNames: string[];
readonly remainingNames: string[];
constructor(opts: {
kind: MissingExternalNetworksKind;
message: string;
networks?: MissingExternalNetwork[];
createdNames?: string[];
remainingNames?: string[];
}) {
super(opts.message);
this.name = 'MissingExternalNetworksError';
this.kind = opts.kind;
this.code = opts.kind === 'create_failed'
? 'external_network_create_failed'
: 'missing_external_networks';
this.networks = opts.networks ?? [];
this.createdNames = opts.createdNames ?? [];
this.remainingNames = opts.remainingNames ?? [];
}
}
export function isMissingExternalNetworksError(error: unknown): error is MissingExternalNetworksError {
return error instanceof MissingExternalNetworksError;
}
export interface DeployInvocationContext {
actor?: string | null;
source:
| 'manual'
| 'rollback'
| 'template'
| 'from_git'
| 'git_apply'
| 'fleet_snapshot'
| 'labels'
| 'scheduler'
| 'webhook'
| 'blueprint'
| 'mesh_redeploy';
}
@@ -112,9 +112,7 @@ function buildOverview(
});
if (hasUnclassifiedPublish) unknownExposureStackCount += 1;
missingExternalCount += facts.networks.filter((network) =>
network.external && facts.drift.missingFromRuntime.includes(network.name),
).length;
missingExternalCount += facts.missingExternalNetworks.length;
}
const connectedContainerCount = snapshot
@@ -327,21 +327,36 @@ export function buildNodeNetworkingFindings(
if (!snapshot) continue;
addComposeDriftFindings(out, facts, baseNetworks);
for (const network of facts.networks) {
if (network.external && facts.drift.missingFromRuntime.includes(network.name)) {
const isRunning = snapshot.containers.some(container => container.stack === facts.stack && ['running', 'restarting'].includes(container.state));
out.push(finding(
'external-network-missing', isRunning ? 'critical' : 'high', 'External network not found',
`Stack "${facts.stack}" requires the external network "${network.name}", which is not present on this node.`,
{ stack: facts.stack, network: network.name },
[
{ kind: 'create-network', label: 'Create network', networkName: network.name, requiresAdmin: true },
{ kind: 'copy-compose-snippet', label: 'Copy Compose snippet', snippetKind: 'external-network', networkName: network.name },
{ kind: 'copy-docker-command', label: 'Copy Docker command', commandKind: 'network-create', networkName: network.name },
{ kind: 'open-stack-editor', label: 'Open stack editor', stack: facts.stack },
],
));
for (const missing of facts.missingExternalNetworks) {
const isRunning = snapshot.containers.some(container => container.stack === facts.stack && ['running', 'restarting'].includes(container.state));
const actions: NetworkingRecommendedAction[] = [
{ kind: 'open-stack-editor', label: 'Open stack editor', stack: facts.stack },
];
if (missing.safe) {
actions.unshift(
{ kind: 'create-network', label: 'Create network', networkName: missing.name, requiresAdmin: true },
{ kind: 'copy-compose-snippet', label: 'Copy Compose snippet', snippetKind: 'external-network', networkName: missing.name },
{ kind: 'copy-docker-command', label: 'Copy Docker command', commandKind: 'network-create', networkName: missing.name },
);
}
const reasonSuffix = missing.safe
? ''
: ` Sencho cannot create it automatically (${[
missing.blockReason === 'unsupported_driver'
? `unsupported driver kind(s): ${[...new Set(missing.declarations.map((d) => d.driverKind))].join(', ')}`
: null,
missing.unsupportedFeatures.length > 0
? `unsupported options: ${missing.unsupportedFeatures.join(', ')}`
: null,
missing.blockReason === 'invalid_name' ? 'invalid Docker network name' : null,
missing.blockReason === 'reserved_system' ? 'reserved system network name' : null,
].filter(Boolean).join('; ')}).`;
out.push(finding(
'external-network-missing', isRunning ? 'critical' : 'high', 'External network not found',
`Stack "${facts.stack}" requires the external network "${missing.name}" (Compose keys: ${missing.keys.join(', ')}), which is not present on this node.${reasonSuffix}`,
{ stack: facts.stack, network: missing.name },
actions,
));
}
}
@@ -0,0 +1,124 @@
/**
* Async resolver for missing external networks (deploy + GET only).
* Doctor and Networking facts use the pure classifier with existing I/O.
*/
import DockerController from '../DockerController';
import { ComposeService } from '../ComposeService';
import { DatabaseService } from '../DatabaseService';
import { FileSystemService } from '../FileSystemService';
import { parseEffectiveModel, type EffectiveModel } from '../preflight/effectiveModel';
import { parseMissingRequiredVars } from '../../helpers/envVarParse';
import { getErrorMessage } from '../../utils/errors';
import { redactSensitiveText, sanitizeForLog } from '../../utils/safeLog';
import {
classifyMissingExternalNetworks,
type MissingExternalNetwork,
} from './missingExternalNetworks';
export type MissingExternalNetworksStatus = 'ok' | 'render_unavailable' | 'runtime_unavailable';
export interface MissingExternalNetworksEnvelope {
status: MissingExternalNetworksStatus;
autoCreateEnabled: boolean;
stackName: string;
networks: MissingExternalNetwork[];
/** Count of external network declarations when the model rendered; 0 otherwise. */
declaredExternalCount: number;
}
const MAX_RENDER_ERROR = 600;
function isAutoCreateEnabled(nodeId: number): boolean {
try {
return DatabaseService.getInstance().getGlobalSettings()['auto_create_missing_external_networks'] === '1';
} catch (error) {
console.warn(
'[MissingExternalNetworks] Failed to read auto-create setting for node %s:',
nodeId,
sanitizeForLog(getErrorMessage(error, 'unknown')),
);
return false;
}
}
async function renderModel(
nodeId: number,
stackName: string,
): Promise<{ model: EffectiveModel | null; renderError: string | null }> {
try {
const result = await ComposeService.getInstance(nodeId).renderConfig(stackName);
if (result.rendered !== null) {
try {
return { model: parseEffectiveModel(JSON.parse(result.rendered), stackName), renderError: null };
} catch (parseErr) {
console.warn(
'[MissingExternalNetworks] Effective model parse failed for %s:',
sanitizeForLog(stackName),
sanitizeForLog(getErrorMessage(parseErr, 'unknown')),
);
return { model: null, renderError: 'Sencho could not parse the rendered Compose model.' };
}
}
const missing = parseMissingRequiredVars(result.stderr);
return {
model: null,
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) {
const msg = redactSensitiveText(getErrorMessage(err, 'docker compose could not be started.'))
.slice(0, MAX_RENDER_ERROR)
.trim()
|| 'Sencho could not run docker compose on this node.';
return { model: null, renderError: msg };
}
}
export async function resolveMissingExternalNetworks(
nodeId: number,
stackName: string,
): Promise<MissingExternalNetworksEnvelope> {
const autoCreateEnabled = isAutoCreateEnabled(nodeId);
const { model } = await renderModel(nodeId, stackName);
if (!model) {
return {
status: 'render_unavailable',
autoCreateEnabled,
stackName,
networks: [],
declaredExternalCount: 0,
};
}
const declaredExternalCount = Object.values(model.networks).filter((n) => n.external).length;
let existingNames: Set<string>;
try {
const knownStacks = await FileSystemService.getInstance(nodeId).getStacks();
const snapshot = await DockerController.getInstance(nodeId).getDependencySnapshot(knownStacks);
existingNames = new Set(snapshot.networks.map((n) => n.name));
} catch (error) {
console.warn(
'[MissingExternalNetworks] Runtime snapshot unavailable for %s:',
sanitizeForLog(stackName),
sanitizeForLog(getErrorMessage(error, 'unknown')),
);
return {
status: 'runtime_unavailable',
autoCreateEnabled,
stackName,
networks: [],
declaredExternalCount,
};
}
return {
status: 'ok',
autoCreateEnabled,
stackName,
networks: classifyMissingExternalNetworks(model, existingNames),
declaredExternalCount,
};
}
+5
View File
@@ -83,4 +83,9 @@ export interface StackNetworkFacts {
networks: NetworkFactNetwork[];
services: NetworkFactService[];
drift: NetworkDriftFacts;
/**
* Missing external networks from the pure classifier. Empty when runtime is
* unavailable or the model is not renderable (use `runtime` / `renderable`).
*/
missingExternalNetworks: import('./missingExternalNetworks').MissingExternalNetwork[];
}