mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-25 09:46:47 +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:
@@ -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);
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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`);
|
||||
|
||||
Reference in New Issue
Block a user