diff --git a/Sources/Container-Compose/Commands/ComposeUp.swift b/Sources/Container-Compose/Commands/ComposeUp.swift index 719ff21..45a8e5f 100644 --- a/Sources/Container-Compose/Commands/ComposeUp.swift +++ b/Sources/Container-Compose/Commands/ComposeUp.swift @@ -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[.. (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) @@ -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) diff --git a/Tests/Container-Compose-StaticTests/MemLimitExtraHostsTests.swift b/Tests/Container-Compose-StaticTests/MemLimitExtraHostsTests.swift index b09d812..4ed7d04 100644 --- a/Tests/Container-Compose-StaticTests/MemLimitExtraHostsTests.swift +++ b/Tests/Container-Compose-StaticTests/MemLimitExtraHostsTests.swift @@ -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) + } }