Skip to content
Merged
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
47 changes: 46 additions & 1 deletion Sources/Container-Compose/Commands/ComposeUp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,46 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
])) != nil
}

/// Parses a Compose memory string (e.g. `"128m"`, `"2g"`, `"512"`) into bytes.
/// Units are binary (1k = 1024), matching how Apple Container interprets
/// `--memory` (`128m` → 134217728 = 128 MiB). Returns `nil` when the value
/// can't be parsed, so callers can fall back to passing it through verbatim.
static func parseMemoryToBytes(_ value: String) -> Int64? {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !trimmed.isEmpty else { return nil }

// Split the leading number from the unit suffix.
guard let split = trimmed.firstIndex(where: { !$0.isNumber && $0 != "." }) else {
// Bare number → bytes.
return Int64(trimmed)
}
let numberPart = String(trimmed[..<split])
let unit = String(trimmed[split...]).filter { $0.isLetter }
guard let amount = Double(numberPart) else { return nil }

let multiplier: Int64
switch unit {
case "", "b": multiplier = 1
case "k", "kb", "kib": multiplier = 1_024
case "m", "mb", "mib": multiplier = 1_024 * 1_024
case "g", "gb", "gib": multiplier = 1_024 * 1_024 * 1_024
default: return nil
}
return Int64(amount * Double(multiplier))
}

/// Apple Container enforces a 200 MiB minimum container memory size and rejects
/// anything smaller with a confusing daemon error. Clamp sub-minimum values up
/// to 200 MiB so common Docker values like `mem_limit: 128m` keep working.
/// Returns the value to pass to `--memory` and whether it was raised.
static func clampMemoryLimit(_ value: String) -> (value: String, clamped: Bool) {
let minimumBytes: Int64 = 200 * 1_024 * 1_024
if let bytes = parseMemoryToBytes(value), bytes < minimumBytes {
return ("200m", true)
}
return (value, false)
}

static func validateStoppedServiceExitCode(_ exitCode: Int32, serviceName: String) throws {
guard exitCode == 0 else {
throw ComposeError.containerRunFailed(serviceName, exitCode)
Expand Down Expand Up @@ -1049,7 +1089,12 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
}
let effectiveMemoryLimit = service.mem_limit ?? service.deploy?.resources?.limits?.memory
if let memory = effectiveMemoryLimit {
runCommandArgs.append(contentsOf: ["--memory", memory])
let resolved = resolveVariable(memory, with: environmentVariables)
let (memoryArg, didClamp) = Self.clampMemoryLimit(resolved)
if didClamp {
print("Note: Service '\(serviceName)' mem_limit '\(resolved)' is below Apple Container's 200 MiB minimum; clamping to \(memoryArg).")
}
runCommandArgs.append(contentsOf: ["--memory", memoryArg])
}

// Handle service-level configs (note: still only parsing/logging, not attaching)
Expand Down
62 changes: 62 additions & 0 deletions Tests/Container-Compose-StaticTests/MemLimitExtraHostsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -150,4 +150,66 @@ struct MemLimitExtraHostsTests {
let result = ComposeUp.resolveHostGatewayIP()
#expect(!result.isEmpty)
}

// MARK: - memory parsing & clamping

@Test("parseMemoryToBytes handles byte/k/m/g units (binary)")
func parseMemoryUnits() {
let kb: Int64 = 1_024
let mb: Int64 = 1_024 * 1_024
let gb: Int64 = 1_024 * 1_024 * 1_024
#expect(ComposeUp.parseMemoryToBytes("128m") == 128 * mb)
#expect(ComposeUp.parseMemoryToBytes("2g") == 2 * gb)
#expect(ComposeUp.parseMemoryToBytes("512k") == 512 * kb)
#expect(ComposeUp.parseMemoryToBytes("1024") == kb)
#expect(ComposeUp.parseMemoryToBytes("1gb") == gb)
}

@Test("parseMemoryToBytes is case-insensitive and tolerates whitespace")
func parseMemoryCaseInsensitive() {
let mb: Int64 = 1_024 * 1_024
let gb: Int64 = 1_024 * 1_024 * 1_024
#expect(ComposeUp.parseMemoryToBytes(" 256M ") == 256 * mb)
#expect(ComposeUp.parseMemoryToBytes("1GiB") == gb)
}

@Test("parseMemoryToBytes returns nil for garbage")
func parseMemoryGarbage() {
#expect(ComposeUp.parseMemoryToBytes("abc") == nil)
#expect(ComposeUp.parseMemoryToBytes("") == nil)
#expect(ComposeUp.parseMemoryToBytes("12x") == nil)
}

@Test("clampMemoryLimit raises sub-200m values to 200m")
func clampRaisesSmallValues() {
let small = ComposeUp.clampMemoryLimit("128m")
#expect(small.value == "200m")
#expect(small.clamped == true)

let bare = ComposeUp.clampMemoryLimit("134217728") // 128 MiB in bytes
#expect(bare.value == "200m")
#expect(bare.clamped == true)
}

@Test("clampMemoryLimit leaves >=200m values untouched")
func clampLeavesLargeValues() {
let at = ComposeUp.clampMemoryLimit("200m")
#expect(at.value == "200m")
#expect(at.clamped == false)

let over = ComposeUp.clampMemoryLimit("512m")
#expect(over.value == "512m")
#expect(over.clamped == false)

let gb = ComposeUp.clampMemoryLimit("1g")
#expect(gb.value == "1g")
#expect(gb.clamped == false)
}

@Test("clampMemoryLimit passes unparseable values through verbatim")
func clampPassesThroughGarbage() {
let garbage = ComposeUp.clampMemoryLimit("${UNSET_VAR}")
#expect(garbage.value == "${UNSET_VAR}")
#expect(garbage.clamped == false)
}
}
Loading