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
+11 -3
View File
@@ -53,7 +53,7 @@ import { buildLocalConfigurationStatus, type ConfigurationStatus } from './dashb
import { buildLocalGraph, mergeFleetGraph, isLocalDependencyGraph, type FleetNodeGraphResult } from '../services/DependencyGraphService';
import { buildNodeLabelInventory, VALID_LABEL_SOURCES, type NodeLabelInventory } from '../services/LabelInventoryService';
import { labelInventoryOptionsFromRequest, requireRevealAdmin } from '../helpers/labelInventoryRequest';
import { PROXY_TIER_HEADER } from '../services/license-headers';
import { PROXY_TIER_HEADER, deployProvenanceHeaders } from '../services/license-headers';
import { LicenseService } from '../services/LicenseService';
const updateTracker = FleetUpdateTrackerService.getInstance();
@@ -2734,7 +2734,12 @@ async function redeploySnapshotStack(node: Node, stackName: string): Promise<voi
if (node.type === 'local') {
const lock = await StackOpLockService.getInstance().runExclusive(
node.id, stackName, 'deploy', 'system',
() => ComposeService.getInstance(node.id).deployStack(stackName),
() => ComposeService.getInstance(node.id).deployStack(
stackName,
undefined,
undefined,
{ source: 'fleet_snapshot', actor: 'system:fleet-snapshot' },
),
);
if (!lock.ran) {
throw new Error(`Cannot redeploy "${stackName}": another operation (${lock.existing.action}) is already in progress.`);
@@ -2745,7 +2750,10 @@ async function redeploySnapshotStack(node: Node, stackName: string): Promise<voi
if (!ctx) throw new SnapshotProxyTargetError(formatNoTargetError(node));
const deployRes = await fetch(`${ctx.baseUrl}/api/stacks/${encodeURIComponent(stackName)}/deploy`, {
method: 'POST',
headers: ctx.headers,
headers: {
...ctx.headers,
...deployProvenanceHeaders('fleet_snapshot', 'system:fleet-snapshot'),
},
signal: AbortSignal.timeout(30000),
});
if (!deployRes.ok) throw await remoteStackError('Failed to redeploy stack', deployRes);
+6 -1
View File
@@ -239,7 +239,12 @@ labelsRouter.post('/:id/action', authMiddleware, async (req: Request, res: Respo
// deploy/update/rollback/backup on the same stack and node.
const lock = await StackOpLockService.getInstance().runExclusive(
req.nodeId, stackName, 'deploy', 'system',
() => ComposeService.getInstance(req.nodeId).deployStack(stackName, undefined, false),
() => ComposeService.getInstance(req.nodeId).deployStack(
stackName,
undefined,
false,
req.deployContext ?? { source: 'labels', actor: req.user?.username ?? null },
),
);
if (!lock.ran) {
results.push({ stackName, success: false, error: stackOpSkipMessage(stackName, lock.existing.action) });
+2
View File
@@ -32,6 +32,7 @@ const ALLOWED_SETTING_KEYS = new Set([
'health_gate_enabled',
'health_gate_window_seconds',
'env_block_deploy_on_missing_required',
'auto_create_missing_external_networks',
'image_update_sidebar_indicators',
]);
@@ -63,6 +64,7 @@ const SettingsPatchSchema = z.object({
health_gate_enabled: z.enum(['0', '1']),
health_gate_window_seconds: z.coerce.number().int().min(15).max(600).transform(String),
env_block_deploy_on_missing_required: z.enum(['0', '1']),
auto_create_missing_external_networks: z.enum(['0', '1']),
image_update_sidebar_indicators: z.enum(['0', '1']),
}).partial();
+70 -4
View File
@@ -1048,7 +1048,12 @@ stacksRouter.post('/from-git', async (req: Request, res: Response) => {
deployError = describePolicyBlock(gate.policy, gate.violations);
} else {
try {
await ComposeService.getInstance(req.nodeId).deployStack(stack_name);
await ComposeService.getInstance(req.nodeId).deployStack(
stack_name,
undefined,
undefined,
req.deployContext ?? { source: 'from_git', actor: req.user?.username ?? null },
);
deployed = true;
invalidateNodeCaches(req.nodeId);
} catch (e) {
@@ -1328,6 +1333,20 @@ stacksRouter.get('/:stackName/preflight', async (req: Request, res: Response) =>
}
});
stacksRouter.get('/:stackName/missing-external-networks', async (req: Request, res: Response) => {
const stackName = req.params.stackName as string;
if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return;
if (!(await requireStackExists(req.nodeId, stackName, res))) return;
try {
const { resolveMissingExternalNetworks } = await import('../services/network/resolveMissingExternalNetworks');
res.json(await resolveMissingExternalNetworks(req.nodeId, stackName));
} catch (error) {
console.error('[Stacks] Failed to resolve missing external networks for %s:', sanitizeForLog(stackName),
sanitizeForLog(getErrorMessage(error, 'unknown')));
res.status(500).json({ error: 'Failed to check external networks' });
}
});
// Running preflight renders the effective model and stores the result, replacing
// any prior run. It is advisory and never blocks a deploy; stack:read is the
// correct gate since it mutates only the preflight tables, never the stack.
@@ -1695,7 +1714,12 @@ stacksRouter.post('/:stackName/deploy', async (req: Request, res: Response) => {
const debug = isDebugEnabled();
const atomic = true;
if (debug) console.debug('[Stacks:debug] Deploy starting', { stackName, atomic, nodeId: req.nodeId });
await ComposeService.getInstance(req.nodeId).deployStack(stackName, getTerminalWs(req.get(DEPLOY_SESSION_HEADER)), atomic);
await ComposeService.getInstance(req.nodeId).deployStack(
stackName,
getTerminalWs(req.get(DEPLOY_SESSION_HEADER)),
atomic,
req.deployContext ?? { source: 'manual', actor: req.user?.username ?? null },
);
invalidateNodeCaches(req.nodeId);
dlog(`[Stacks] Deploy completed: ${sanitizeForLog(stackName)}`);
if (debug) console.debug(`[Stacks:debug] Deploy finished in ${Date.now() - t0}ms`);
@@ -1710,6 +1734,21 @@ stacksRouter.post('/:stackName/deploy', async (req: Request, res: Response) => {
}
} catch (error: unknown) {
console.error('[Stacks] Deploy failed: %s', sanitizeForLog(stackName), error);
const { isMissingExternalNetworksError } = await import('../services/network/missingExternalNetworksError');
if (isMissingExternalNetworksError(error)) {
const status = error.kind === 'unavailable' ? 503 : error.kind === 'create_failed' ? 500 : 409;
if (!res.headersSent) {
res.status(status).json({
error: error.message,
code: error.code,
kind: error.kind,
networks: error.networks,
createdNames: error.createdNames,
remainingNames: error.remainingNames,
});
}
return;
}
const rollbackInfo = getComposeRollbackInfo(error);
const rolledBack = rollbackInfo?.rolledBack ?? false;
if (rolledBack) {
@@ -2033,6 +2072,7 @@ stacksRouter.post('/:stackName/rollback', async (req: Request, res: Response) =>
// the same project. Lock held below: all early-returns stay inside the try so
// finally fires.
if (!tryAcquireStackOpLock(req, res, stackName, 'rollback')) return;
let revertRestore: (() => Promise<void>) | null = null;
try {
const fsSvc = FileSystemService.getInstance(req.nodeId);
const backupInfo = await fsSvc.getBackupInfo(stackName);
@@ -2045,7 +2085,7 @@ stacksRouter.post('/:stackName/rollback', async (req: Request, res: Response) =>
// the restored target can be undone: restoreStackFiles commits to disk, and
// without this a blocked rollback would leave disk rolled back while the
// deployed state is unchanged.
const revertRestore = await fsSvc.snapshotStackFiles(stackName);
revertRestore = await fsSvc.snapshotStackFiles(stackName);
await fsSvc.restoreStackFiles(stackName);
if (!(await runPolicyGate(req, res, stackName, req.nodeId))) {
try {
@@ -2059,13 +2099,39 @@ stacksRouter.post('/:stackName/rollback', async (req: Request, res: Response) =>
}
return;
}
await ComposeService.getInstance(req.nodeId).deployStack(stackName, getTerminalWs(req.get(DEPLOY_SESSION_HEADER)), false);
await ComposeService.getInstance(req.nodeId).deployStack(
stackName,
getTerminalWs(req.get(DEPLOY_SESSION_HEADER)),
false,
req.deployContext ?? { source: 'rollback', actor: req.user?.username ?? null },
);
invalidateNodeCaches(req.nodeId);
dlog(`[Stacks] Rollback completed: ${sanitizeForLog(stackName)}`);
res.json({ message: 'Stack rolled back: compose and env files restored.' });
notifyActionSuccess('deploy_success', `${stackName} rolled back`, stackName, req.user?.username ?? 'system');
} catch (error: unknown) {
console.error('[Stacks] Rollback failed: %s', sanitizeForLog(stackName), error);
const { isMissingExternalNetworksError } = await import('../services/network/missingExternalNetworksError');
if (isMissingExternalNetworksError(error)) {
if (revertRestore) {
try {
await revertRestore();
} catch (revertError) {
console.error('[Stacks] Failed to revert files after a network-blocked rollback: %s', sanitizeForLog(stackName), revertError);
notifyActionFailure('rollback', stackName, revertError, req.user?.username ?? 'system');
}
}
const status = error.kind === 'unavailable' ? 503 : error.kind === 'create_failed' ? 500 : 409;
if (!res.headersSent) {
res.status(status).json({
error: error.message,
code: error.code,
kind: error.kind,
networks: error.networks,
});
}
return;
}
const message = getErrorMessage(error, 'Rollback failed.');
notifyActionFailure('rollback', stackName, error, req.user?.username ?? 'system');
if (!res.headersSent) {
+6 -1
View File
@@ -135,7 +135,12 @@ templatesRouter.post('/deploy', authMiddleware, async (req: Request, res: Respon
return;
}
const atomic = true;
await ComposeService.getInstance(req.nodeId).deployStack(stackName, getTerminalWs(req.get(DEPLOY_SESSION_HEADER)), atomic);
await ComposeService.getInstance(req.nodeId).deployStack(
stackName,
getTerminalWs(req.get(DEPLOY_SESSION_HEADER)),
atomic,
req.deployContext ?? { source: 'template', actor: req.user?.username ?? null },
);
invalidateNodeCaches(req.nodeId);
console.log(`[Templates] Deploy completed: ${stackName}`);
if (debug) console.debug(`[Templates:debug] Deploy timing: ${stackName} took ${Date.now() - deployStartedAt}ms`);