feat: add extra Docker networks to container config
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m23s
CI / docker (push) Successful in 1m7s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 38s

Apps can now join additional Docker networks (e.g., monitoring,
prometheus) configured via containerConfig.extraNetworks. Flows through
the 3-layer config merge. Networks are created if absent and containers
are connected during deployment. UI adds a pill-list field on the
Resources tab (both create and edit views).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-12 16:53:01 +02:00
parent 5b6543b167
commit be96336974
4 changed files with 68 additions and 2 deletions

View File

@@ -147,6 +147,16 @@ public class DeploymentExecutor {
additionalNets.add(envNet); additionalNets.add(envNet);
} }
// User-configured extra networks (e.g., monitoring)
if (networkManager != null && config.extraNetworks() != null) {
for (String net : config.extraNetworks()) {
if (!net.isBlank() && !additionalNets.contains(net)) {
networkManager.ensureNetwork(net);
additionalNets.add(net);
}
}
}
// === START REPLICAS === // === START REPLICAS ===
updateStage(deployment.id(), DeployStage.START_REPLICAS); updateStage(deployment.id(), DeployStage.START_REPLICAS);
@@ -370,6 +380,7 @@ public class DeploymentExecutor {
map.put("deploymentStrategy", config.deploymentStrategy()); map.put("deploymentStrategy", config.deploymentStrategy());
map.put("runtimeType", config.runtimeType()); map.put("runtimeType", config.runtimeType());
map.put("customArgs", config.customArgs()); map.put("customArgs", config.customArgs());
map.put("extraNetworks", config.extraNetworks());
return map; return map;
} }
} }

View File

