Skip to content
Open
94 changes: 93 additions & 1 deletion Sources/Container-Compose/Codable Structs/Service.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,41 @@ import Foundation


/// Represents a single service definition within the `services` section.
/// One `ulimits:` entry.
///
/// Compose accepts either a single value (`nofile: 65535`) or a soft/hard pair
/// (`nofile: {soft: 20000, hard: 40000}`). `container run --ulimit` takes
/// `<type>=<soft>[:<hard>]`, so both forms are normalised to that string here.
///
/// Decoding is deliberately throwing: an entry this cannot understand fails the
/// whole file rather than nilling the map, which would drop the sibling entries
/// with it and give no sign that anything was lost.
private struct UlimitValue: Decodable {
let flagValue: String

private enum CodingKeys: String, CodingKey {
case soft, hard
}

init(from decoder: any Decoder) throws {
if let single = try? decoder.singleValueContainer() {
if let intValue = try? single.decode(Int.self) {
flagValue = "\(intValue)"
return
}
if let stringValue = try? single.decode(String.self) {
flagValue = stringValue
return
}
}

let keyed = try decoder.container(keyedBy: CodingKeys.self)
let soft = try keyed.decode(Int.self, forKey: .soft)
let hard = try keyed.decode(Int.self, forKey: .hard)
flagValue = "\(soft):\(hard)"
}
}

