mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-19 06:46:23 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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.',
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user