Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 129 additions & 29 deletions Sources/Container-Compose/Commands/ComposeUp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,107 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
return (value, false)
}

/// The image reference a service is actually pulled and run from.
///
/// Interpolated: pinning an image per environment with
/// `image: repo/name:${TAG:-1.0}` is the common case, and container rejects
/// the raw form with "invalid format for image reference".
///
/// Pure so the mapping is testable; the surrounding assembly has no seam.
static func resolvedImageReference(_ image: String, environment: [String: String]) -> String {
resolveVariable(image, with: environment)
}

/// The `--label` arguments for a service, including the two Compose adds.
///
/// The compose labels are set first so the project/service labels take
/// precedence over a user value for the same key; keys are sorted for a
/// deterministic argv.
static func labelRunArgs(
for service: Service,
serviceName: String,
projectName: String,
environment: [String: String]
) -> [String] {
var labels = service.labels ?? [:]
labels["com.docker.compose.project"] = projectName
labels["com.docker.compose.service"] = serviceName

var args: [String] = []
for key in labels.keys.sorted() {
let value = resolveVariable(labels[key] ?? "", with: environment)
args.append(contentsOf: ["--label", "\(key)=\(value)"])
}
return args
}

/// The name a top-level network is actually created under.
///
/// Interpolated for the same reason the service-level network reference is:
/// a compose file that selects its ingress network per environment writes
/// `name: ${INGRESS_NET:-local}`, and passing that through raw makes
/// `container network create` reject it with "invalid network name".
///
/// Pure so the mapping is testable; `setupNetwork` itself has no test seam.
static func resolvedNetworkName(
key networkKey: String,
config: Network?,
environment: [String: String]
) -> String {
resolveVariable(config?.name ?? networkKey, with: environment)
}

/// Every `--network` argument a service contributes, plus the warnings the
/// caller should print.
///
/// Pure so the *call site* is covered: a test that calls
/// `resolvedNetworkName` directly passes whether or not `configService`
/// routes through it, which is exactly how the raw-`.name` connect bug
/// survived. Reverting this body to `networks?[key]??.name` turns the
/// document-level test red.
static func networkRunArgs(
for service: Service,
dockerCompose: DockerCompose,
serviceName: String,
environment: [String: String],
supportsAliases: Bool = networkAliasesSupported
) -> (args: [String], warnings: [String]) {
var args: [String] = []
var warnings: [String] = []
for network in service.networks ?? [] {
let networkToConnect = resolvedNetworkName(
key: network,
config: dockerCompose.networks?[network] ?? nil,
environment: environment)
let translation = networkRunArg(
network: networkToConnect,
aliases: service.networkConfigurations?[network]?.aliases ?? [],
serviceName: serviceName,
environmentVariables: environment,
supportsAliases: supportsAliases)
args.append("--network")
args.append(translation.arg)
if let warning = translation.warning { warnings.append(warning) }
}
return (args, warnings)
}

/// The network whose gateway `host-gateway` resolves to: a service's first
/// network, under the name it was actually created with.
///
/// Pure for the same reason as `networkRunArgs`: this feeds
/// `container network inspect`, and a miss there only warns before falling
/// back to the host's LAN default route, so getting it wrong is silent.
static func gatewayNetworkName(
for service: Service,
dockerCompose: DockerCompose,
environment: [String: String]
) -> String {
guard let key = service.networks?.first else { return "default" }
return resolvedNetworkName(
key: key, config: dockerCompose.networks?[key] ?? nil, environment: environment)
}

