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
+8 -2
View File
@@ -12,7 +12,7 @@ import { ComposeService } from './ComposeService';
import { StackOpLockService, stackOpSkipMessage, type StackOpAction } from './StackOpLockService';
import { FileSystemService } from './FileSystemService';
import { NodeRegistry } from './NodeRegistry';
import { PROXY_TIER_HEADER } from './license-headers';
import { PROXY_TIER_HEADER, deployProvenanceHeaders } from './license-headers';
import { LicenseService } from './LicenseService';
import { assertPolicyGateAllows, buildSystemPolicyGateOptions, describePolicyBlock, triggerPostDeployScan } from '../helpers/policyGate';
import { enforcePolicyForImageRefs } from './PolicyEnforcement';
@@ -451,7 +451,12 @@ export class BlueprintService {
nodeId,
buildSystemPolicyGateOptions('blueprint', { auditPath }),
);
await ComposeService.getInstance(nodeId).deployStack(stackName, undefined, false);
await ComposeService.getInstance(nodeId).deployStack(
stackName,
undefined,
false,
{ source: 'blueprint', actor: 'system:blueprint' },
);
},
);
return lock.ran ? { ran: true } : { ran: false, existingAction: lock.existing.action };
@@ -495,6 +500,7 @@ export class BlueprintService {
Authorization: `Bearer ${apiToken}`,
[PROXY_TIER_HEADER]: proxy.tier,
'Content-Type': 'application/json',
...deployProvenanceHeaders('blueprint', 'system:blueprint'),
};
}
@@ -55,6 +55,7 @@ export const CAPABILITIES = [
'compose-storage',
'cross-node-rbac',
'stack-down-remove-volumes',
'guided-external-network-preflight',
] as const;
/**
+144 -1
View File
@@ -25,6 +25,41 @@ import { parseMissingRequiredVars } from '../helpers/envVarParse';
import { redactSensitiveText, sanitizeForLog } from '../utils/safeLog';
import { pathsMatch, resolveHostBindPath } from '../utils/composePathMapping';
import { loadStackBuildServices } from './ImageUpdateService';
import { resolveMissingExternalNetworks } from './network/resolveMissingExternalNetworks';
import {
MissingExternalNetworksError,
type DeployInvocationContext,
} from './network/missingExternalNetworksError';
import { invalidateNodeCaches } from '../helpers/cacheInvalidation';
import type { NotificationCategory } from './NotificationService';
function recordNetworkAutoCreatedActivity(
nodeId: number,
stackName: string,
createdNames: string[],
level: 'info' | 'warning',
ctx?: DeployInvocationContext,
): void {
if (createdNames.length === 0) return;
const names = [...createdNames].sort((a, b) => a.localeCompare(b)).join(', ');
const source = ctx?.source ?? 'manual';
try {
DatabaseService.getInstance().addNotificationHistory(nodeId, {
level,
category: 'network_auto_created' as NotificationCategory,
message: `Auto-created external network(s) for ${stackName}: ${names} (source: ${source})`,
timestamp: Date.now(),
stack_name: stackName,
actor_username: ctx?.actor ?? null,
});
} catch (error) {
console.error(
'[ComposeService] Failed to record network_auto_created activity for %s:',
sanitizeForLog(stackName),
sanitizeForLog(getErrorMessage(error, 'unknown')),
);
}
}
export class ComposeRollbackError extends Error {
public readonly rollbackAttempted: boolean;
@@ -461,9 +496,117 @@ export class ComposeService {
);
}
async deployStack(stackName: string, ws?: WebSocket, atomic?: boolean): Promise<void> {
/**
* Missing-external gate: after env/Pilot asserts, before atomic backup.
* Creates safe bridge networks only when the opt-in setting is on.
*/
private async ensureExternalNetworksForDeploy(
stackName: string,
ctx?: DeployInvocationContext,
): Promise<void> {
const resolved = await resolveMissingExternalNetworks(this.nodeId, stackName);
if (resolved.status === 'render_unavailable') {
throw new MissingExternalNetworksError({
kind: 'unavailable',
message: 'Sencho could not render this stack\'s Compose model to check external networks.',
});
}
if (resolved.status === 'runtime_unavailable') {
// No declared externals: nothing to verify; proceed.
if (resolved.declaredExternalCount === 0) return;
throw new MissingExternalNetworksError({
kind: 'unavailable',
message: 'Sencho could not read Docker networking state to check external networks.',
});
}
if (resolved.networks.length === 0) return;
const unsafe = resolved.networks.filter((n) => !n.safe);
if (unsafe.length > 0) {
throw new MissingExternalNetworksError({
kind: 'unsupported',
message: 'One or more missing external networks cannot be created safely by Sencho.',
networks: resolved.networks,
});
}
if (!resolved.autoCreateEnabled) {
throw new MissingExternalNetworksError({
kind: 'prompt',
message: 'One or more external networks required by this stack are missing on this node.',
networks: resolved.networks,
});
}
const docker = DockerController.getInstance(this.nodeId);
const createdNames: string[] = [];
const recordCreatedNetworks = (level: 'info' | 'warning') => {
if (createdNames.length === 0) return;
invalidateNodeCaches(this.nodeId);
recordNetworkAutoCreatedActivity(this.nodeId, stackName, createdNames, level, ctx);
};
for (const network of resolved.networks) {
try {
await docker.createNetwork({ Name: network.name, Driver: 'bridge' });
createdNames.push(network.name);
} catch (createErr) {
// Authoritative re-check: continue only if the network now exists.
let exists = false;
try {
const knownStacks = await FileSystemService.getInstance(this.nodeId).getStacks();
const snapshot = await docker.getDependencySnapshot(knownStacks);
exists = snapshot.networks.some((n) => n.name === network.name);
} catch (snapErr) {
console.warn(
'[ComposeService] Post-create snapshot failed for %s:',
sanitizeForLog(network.name),
sanitizeForLog(getErrorMessage(snapErr, 'unknown')),
);
}
if (!exists) {
recordCreatedNetworks('warning');
throw new MissingExternalNetworksError({
kind: 'create_failed',
message: `Failed to create external network "${network.name}".`,
networks: resolved.networks,
createdNames,
remainingNames: resolved.networks
.map((missingNetwork) => missingNetwork.name)
.filter((name) => !createdNames.includes(name)),
});
}
// Race-existing: do not record in createdNames.
}
}
// Re-resolve before Compose.
const recheck = await resolveMissingExternalNetworks(this.nodeId, stackName);
if (recheck.status !== 'ok' || recheck.networks.length > 0) {
recordCreatedNetworks('warning');
throw new MissingExternalNetworksError({
kind: recheck.status === 'ok' ? 'create_failed' : 'unavailable',
message: 'External networks were still missing after automatic creation.',
networks: recheck.networks,
createdNames,
remainingNames: recheck.networks.map((n) => n.name),
});
}
recordCreatedNetworks('info');
}
async deployStack(
stackName: string,
ws?: WebSocket,
atomic?: boolean,
ctx?: DeployInvocationContext,
): Promise<void> {
await this.assertRequiredEnvPresent(stackName);
await this.assertSafePilotBindMapping(stackName);
await this.ensureExternalNetworksForDeploy(stackName, ctx);
const stackDir = path.join(this.baseDir, stackName);
const debug = isDebugEnabled();
const t0 = Date.now();
+2
View File
@@ -1709,6 +1709,7 @@ export class DatabaseService {
stmt.run('image_update_check_cron', '');
stmt.run('image_update_sidebar_indicators', '1');
stmt.run('env_block_deploy_on_missing_required', '0');
stmt.run('auto_create_missing_external_networks', '0');
// Seed the default local node if none exists
const nodeCount = (this.db.prepare('SELECT COUNT(*) as count FROM nodes').get() as any)?.count || 0;
@@ -3117,6 +3118,7 @@ export class DatabaseService {
const categories = [
'deploy_success', 'deploy_failure', 'stack_started', 'stack_stopped', 'stack_restarted',
'image_update_applied', 'update_started', 'health_gate_passed', 'health_gate_failed',
'network_auto_created',
];
const placeholders = categories.map(() => '?').join(', ');
const sql = `
+2 -1
View File
@@ -27,6 +27,7 @@ import { isDebugEnabled } from '../utils/debug';
import { sanitizeForLog } from '../utils/safeLog';
import { describeSpawnError } from '../utils/spawnErrors';
import { authoredComposeFileArgs, authoredComposeEnvFileArgs } from '../utils/authoredComposeArgs';
import { isValidDockerNetworkName } from './network/dockerNetworkName';
export type {
PruneItemOutcome,
@@ -1418,7 +1419,7 @@ class DockerController {
}
public async createNetwork(options: CreateNetworkOptions) {
if (!options.Name || !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(options.Name)) {
if (!options.Name || !isValidDockerNetworkName(options.Name)) {
throw new Error('Invalid network name. Use alphanumeric characters, hyphens, underscores, and dots.');
}
return await this.docker.createNetwork(options);
+6 -1
View File
@@ -1325,7 +1325,12 @@ export class GitSourceService {
);
const lock = await StackOpLockService.getInstance().runExclusive(
nodeId, stackName, 'deploy', 'system',
() => ComposeService.getInstance(nodeId).deployStack(stackName),
() => ComposeService.getInstance(nodeId).deployStack(
stackName,
undefined,
undefined,
{ source: 'git_apply', actor: opts.actor ?? 'system:git-source' },
),
);
if (!lock.ran) {
const busy = `Auto-deploy skipped: another operation (${lock.existing.action}) is already in progress for ${stackName}.`;
+10 -3
View File
@@ -9,7 +9,7 @@ import { DatabaseService, type NodeMode } from './DatabaseService';
import DockerController from './DockerController';
import { FileSystemService } from './FileSystemService';
import { LicenseService } from './LicenseService';
import { PROXY_TIER_HEADER } from './license-headers';
import { PROXY_TIER_HEADER, deployProvenanceHeaders } from './license-headers';
import { MeshForwarder, type MeshForwarderHost } from './MeshForwarder';
import { NodeRegistry } from './NodeRegistry';
import { PilotTunnelManager } from './PilotTunnelManager';
@@ -2272,11 +2272,12 @@ export class MeshService extends EventEmitter implements MeshForwarderHost {
apiPath: string,
body: unknown,
timeoutMs: number,
extraHeaders?: Record<string, string>,
): Promise<Response> {
const target = NodeRegistry.getInstance().getProxyTarget(nodeId);
if (!target) throw new MeshError('no_target', `no proxy target for node ${nodeId}`);
const url = `${target.apiUrl.replace(/\/$/, '')}${apiPath}`;
const headers: Record<string, string> = {};
const headers: Record<string, string> = { ...extraHeaders };
if (body !== undefined) headers['Content-Type'] = 'application/json';
if (target.apiToken) headers['Authorization'] = `Bearer ${target.apiToken}`;
const proxyHeaders = LicenseService.getInstance().getProxyHeaders();
@@ -2383,7 +2384,12 @@ export class MeshService extends EventEmitter implements MeshForwarderHost {
);
const lock = await StackOpLockService.getInstance().runExclusive(
nodeId, stackName, 'deploy', 'system',
() => ComposeService.getInstance(nodeId).deployStack(stackName),
() => ComposeService.getInstance(nodeId).deployStack(
stackName,
undefined,
undefined,
{ source: 'mesh_redeploy', actor: 'system:mesh' },
),
);
if (!lock.ran) {
throw new Error(`Cannot redeploy "${stackName}": another operation (${lock.existing.action}) is already in progress.`);
@@ -2406,6 +2412,7 @@ export class MeshService extends EventEmitter implements MeshForwarderHost {
`/api/stacks/${encodeURIComponent(stackName)}/deploy`,
{},
10 * 60 * 1000,
deployProvenanceHeaders('mesh_redeploy', 'system:mesh'),
);
if (!res.ok) {
const body = await res.text().catch(() => '');
@@ -39,6 +39,8 @@ export type NotificationCategory =
| 'update_started'
| 'health_gate_passed'
| 'health_gate_failed'
// Automatic external-network creation during deploy. History-only.
| 'network_auto_created'
| 'node_update_available'
| 'system';
@@ -56,6 +58,7 @@ export const ALL_SUPPRESSIBLE_CATEGORIES: readonly NotificationCategory[] = [
...ALL_NOTIFICATION_CATEGORIES,
'drift_detected', 'drift_resolved',
'update_started', 'health_gate_passed', 'health_gate_failed',
'network_auto_created',
];
/** Webhook timeout: 10 seconds per external dispatch call. */
+18 -4
View File
@@ -2,7 +2,7 @@ import { CronExpressionParser } from 'cron-parser';
import { DatabaseService } from './DatabaseService';
import type { ScheduledTask } from './DatabaseService';
import { LicenseService } from './LicenseService';
import { PROXY_TIER_HEADER } from './license-headers';
import { PROXY_TIER_HEADER, deployProvenanceHeaders } from './license-headers';
import DockerController from './DockerController';
import { ComposeService } from './ComposeService';
import { StackOpLockService, stackOpSkipMessage as skipMessage } from './StackOpLockService';
@@ -561,7 +561,11 @@ export class SchedulerService {
// that node's scan-policy gate against the images it actually holds. The
// hub-side enforceSchedulerPolicyGate below is for local nodes only.
if (this.isRemoteNode(task.node_id)) {
await this.postToRemoteStack(task.node_id, `${encodeURIComponent(task.target_id)}/deploy`);
await this.postToRemoteStack(
task.node_id,
`${encodeURIComponent(task.target_id)}/deploy`,
deployProvenanceHeaders('scheduler', 'system:scheduler'),
);
return `Started stack "${task.target_id}" on remote node`;
}
await this.enforceSchedulerPolicyGate(
@@ -573,7 +577,12 @@ export class SchedulerService {
const localNodeId = task.node_id ?? NodeRegistry.getInstance().getDefaultNodeId();
const lock = await StackOpLockService.getInstance().runExclusive(
localNodeId, task.target_id, 'deploy', 'system',
() => ComposeService.getInstance(localNodeId).deployStack(task.target_id),
() => ComposeService.getInstance(localNodeId).deployStack(
task.target_id,
undefined,
undefined,
{ source: 'scheduler', actor: 'system:scheduler' },
),
);
if (!lock.ran) throw new Error(skipMessage(task.target_id, lock.existing.action));
return `Started stack "${task.target_id}"`;
@@ -984,7 +993,11 @@ export class SchedulerService {
}
}
private async postToRemoteStack(nodeId: number, routeSuffix: string): Promise<void> {
private async postToRemoteStack(
nodeId: number,
routeSuffix: string,
extraHeaders?: Record<string, string>,
): Promise<void> {
const proxyTarget = this.requireRemoteProxyTarget(nodeId);
const baseUrl = proxyTarget.apiUrl.replace(/\/$/, '');
const proxyHeaders = LicenseService.getInstance().getProxyHeaders();
@@ -998,6 +1011,7 @@ export class SchedulerService {
'Content-Type': 'application/json',
'Authorization': `Bearer ${proxyTarget.apiToken}`,
[PROXY_TIER_HEADER]: proxyHeaders.tier,
...extraHeaders,
},
signal: AbortSignal.timeout(300_000),
});
+6 -1
View File
@@ -156,7 +156,12 @@ export class WebhookService {
nodeId,
buildSystemPolicyGateOptions('webhook', { auditPath: `/api/webhooks/${webhookId}/execute` }),
);
await compose.deployStack(stackName, undefined, atomic);
await compose.deployStack(
stackName,
undefined,
atomic,
{ source: 'webhook', actor: 'system:webhook' },
);
HealthGateService.getInstance().begin(nodeId, stackName, 'deploy', 'system:webhook');
break;
case 'restart':
+41
View File
@@ -16,3 +16,44 @@ export const PROXY_TIER_HEADER = 'x-sencho-tier';
* client cannot smuggle a role through.
*/
export const PROXY_ROLE_HEADER = 'x-sencho-actor-role';
/**
* Trusted deploy provenance for machine-to-machine / proxied deploys.
* The gateway always strips client-supplied values and, for interactive
* proxied requests, overwrites with source=manual and the signed-in username.
* Background callers (scheduler, fleet, blueprint, mesh) set these only on
* direct machine-originated HTTP after the strip boundary.
*/
export const PROXY_DEPLOY_SOURCE_HEADER = 'x-sencho-deploy-source';
export const PROXY_DEPLOY_ACTOR_HEADER = 'x-sencho-deploy-actor';
export const DEPLOY_SOURCES = [
'manual',
'rollback',
'template',
'from_git',
'git_apply',
'fleet_snapshot',
'labels',
'scheduler',
'webhook',
'blueprint',
'mesh_redeploy',
] as const;
export type DeploySourceHeader = (typeof DEPLOY_SOURCES)[number];
export function isDeploySourceHeader(value: unknown): value is DeploySourceHeader {
return typeof value === 'string' && (DEPLOY_SOURCES as readonly string[]).includes(value);
}
/** Headers for direct machine-originated deploy HTTP (never for browser clients). */
export function deployProvenanceHeaders(
source: DeploySourceHeader,
actor: string,
): Record<string, string> {
return {
[PROXY_DEPLOY_SOURCE_HEADER]: source,
[PROXY_DEPLOY_ACTOR_HEADER]: actor,
};
}
@@ -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[];
}
@@ -70,12 +70,43 @@ export interface EffService {
labelKeys: string[];
}
/**
* Display-safe driver classification. Arbitrary interpolated driver strings
* (which can carry resolved .env values) are collapsed to `custom` and never
* retained as raw text on the model.
*/
export type EffDriverKind =
| 'default'
| 'bridge'
| 'overlay'
| 'macvlan'
| 'ipvlan'
| 'host'
| 'none'
| 'custom';
export interface EffResource {
/** Resolved docker name (compose config fills this in). */
name: string;
external: boolean;
/** Top-level `internal: true` (no outbound/host connectivity for the network). */
internal: boolean;
/**
* Bounded driver kind; never a raw interpolated driver string.
* Absent on older hand-built test fixtures; treated as `default`.
*/
driverKind?: EffDriverKind;
/** True when `driver_opts` is a non-empty object. */
hasDriverOpts?: boolean;
/** True when IPAM has a non-empty driver, config, or options (empty `{}` is safe). */
hasCustomIpam?: boolean;
attachable?: boolean;
/** False only when `enable_ipv4` is explicitly false. Absent means enabled. */
ipv4Enabled?: boolean;
/** True only when `enable_ipv6` is explicitly true. Absent means disabled. */
ipv6Enabled?: boolean;
/** True when `labels` is a non-empty object (values are never retained). */
hasLabels?: boolean;
}
export interface EffectiveModel {
@@ -293,12 +324,53 @@ function parseExtraHosts(extraHosts: unknown): string[] {
return [];
}
const KNOWN_DRIVER_KINDS: ReadonlySet<string> = new Set([
'bridge', 'overlay', 'macvlan', 'ipvlan', 'host', 'none',
]);
function toDriverKind(raw: string | undefined): EffDriverKind {
if (raw === undefined || raw === '') return 'default';
const lower = raw.toLowerCase();
if (KNOWN_DRIVER_KINDS.has(lower)) return lower as EffDriverKind;
return 'custom';
}
function isNonEmptyObject(value: unknown): boolean {
return !!value && typeof value === 'object' && !Array.isArray(value)
&& Object.keys(value as Record<string, unknown>).length > 0;
}
/** Empty `ipam: {}` is a Compose-normalized default; only non-empty IPAM blocks. */
function hasCustomIpam(ipam: unknown): boolean {
if (!ipam || typeof ipam !== 'object' || Array.isArray(ipam)) return false;
const o = ipam as Record<string, unknown>;
if (typeof o.driver === 'string' && o.driver.trim() !== '') return true;
if (Array.isArray(o.config) && o.config.length > 0) return true;
if (isNonEmptyObject(o.options)) return true;
return false;
}
function parseResourceEntry(key: string, entry: unknown): EffResource {
const o = (entry ?? {}) as Record<string, unknown>;
return {
name: str(o.name) ?? key,
external: o.external === true,
internal: o.internal === true,
driverKind: toDriverKind(str(o.driver)),
hasDriverOpts: isNonEmptyObject(o.driver_opts),
hasCustomIpam: hasCustomIpam(o.ipam),
attachable: o.attachable === true,
ipv4Enabled: o.enable_ipv4 !== false,
ipv6Enabled: o.enable_ipv6 === true,
hasLabels: isNonEmptyObject(o.labels),
};
}
function parseResources(value: unknown): Record<string, EffResource> {
const out: Record<string, EffResource> = {};
if (value && typeof value === 'object' && !Array.isArray(value)) {
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
const o = (entry ?? {}) as Record<string, unknown>;
out[key] = { name: str(o.name) ?? key, external: o.external === true, internal: o.internal === true };
out[key] = parseResourceEntry(key, entry);
}
}
return out;
+30 -11
View File
@@ -2,6 +2,7 @@ import type { PreflightContext, PreflightFinding, PreflightSeverity, NodePortBin
import type { EffService, EffPortSpec } from './effectiveModel';
import type { ExposureIntent } from '../network/types';
import { isLoopback, runtimeResourceName } from '../network/normalize';
import { classifyMissingExternalNetworks } from '../network/missingExternalNetworks';
/** Higher number = more severe. Used to derive a run's overall status. */
export const SEVERITY_RANK: Record<PreflightSeverity, number> = { info: 0, warning: 1, high: 2, blocker: 3 };
@@ -434,19 +435,37 @@ const externalNetworkMissing: PreflightRule = {
id: 'external-network-missing',
run(ctx) {
if (!ctx.model || !ctx.nodeStateAvailable) return [];
const findings: PreflightFinding[] = [];
for (const [key, net] of Object.entries(ctx.model.networks)) {
if (!net.external || ctx.existingNetworkNames.has(net.name)) continue;
findings.push({
return classifyMissingExternalNetworks(ctx.model, ctx.existingNetworkNames).map((group) => {
const keysLabel = group.keys.map((k) => `networks.${k}`).join(', ');
if (group.safe) {
return {
ruleId: 'external-network-missing',
severity: 'blocker' as const,
title: 'External network not found',
message: `The model requires the external network "${group.name}" (Compose keys: ${group.keys.join(', ')}), which does not exist on this node. The deploy will fail.`,
sourcePath: keysLabel,
remediation: `Create it with: docker network create ${group.name}`,
};
}
const reasons = [
group.blockReason === 'unsupported_driver'
? `unsupported driver kind(s): ${[...new Set(group.declarations.map((d) => d.driverKind))].join(', ')}`
: null,
group.unsupportedFeatures.length > 0
? `unsupported options: ${group.unsupportedFeatures.join(', ')}`
: null,
group.blockReason === 'invalid_name' ? 'invalid Docker network name' : null,
group.blockReason === 'reserved_system' ? 'reserved system network name' : null,
].filter(Boolean).join('; ');
return {
ruleId: 'external-network-missing',
severity: 'blocker',
severity: 'blocker' as const,
title: 'External network not found',
message: `The model requires the external network "${net.name}", which does not exist on this node. The deploy will fail.`,
sourcePath: `networks.${key}`,
remediation: `Create it with: docker network create ${net.name}`,
});
}
return findings;
message: `The model requires the external network "${group.name}" (Compose keys: ${group.keys.join(', ')}), which does not exist on this node. Sencho cannot create it automatically (${reasons}).`,
sourcePath: keysLabel,
remediation: 'Create this network outside Sencho with the required driver and options, or simplify the Compose declaration to a plain external bridge network.',
};
});
},
};