public struct Service: Codable, Hashable {
/// Docker image name
public let image: String?
Expand Down Expand Up @@ -92,6 +127,30 @@ public struct Service: Codable, Hashable {
/// Mount container's root filesystem as read-only
public let read_only: Bool?

/// Linux capabilities to add, e.g. `NET_BIND_SERVICE`
public let cap_add: [String]?

/// Linux capabilities to drop, e.g. `ALL`
public let cap_drop: [String]?

/// Size of `/dev/shm`, e.g. `256m`
public let shm_size: String?

/// Compose `init:` — run an init process that reaps zombies.
/// Named `runInit` because `init` is a Swift keyword; the wire name is
/// restored by the `init` CodingKey below.
public let runInit: Bool?

/// Resource limits, e.g. `["nofile": "65535"]`
public let ulimits: [String: String]?

/// tmpfs mounts, Compose list form: `["/run:noexec,nosuid", "/tmp"]`
public let tmpfs: [String]?

/// Compose `network_mode`. Parsed so it can be reported; `container run`
/// has no equivalent, see `ComposeUp.unsupportedOptionWarnings`.
public let network_mode: String?

/// Working directory inside the container
public let working_dir: String?

Expand Down Expand Up @@ -133,7 +192,8 @@ public struct Service: Codable, Hashable {
enum CodingKeys: String, CodingKey {
case image, build, deploy, restart, healthcheck, volumes, environment, env_file, ports, command, depends_on, user,
container_name, labels, networks, hostname, entrypoint, privileged, read_only, working_dir, configs, secrets, stdin_open, tty, platform,
mem_limit, extra_hosts, profiles
mem_limit, extra_hosts, profiles, cap_add, cap_drop, shm_size, ulimits, tmpfs, network_mode
case runInit = "init"
}

/// Public memberwise initializer for testing
Expand All @@ -159,6 +219,13 @@ public struct Service: Codable, Hashable {
entrypoint: [String]? = nil,
privileged: Bool? = nil,
read_only: Bool? = nil,
cap_add: [String]? = nil,
cap_drop: [String]? = nil,
shm_size: String? = nil,
runInit: Bool? = nil,
ulimits: [String: String]? = nil,
tmpfs: [String]? = nil,
network_mode: String? = nil,
working_dir: String? = nil,
platform: String? = nil,
configs: [ServiceConfig]? = nil,
Expand Down Expand Up @@ -191,6 +258,13 @@ public struct Service: Codable, Hashable {
self.entrypoint = entrypoint
self.privileged = privileged
self.read_only = read_only
self.cap_add = cap_add
self.cap_drop = cap_drop
self.shm_size = shm_size
self.runInit = runInit
self.ulimits = ulimits
self.tmpfs = tmpfs
self.network_mode = network_mode
self.working_dir = working_dir
self.platform = platform
self.configs = configs
Expand Down Expand Up @@ -315,6 +389,24 @@ public struct Service: Codable, Hashable {

privileged = try container.decodeIfPresent(Bool.self, forKey: .privileged)
read_only = try container.decodeIfPresent(Bool.self, forKey: .read_only)
cap_add = try container.decodeIfPresent([String].self, forKey: .cap_add)
cap_drop = try container.decodeIfPresent([String].self, forKey: .cap_drop)
shm_size = try container.decodeIfPresent(String.self, forKey: .shm_size)
runInit = try container.decodeIfPresent(Bool.self, forKey: .runInit)
ulimits = try container
.decodeIfPresent([String: UlimitValue].self, forKey: .ulimits)?
.mapValues(\.flagValue)

// List form is the common one; a bare string is also legal. Anything else
// throws rather than silently becoming nil.
if !container.contains(.tmpfs) {
tmpfs = nil
} else if let list = try? container.decode([String].self, forKey: .tmpfs) {
tmpfs = list
} else {
tmpfs = [try container.decode(String.self, forKey: .tmpfs)]
}
network_mode = try container.decodeIfPresent(String.self, forKey: .network_mode)
working_dir = try container.decodeIfPresent(String.self, forKey: .working_dir)
configs = try container.decodeIfPresent([ServiceConfig].self, forKey: .configs)
secrets = try container.decodeIfPresent([ServiceSecret].self, forKey: .secrets)
Expand Down
127 changes: 127 additions & 0 deletions Sources/Container-Compose/Commands/ComposeUp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,119 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
return (value, false)
}

/// Maps the container-hardening compose keys to `container run` flags.
///
/// Extracted as a pure function so the mapping is directly testable: the
/// surrounding argument assembly has no test seam, which is how
/// `healthcheck.timeout` stayed parsed-but-unapplied.
///
/// Capabilities are emitted drop-then-add for readability only. `container`
/// collects `--cap-add` and `--cap-drop` into two separate arrays and then
/// computes the effective set (drop-ALL clears the base, adds are applied,
/// individual drops are removed), so the order they appear in on the command
/// line carries no meaning.
static func hardeningRunArgs(for service: Service, environment: [String: String] = [:]) -> [String] {
var args: [String] = []

for capability in service.cap_drop ?? [] {
args.append(contentsOf: ["--cap-drop", resolveVariable(capability, with: environment)])
}
for capability in service.cap_add ?? [] {
args.append(contentsOf: ["--cap-add", resolveVariable(capability, with: environment)])
}

if let shmSize = service.shm_size {
args.append(contentsOf: ["--shm-size", resolveVariable(shmSize, with: environment)])
}
if service.runInit == true {
args.append("--init")
}
for name in (service.ulimits ?? [:]).keys.sorted() {
guard let value = service.ulimits?[name] else { continue }
args.append(contentsOf: ["--ulimit", "\(name)=\(resolveVariable(value, with: environment))"])
}

for entry in service.tmpfs ?? [] {
let (target, options) = Self.splitTmpfsEntry(resolveVariable(entry, with: environment))
var spec = "type=tmpfs,target=\(target)"
for option in options where option.hasPrefix("mode=") || option.hasPrefix("size=") {
spec += ",\(option)"
}
args.append(contentsOf: ["--mount", spec])
}

return args
}

/// Splits a Compose tmpfs entry into its target path and its option list.
static func splitTmpfsEntry(_ entry: String) -> (target: String, options: [String]) {
let parts = entry.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)
let target = String(parts[0]).trimmingCharacters(in: .whitespaces)
guard parts.count == 2 else { return (target, []) }
// `/run:noexec, mode=0755` is legal Compose; without the trim the second
// option would not match its prefix and would be dropped as unsupported.
let options = parts[1]
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
return (target, options)
}

/// Compose options this tool parses but `container run` cannot express.
///
/// Returned rather than printed so the mapping stays testable, and reported
/// rather than dropped silently — a silent drop is the failure mode this
/// change set exists to remove.
static func unsupportedOptionWarnings(
for service: Service,
serviceName: String,
environment: [String: String] = [:]
) -> [String] {
var warnings: [String] = []

for entry in service.tmpfs ?? [] {
let (target, options) = Self.splitTmpfsEntry(resolveVariable(entry, with: environment))
let dropped = options.filter { !$0.hasPrefix("mode=") && !$0.hasPrefix("size=") }
guard !dropped.isEmpty else { continue }

warnings.append(
"Note: Service '\(serviceName)' tmpfs '\(target)': `container run` accepts only target, mode and size; dropped \(dropped.joined(separator: ","))."
)

if dropped.contains(where: { $0.hasPrefix("uid=") || $0.hasPrefix("gid=") }) {
warnings.append(
"Warning: Service '\(serviceName)' tmpfs '\(target)' requested uid/gid ownership, which `container run` cannot express. The mount will be owned by root, so a non-root container cannot write to it unless the mode is world-writable."
)
}
}

// `none` is rejected outright rather than warned about, so it is absent
// here; every other value still only produces a note.
if let mode = service.network_mode, Self.rejectedNetworkMode(mode) == nil {
warnings.append(
"Note: Service '\(serviceName)' sets network_mode: \(mode). `container run` has no equivalent; the container will join the default network."
)
}

return warnings
}

/// The `network_mode` values that must stop the run instead of being reported.
///
/// Only `none` qualifies. Every unsupported mode is wrong, but the rest fail
/// as "does not behave as configured" — the container gets the default network
/// where the file asked for the host's, or for a peer's. `none` fails in the
/// other direction: the file asks for no networking at all and the container
/// would come up connected, which is less isolation than was requested and is
/// not something to discover from a note in the log.
///
/// Returns the normalised mode when it must be rejected, `nil` otherwise.
static func rejectedNetworkMode(_ mode: String?) -> String? {
guard let normalised = mode?.trimmingCharacters(in: .whitespaces).lowercased(),
!normalised.isEmpty else { return nil }
return normalised == "none" ? normalised : nil
}

static func validateStoppedServiceExitCode(_ exitCode: Int32, serviceName: String) throws {
guard exitCode == 0 else {
throw ComposeError.containerRunFailed(serviceName, exitCode)
Expand Down Expand Up @@ -1070,6 +1183,20 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
runCommandArgs.append("--read-only")
}

runCommandArgs.append(
contentsOf: Self.hardeningRunArgs(for: service, environment: environmentVariables)
)
if let rejected = Self.rejectedNetworkMode(service.network_mode) {
throw ComposeError.unsupportedNetworkMode(serviceName, rejected)
}
for warning in Self.unsupportedOptionWarnings(
for: service,
serviceName: serviceName,
environment: environmentVariables
) {
print(warning)
}

// Add resource limits.
// `mem_limit` is the top-level shorthand; `deploy.resources.limits.memory` is
// the structured form. Both map to `container run --memory`. `mem_limit` takes
Expand Down
6 changes: 6 additions & 0 deletions Sources/Container-Compose/Errors.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ public enum ComposeError: Error, LocalizedError {
case healthcheckUnavailable(String)
case healthcheckFailed(String)
case noSuchService(String)
case unsupportedNetworkMode(String, String)

public var errorDescription: String? {
switch self {
Expand All @@ -67,6 +68,11 @@ public enum ComposeError: Error, LocalizedError {
return "Service '\(service)' failed its healthcheck."
case .noSuchService(let name):
return "no such service: \(name)"
case .unsupportedNetworkMode(let service, let mode):
return "Service '\(service)' sets network_mode: \(mode), which `container run` "
+ "cannot express. Starting it anyway would attach the container to the default "
+ "network — more connectivity than the compose file asks for, not less. Remove "
+ "the key, or run this service under a runtime that supports it."
}
}
}
Expand Down
Loading