1.0.32
This commit is contained in:
26 files changed
+1367
-765
No files matched your search
+129
-125
@@ -6,7 +6,6 @@
|
||||
*/
|
||||
|
||||
import { homedir } from 'node:os';
|
||||
import { mergeImageEnvVars } from './container-env-merge';
|
||||
import { existsSync, mkdirSync, rmSync, readdirSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import * as http from 'node:http';
|
||||
@@ -1299,6 +1298,11 @@ export interface CreateContainerOptions {
|
||||
restartPolicy?: string;
|
||||
restartMaxRetries?: number;
|
||||
networkMode?: string;
|
||||
/** Additional networks attached after creation via POST /networks/<name>/connect.
|
||||
* Must NOT include the primary (which lives in networkMode + EndpointsConfig). */
|
||||
additionalNetworks?: string[];
|
||||
/** @deprecated use networkMode + additionalNetworks. Kept only so old callers
|
||||
* (auto-update inspect path) keep working — see backward-compat block below. */
|
||||
networks?: string[];
|
||||
/** Network aliases for the primary network */
|
||||
networkAliases?: string[];
|
||||
@@ -1454,92 +1458,67 @@ export async function createContainer(options: CreateContainerOptions, envId?: n
|
||||
containerConfig.Volumes = options.volumes;
|
||||
}
|
||||
|
||||
if (options.networkMode) {
|
||||
containerConfig.HostConfig.NetworkMode = options.networkMode;
|
||||
|
||||
// Build endpoint config for primary network with aliases, static IP, and gateway priority
|
||||
const hasNetworkConfig = options.networkAliases?.length || options.networkIpv4Address || options.networkIpv6Address || options.networkGwPriority !== undefined;
|
||||
if (hasNetworkConfig) {
|
||||
const endpointConfig: any = {};
|
||||
|
||||
if (options.networkAliases && options.networkAliases.length > 0) {
|
||||
endpointConfig.Aliases = options.networkAliases;
|
||||
}
|
||||
|
||||
if (options.networkIpv4Address || options.networkIpv6Address) {
|
||||
endpointConfig.IPAMConfig = {};
|
||||
if (options.networkIpv4Address) {
|
||||
endpointConfig.IPAMConfig.IPv4Address = options.networkIpv4Address;
|
||||
}
|
||||
if (options.networkIpv6Address) {
|
||||
endpointConfig.IPAMConfig.IPv6Address = options.networkIpv6Address;
|
||||
}
|
||||
}
|
||||
|
||||
// Gateway priority (Docker Engine 28+)
|
||||
if (options.networkGwPriority !== undefined) {
|
||||
endpointConfig.GwPriority = options.networkGwPriority;
|
||||
}
|
||||
|
||||
containerConfig.NetworkingConfig = {
|
||||
EndpointsConfig: {
|
||||
[options.networkMode]: endpointConfig
|
||||
}
|
||||
};
|
||||
}
|
||||
// Backward-compat: callers (and the auto-update inspect path) may still pass
|
||||
// the legacy "networks" array which conflated primary + extras. Normalize to
|
||||
// the new model: anything that isn't the primary becomes an additional network.
|
||||
if (options.networks && !options.additionalNetworks) {
|
||||
const primary = options.networkMode || '';
|
||||
options.additionalNetworks = options.networks.filter(n => n !== primary);
|
||||
}
|
||||
|
||||
if (options.networks && options.networks.length > 0) {
|
||||
containerConfig.HostConfig.NetworkMode = options.networks[0];
|
||||
// Networking model:
|
||||
// - networkMode is the SINGLE source of truth for the primary network
|
||||
// - EndpointsConfig has exactly ONE entry, keyed by networkMode, carrying
|
||||
// the form's IPv4/IPv6/aliases/gwPriority
|
||||
// - additionalNetworks (extras only — NEVER includes the primary) are
|
||||
// attached after container creation via POST /networks/{name}/connect
|
||||
// - Shared modes (host / none / container:X / service:X) skip both
|
||||
// EndpointsConfig and additionalNetworks: Docker rejects them.
|
||||
const primaryMode = options.networkMode || '';
|
||||
const isSharedMode = primaryMode === 'host' || primaryMode === 'none' || primaryMode.startsWith('container:') || primaryMode.startsWith('service:');
|
||||
|
||||
// Build endpoint configs for all networks
|
||||
const endpointsConfig: Record<string, any> = {};
|
||||
if (primaryMode) {
|
||||
containerConfig.HostConfig.NetworkMode = primaryMode;
|
||||
}
|
||||
|
||||
for (const network of options.networks) {
|
||||
const isFirstNetwork = network === options.networks[0];
|
||||
const netCfg = options.networkConfigs?.[network];
|
||||
// Build the single EndpointsConfig entry for the primary (only for non-shared modes).
|
||||
if (primaryMode && !isSharedMode) {
|
||||
const endpointConfig: any = {};
|
||||
if (options.networkAliases?.length) {
|
||||
endpointConfig.Aliases = options.networkAliases;
|
||||
}
|
||||
if (options.networkIpv4Address || options.networkIpv6Address) {
|
||||
endpointConfig.IPAMConfig = {};
|
||||
if (options.networkIpv4Address) endpointConfig.IPAMConfig.IPv4Address = options.networkIpv4Address;
|
||||
if (options.networkIpv6Address) endpointConfig.IPAMConfig.IPv6Address = options.networkIpv6Address;
|
||||
}
|
||||
if (options.networkGwPriority !== undefined) {
|
||||
endpointConfig.GwPriority = options.networkGwPriority;
|
||||
}
|
||||
containerConfig.NetworkingConfig = {
|
||||
EndpointsConfig: {
|
||||
[primaryMode]: endpointConfig
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Extras attached after creation. Empty array for shared modes.
|
||||
const extraNetworks: { name: string; config: any }[] = [];
|
||||
if (!isSharedMode && options.additionalNetworks?.length) {
|
||||
for (const netName of options.additionalNetworks) {
|
||||
if (netName === primaryMode) continue; // primary lives in EndpointsConfig, not here
|
||||
const netCfg = options.networkConfigs?.[netName];
|
||||
const endpointConfig: any = {};
|
||||
|
||||
// Per-network config from networkConfigs (takes precedence)
|
||||
if (netCfg) {
|
||||
if (netCfg.aliases && netCfg.aliases.length > 0) {
|
||||
endpointConfig.Aliases = netCfg.aliases;
|
||||
}
|
||||
if (netCfg.aliases?.length) endpointConfig.Aliases = netCfg.aliases;
|
||||
if (netCfg.ipv4Address || netCfg.ipv6Address) {
|
||||
endpointConfig.IPAMConfig = {};
|
||||
if (netCfg.ipv4Address) {
|
||||
endpointConfig.IPAMConfig.IPv4Address = netCfg.ipv4Address;
|
||||
}
|
||||
if (netCfg.ipv6Address) {
|
||||
endpointConfig.IPAMConfig.IPv6Address = netCfg.ipv6Address;
|
||||
}
|
||||
}
|
||||
} else if (isFirstNetwork) {
|
||||
// Backward compat: apply flat fields to first network if no networkConfigs
|
||||
if (options.networkAliases && options.networkAliases.length > 0) {
|
||||
endpointConfig.Aliases = options.networkAliases;
|
||||
}
|
||||
if (options.networkIpv4Address || options.networkIpv6Address) {
|
||||
endpointConfig.IPAMConfig = {};
|
||||
if (options.networkIpv4Address) {
|
||||
endpointConfig.IPAMConfig.IPv4Address = options.networkIpv4Address;
|
||||
}
|
||||
if (options.networkIpv6Address) {
|
||||
endpointConfig.IPAMConfig.IPv6Address = options.networkIpv6Address;
|
||||
}
|
||||
}
|
||||
// Gateway priority (Docker Engine 28+)
|
||||
if (options.networkGwPriority !== undefined) {
|
||||
endpointConfig.GwPriority = options.networkGwPriority;
|
||||
if (netCfg.ipv4Address) endpointConfig.IPAMConfig.IPv4Address = netCfg.ipv4Address;
|
||||
if (netCfg.ipv6Address) endpointConfig.IPAMConfig.IPv6Address = netCfg.ipv6Address;
|
||||
}
|
||||
}
|
||||
|
||||
endpointsConfig[network] = endpointConfig;
|
||||
extraNetworks.push({ name: netName, config: endpointConfig });
|
||||
}
|
||||
|
||||
containerConfig.NetworkingConfig = {
|
||||
EndpointsConfig: endpointsConfig
|
||||
};
|
||||
}
|
||||
|
||||
if (options.privileged !== undefined) {
|
||||
@@ -1772,6 +1751,39 @@ export async function createContainer(options: CreateContainerOptions, envId?: n
|
||||
containerConfig.Domainname = options.domainname;
|
||||
}
|
||||
|
||||
// Shared network modes (host / container:X / service:X) inherit the
|
||||
// namespace's networking, so Docker rejects any field that would override it.
|
||||
// Strip them defensively — the merge in updateContainer carries values from
|
||||
// the old config that may not be valid for the new mode (e.g. switching from
|
||||
// bridge → container:foo with a leftover Hostname).
|
||||
// Mirrors the same logic in recreateContainerFromInspect.
|
||||
{
|
||||
const primary = containerConfig.HostConfig?.NetworkMode || '';
|
||||
const isHost = primary === 'host';
|
||||
const isContainer = primary.startsWith('container:');
|
||||
const isService = primary.startsWith('service:');
|
||||
if (isHost || isContainer || isService) {
|
||||
delete containerConfig.Hostname;
|
||||
delete containerConfig.Domainname;
|
||||
delete containerConfig.MacAddress;
|
||||
}
|
||||
if (isContainer || isService) {
|
||||
// container:X also shares ports / DNS / hosts with the target
|
||||
delete containerConfig.ExposedPorts;
|
||||
if (containerConfig.HostConfig) {
|
||||
delete containerConfig.HostConfig.PortBindings;
|
||||
delete containerConfig.HostConfig.PublishAllPorts;
|
||||
delete containerConfig.HostConfig.Dns;
|
||||
delete containerConfig.HostConfig.DnsOptions;
|
||||
delete containerConfig.HostConfig.DnsSearch;
|
||||
delete containerConfig.HostConfig.ExtraHosts;
|
||||
delete containerConfig.HostConfig.Links;
|
||||
}
|
||||
// NetworkingConfig is meaningless when sharing a namespace
|
||||
delete containerConfig.NetworkingConfig;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await dockerJsonRequest<{ Id: string }>(
|
||||
`/containers/create?name=${encodeURIComponent(options.name)}`,
|
||||
{
|
||||
@@ -1781,6 +1793,18 @@ export async function createContainer(options: CreateContainerOptions, envId?: n
|
||||
envId
|
||||
);
|
||||
|
||||
// Attach additional networks now that the container exists. Docker only allows
|
||||
// a single network at create time, so anything beyond the primary is connected here.
|
||||
for (const extra of extraNetworks) {
|
||||
try {
|
||||
await connectContainerToNetworkRaw(extra.name, result.Id, extra.config, envId);
|
||||
} catch (err: any) {
|
||||
console.error(`Failed to attach additional network "${extra.name}":`, err?.message || err);
|
||||
// Don't fail the whole create — primary network is already connected.
|
||||
// Caller (updateContainer rollback path) will surface this if needed.
|
||||
}
|
||||
}
|
||||
|
||||
return { id: result.Id, start: () => startContainer(result.Id, envId) };
|
||||
}
|
||||
|
||||
@@ -1901,50 +1925,6 @@ export async function recreateContainerFromInspect(
|
||||
HostConfig: hostConfig
|
||||
};
|
||||
|
||||
// 4a. Update image-embedded labels and env vars to match the new image.
|
||||
// Docker's create API uses exactly the labels/env you pass, ignoring the new image's
|
||||
// embedded values. We inspect both old and new images to distinguish image-origin
|
||||
// values from user-set values, then merge accordingly.
|
||||
try {
|
||||
const [oldImageInspect, newImageInspect] = await Promise.all([
|
||||
inspectImage(config.Image, envId),
|
||||
inspectImage(newImage, envId)
|
||||
]);
|
||||
|
||||
// Merge labels
|
||||
const oldImageLabels: Record<string, string> = (oldImageInspect as any)?.Config?.Labels || {};
|
||||
const newImageLabels: Record<string, string> = (newImageInspect as any)?.Config?.Labels || {};
|
||||
const containerLabels: Record<string, string> = createConfig.Labels || {};
|
||||
|
||||
const mergedLabels: Record<string, string> = {};
|
||||
|
||||
// Keep user-set labels (not present in old image)
|
||||
for (const [k, v] of Object.entries(containerLabels)) {
|
||||
if (!(k in oldImageLabels)) {
|
||||
mergedLabels[k] = v;
|
||||
}
|
||||
}
|
||||
|
||||
// Add all new image labels (overrides old image labels)
|
||||
for (const [k, v] of Object.entries(newImageLabels)) {
|
||||
mergedLabels[k] = v;
|
||||
}
|
||||
|
||||
createConfig.Labels = mergedLabels;
|
||||
log?.(`Updated image labels: ${Object.keys(newImageLabels).length} from new image, ${Object.keys(mergedLabels).length} total`);
|
||||
|
||||
// Merge env vars (same logic: image-baked vars get updated, user-set vars preserved)
|
||||
const oldImageEnv: string[] = (oldImageInspect as any)?.Config?.Env || [];
|
||||
const newImageEnv: string[] = (newImageInspect as any)?.Config?.Env || [];
|
||||
const containerEnv: string[] = createConfig.Env || [];
|
||||
|
||||
createConfig.Env = mergeImageEnvVars(containerEnv, oldImageEnv, newImageEnv);
|
||||
log?.(`Updated image env vars: ${newImageEnv.length} from new image, ${createConfig.Env.length} total`);
|
||||
} catch (e) {
|
||||
log?.(`Warning: could not update image labels/env: ${e}`);
|
||||
// Fall through with old values — non-fatal
|
||||
}
|
||||
|
||||
// Strip default MemorySwappiness — Podman + cgroupv2 rejects it.
|
||||
// Docker returns -1, Podman returns 0 when unset.
|
||||
const swappiness = createConfig.HostConfig?.MemorySwappiness;
|
||||
@@ -2424,20 +2404,44 @@ export async function updateContainer(id: string, options: Partial<CreateContain
|
||||
// Extract ALL existing container options
|
||||
const existingOptions = extractContainerOptions(oldContainerInfo);
|
||||
|
||||
// Merge user-provided options on top of existing options
|
||||
// User options take precedence, but we preserve everything not explicitly provided
|
||||
// Per-network fields (aliases, static IPs, MAC, gateway priority) are scoped to
|
||||
// a single network. When the user switches the primary network, the values
|
||||
// extracted from the old network must NOT follow the container — e.g. compose
|
||||
// service aliases from "anton" applied to the default bridge fail with
|
||||
// "invalid endpoint settings" because the default bridge doesn't accept aliases.
|
||||
if (options.networkMode && options.networkMode !== networkMode) {
|
||||
existingOptions.networkAliases = undefined;
|
||||
existingOptions.networkIpv4Address = undefined;
|
||||
existingOptions.networkIpv6Address = undefined;
|
||||
existingOptions.networkGwPriority = undefined;
|
||||
existingOptions.macAddress = undefined;
|
||||
}
|
||||
|
||||
// Merge user-provided options on top of existing options.
|
||||
// Semantics (#1119):
|
||||
// - key absent from `options` → preserve existingOptions[key]
|
||||
// - key present with explicit `null` → CLEAR the field (treat as undefined)
|
||||
// - key present with any other value → use the user's value
|
||||
//
|
||||
// Without the null-as-clear handling, clearing a Resources field in the UI
|
||||
// (which sends null) would merge over the existing value identically to a
|
||||
// preserved field — Memory limit, etc. would silently stick around.
|
||||
const userOptions: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(options)) {
|
||||
userOptions[k] = v === null ? undefined : v;
|
||||
}
|
||||
const mergedOptions: CreateContainerOptions = {
|
||||
...existingOptions,
|
||||
...options,
|
||||
...userOptions,
|
||||
// Replace labels, but preserve Docker internal labels (com.docker.*)
|
||||
labels: options.labels !== undefined
|
||||
labels: options.labels !== undefined && options.labels !== null
|
||||
? {
|
||||
...Object.fromEntries(
|
||||
Object.entries(existingOptions.labels || {}).filter(([k]) => k.startsWith('com.docker.'))
|
||||
),
|
||||
...options.labels
|
||||
}
|
||||
: existingOptions.labels
|
||||
: options.labels === null ? {} : existingOptions.labels
|
||||
};
|
||||
|
||||
// 1. Stop old container
|
||||
|
||||
Reference in new issue
Block a user