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
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
106 changes: 106 additions & 0 deletions Sources/Container-Compose/Commands/ComposeUp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,101 @@ 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."
)
}
}

if let mode = service.network_mode {
warnings.append(
"Note: Service '\(serviceName)' sets network_mode: \(mode). `container run` has no equivalent; the container will join the default network."
)
}

return warnings
}

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

runCommandArgs.append(
contentsOf: Self.hardeningRunArgs(for: service, environment: environmentVariables)
)
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
Loading