@@ -32,7 +32,8 @@ public final class ConfigMerger {
boolVal(appConfig, envConfig, "routeControlEnabled", true), boolVal(appConfig, envConfig, "routeControlEnabled", true),
boolVal(appConfig, envConfig, "replayEnabled", true), boolVal(appConfig, envConfig, "replayEnabled", true),
stringVal(appConfig, envConfig, "runtimeType", "auto"), stringVal(appConfig, envConfig, "runtimeType", "auto"),
stringVal(appConfig, envConfig, "customArgs", "") stringVal(appConfig, envConfig, "customArgs", ""),
stringList(appConfig, envConfig, "extraNetworks")
); );
} }
@@ -78,6 +79,17 @@ public final class ConfigMerger {
return List.of(); return List.of();
} }
private static List<String> stringList(Map<String, Object> app, Map<String, Object> env, String key) {
Object val = app.containsKey(key) ? app.get(key) : env.get(key);
if (val instanceof List<?> list) {
return list.stream()
.filter(String.class::isInstance)
.map(String.class::cast)
.toList();
}
return List.of();
}
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
private static Map<String, String> stringMap(Map<String, Object> app, Map<String, Object> env, String key) { private static Map<String, String> stringMap(Map<String, Object> app, Map<String, Object> env, String key) {
Object val = app.containsKey(key) ? app.get(key) : env.get(key); Object val = app.containsKey(key) ? app.get(key) : env.get(key);

View File

@@ -21,7 +21,8 @@ public record ResolvedContainerConfig(
boolean routeControlEnabled, boolean routeControlEnabled,
boolean replayEnabled, boolean replayEnabled,
String runtimeType, String runtimeType,
String customArgs String customArgs,
List<String> extraNetworks
) { ) {
public long memoryLimitBytes() { public long memoryLimitBytes() {
return (long) memoryLimitMb * 1024 * 1024; return (long) memoryLimitMb * 1024 * 1024;

View File

@@ -211,6 +211,8 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
const [sslOffloading, setSslOffloading] = useState(true); const [sslOffloading, setSslOffloading] = useState(true);
const [runtimeType, setRuntimeType] = useState(String(defaults.runtimeType ?? 'auto')); const [runtimeType, setRuntimeType] = useState(String(defaults.runtimeType ?? 'auto'));
const [customArgs, setCustomArgs] = useState(String(defaults.customArgs ?? '')); const [customArgs, setCustomArgs] = useState(String(defaults.customArgs ?? ''));
const [extraNetworks, setExtraNetworks] = useState<string[]>(Array.isArray(defaults.extraNetworks) ? defaults.extraNetworks as string[] : []);
const [newNetwork, setNewNetwork] = useState('');
const [configTab, setConfigTab] = useState<'monitoring' | 'resources' | 'variables'>('monitoring'); const [configTab, setConfigTab] = useState<'monitoring' | 'resources' | 'variables'>('monitoring');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -226,6 +228,7 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
setPorts(Array.isArray(d.exposedPorts) ? d.exposedPorts as number[] : []); setPorts(Array.isArray(d.exposedPorts) ? d.exposedPorts as number[] : []);
setRuntimeType(String(d.runtimeType ?? 'auto')); setRuntimeType(String(d.runtimeType ?? 'auto'));
setCustomArgs(String(d.customArgs ?? '')); setCustomArgs(String(d.customArgs ?? ''));
setExtraNetworks(Array.isArray(d.extraNetworks) ? d.extraNetworks as string[] : []);
}, [envId, environments]); }, [envId, environments]);
useEffect(() => { useEffect(() => {
@@ -267,6 +270,7 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
sslOffloading: sslOffloading, sslOffloading: sslOffloading,
runtimeType: runtimeType, runtimeType: runtimeType,
customArgs: customArgs || null, customArgs: customArgs || null,
extraNetworks: extraNetworks,
}; };
await updateContainerConfig.mutateAsync({ appId: app.slug, config: containerConfig }); await updateContainerConfig.mutateAsync({ appId: app.slug, config: containerConfig });
@@ -500,6 +504,23 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
<Toggle checked={sslOffloading} onChange={() => !busy && setSslOffloading(!sslOffloading)} disabled={busy} /> <Toggle checked={sslOffloading} onChange={() => !busy && setSslOffloading(!sslOffloading)} disabled={busy} />
<span className={sslOffloading ? styles.toggleEnabled : styles.toggleDisabled}>{sslOffloading ? 'Enabled' : 'Disabled'}</span> <span className={sslOffloading ? styles.toggleEnabled : styles.toggleDisabled}>{sslOffloading ? 'Enabled' : 'Disabled'}</span>
</div> </div>
<span className={styles.configLabel}>Extra Networks</span>
<div>
<div className={styles.portPills}>
{extraNetworks.map((n) => (
<span key={n} className={styles.portPill}>
{n}
<button className={styles.portPillDelete} disabled={busy}
onClick={() => !busy && setExtraNetworks(extraNetworks.filter((x) => x !== n))}>&times;</button>
</span>
))}
<input className={styles.portAddInput} disabled={busy} placeholder="+ network" value={newNetwork}
onChange={(e) => setNewNetwork(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); const v = newNetwork.trim(); if (v && !extraNetworks.includes(v)) { setExtraNetworks([...extraNetworks, v]); setNewNetwork(''); } } }} />
</div>
<span className={styles.configHint}>Additional Docker networks to join (e.g., monitoring, prometheus)</span>
</div>
</div> </div>
</div> </div>
)} )}
@@ -829,6 +850,8 @@ function ConfigSubTab({ app, environment }: { app: App; environment?: Environmen
const [sslOffloading, setSslOffloading] = useState(true); const [sslOffloading, setSslOffloading] = useState(true);
const [runtimeType, setRuntimeType] = useState(String(merged.runtimeType ?? 'auto')); const [runtimeType, setRuntimeType] = useState(String(merged.runtimeType ?? 'auto'));
const [customArgs, setCustomArgs] = useState(String(merged.customArgs ?? '')); const [customArgs, setCustomArgs] = useState(String(merged.customArgs ?? ''));
const [extraNetworks, setExtraNetworks] = useState<string[]>(Array.isArray(merged.extraNetworks) ? merged.extraNetworks as string[] : []);
const [newNetwork, setNewNetwork] = useState('');
// Versions query for runtime detection hints // Versions query for runtime detection hints
const { data: versions = [] } = useAppVersions(app.slug); const { data: versions = [] } = useAppVersions(app.slug);
@@ -862,6 +885,7 @@ function ConfigSubTab({ app, environment }: { app: App; environment?: Environmen
setSslOffloading(merged.sslOffloading !== false); setSslOffloading(merged.sslOffloading !== false);
setRuntimeType(String(merged.runtimeType ?? 'auto')); setRuntimeType(String(merged.runtimeType ?? 'auto'));
setCustomArgs(String(merged.customArgs ?? '')); setCustomArgs(String(merged.customArgs ?? ''));
setExtraNetworks(Array.isArray(merged.extraNetworks) ? merged.extraNetworks as string[] : []);
}, [agentConfig, merged]); }, [agentConfig, merged]);
useEffect(() => { syncFromServer(); }, [syncFromServer]); useEffect(() => { syncFromServer(); }, [syncFromServer]);
@@ -913,6 +937,7 @@ function ConfigSubTab({ app, environment }: { app: App; environment?: Environmen
sslOffloading: sslOffloading, sslOffloading: sslOffloading,
runtimeType: runtimeType, runtimeType: runtimeType,
customArgs: customArgs || null, customArgs: customArgs || null,
extraNetworks: extraNetworks,
}; };
try { try {
await updateContainerConfig.mutateAsync({ appId: app.slug, config: containerConfig }); await updateContainerConfig.mutateAsync({ appId: app.slug, config: containerConfig });
@@ -1198,6 +1223,23 @@ function ConfigSubTab({ app, environment }: { app: App; environment?: Environmen
<Toggle checked={sslOffloading} onChange={() => editing && setSslOffloading(!sslOffloading)} disabled={!editing} /> <Toggle checked={sslOffloading} onChange={() => editing && setSslOffloading(!sslOffloading)} disabled={!editing} />
<span className={sslOffloading ? styles.toggleEnabled : styles.toggleDisabled}>{sslOffloading ? 'Enabled' : 'Disabled'}</span> <span className={sslOffloading ? styles.toggleEnabled : styles.toggleDisabled}>{sslOffloading ? 'Enabled' : 'Disabled'}</span>
</div> </div>
<span className={styles.configLabel}>Extra Networks</span>
<div>
<div className={styles.portPills}>
{extraNetworks.map((n) => (
<span key={n} className={styles.portPill}>
{n}
<button className={styles.portPillDelete} disabled={!editing}
onClick={() => editing && setExtraNetworks(extraNetworks.filter((x) => x !== n))}>&times;</button>
</span>
))}
<input className={styles.portAddInput} disabled={!editing} placeholder="+ network" value={newNetwork}
onChange={(e) => setNewNetwork(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); const v = newNetwork.trim(); if (v && !extraNetworks.includes(v)) { setExtraNetworks([...extraNetworks, v]); setNewNetwork(''); } } }} />
</div>
<span className={styles.configHint}>Additional Docker networks to join (e.g., monitoring, prometheus)</span>
</div>
</div> </div>
</div> </div>
)} )}