static func validateStoppedServiceExitCode(_ exitCode: Int32, serviceName: String) throws {
guard exitCode == 0 else {
throw ComposeError.containerRunFailed(serviceName, exitCode)
Expand Down Expand Up @@ -749,7 +850,11 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
}

private func setupNetwork(name networkName: String, config networkConfig: Network?) async throws {
let actualNetworkName = networkConfig?.name ?? networkName // Use explicit name or key as name
let actualNetworkName = Self.resolvedNetworkName(
key: networkName,
config: networkConfig,
environment: environmentVariables
)

if let externalNetwork = networkConfig?.external, externalNetwork.isExternal {
print("Info: Network '\(networkName)' is declared as external.")
Expand Down Expand Up @@ -821,10 +926,13 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
if let buildConfig = service.build {
imageToRun = try await buildService(buildConfig, for: service, serviceName: serviceName)
} else if let img = service.image {
// Use specified image if no build config
// Pull image if necessary
try await pullImage(img, platform: service.platform)
imageToRun = img
// Use specified image if no build config.
// Interpolated: pinning an image per environment with
// `image: repo/name:${TAG:-1.0}` is the common case, and container
// rejects the raw form with "invalid format for image reference".
let resolvedImage = Self.resolvedImageReference(img, environment: environmentVariables)
try await pullImage(resolvedImage, platform: service.platform)
imageToRun = resolvedImage
} else {
// Should not happen due to Service init validation, but as a fallback
throw ComposeError.imageNotFound(serviceName)
Expand Down Expand Up @@ -880,12 +988,12 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
// mis-groups unrelated containers that merely share a prefix). The compose labels are
// set last so they take precedence over a user value for the same key; keys are sorted
// for a deterministic `container run` argv.
var labels = service.labels ?? [:]
labels["com.docker.compose.project"] = projectName
labels["com.docker.compose.service"] = serviceName
for key in labels.keys.sorted() {
runCommandArgs.append(contentsOf: ["--label", "\(key)=\(labels[key] ?? "")"])
}
runCommandArgs.append(contentsOf: Self.labelRunArgs(
for: service,
serviceName: serviceName,
projectName: projectName,
environment: environmentVariables
))

// REMOVED: Restart policy is not supported by `container run`
// if let restart = service.restart {
Expand Down Expand Up @@ -956,22 +1064,11 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {

// Connect to specified networks
if let serviceNetworks = service.networks {
for network in serviceNetworks {
let resolvedNetwork = resolveVariable(network, with: environmentVariables)
// Use the explicit network name from top-level definition if available, otherwise resolved name
let networkToConnect = dockerCompose.networks?[network]??.name ?? resolvedNetwork
runCommandArgs.append("--network")
let networkTranslation = Self.networkRunArg(
network: networkToConnect,
aliases: service.networkConfigurations?[network]?.aliases ?? [],
serviceName: serviceName,
environmentVariables: environmentVariables
)
runCommandArgs.append(networkTranslation.arg)
if let warning = networkTranslation.warning {
print(warning)
}
}
let networkArgs = Self.networkRunArgs(
for: service, dockerCompose: dockerCompose,
serviceName: serviceName, environment: environmentVariables)
runCommandArgs.append(contentsOf: networkArgs.args)
for warning in networkArgs.warnings { print(warning) }
print(
"Info: Service '\(serviceName)' is configured to connect to networks: \(serviceNetworks.joined(separator: ", ")) ascertained from networks attribute in \(composePath)."
)
Expand Down Expand Up @@ -1020,7 +1117,8 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
// empty, silently producing "--add-host foo:" (now "foo:" in /etc/hosts).
let resolvedEntries = extraHosts.map { resolveVariable($0, with: environmentVariables) }
let needsGateway = resolvedEntries.contains { $0.hasSuffix(":host-gateway") }
let resolvedNetworkName = service.networks?.first.map { resolveVariable($0, with: environmentVariables) } ?? "default"
let resolvedNetworkName = Self.gatewayNetworkName(
for: service, dockerCompose: dockerCompose, environment: environmentVariables)
let hostGatewayIP = needsGateway ? Self.resolveHostGatewayIP(networkName: resolvedNetworkName) : ""

var hostsFileLines = ["127.0.0.1 localhost", "::1 localhost"]
Expand Down Expand Up @@ -1319,7 +1417,9 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
/// - Returns: Image Name (`String`)
private func buildService(_ buildConfig: Build, for service: Service, serviceName: String) async throws -> String {
// Determine image tag for built image
let imageToRun = service.image ?? "\(serviceName):latest"
let imageToRun = service.image
.map { Self.resolvedImageReference($0, environment: environmentVariables) }
?? "\(serviceName):latest"
let imageList = try await ClientImage.list()
if !rebuild, imageList.contains(where: { $0.description.reference.components(separatedBy: "/").last == imageToRun }) {
return imageToRun
Expand Down
Loading