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
44 changes: 42 additions & 2 deletions Sources/Container-Compose/Commands/ComposeUp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,42 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
/// 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.
/// The CPU count the image builder is given for a service.
///
/// Separate from `clampCPULimit` because it also carries the default for a
/// service that declares no limit at all.
///
/// Not `Int64(limit) ?? 2`: Int64 cannot parse "0.5", so that form silently
/// handed a service asking for half a CPU the fallback of two - four times
/// its request, with nothing printed. The run path rejected the same input
/// loudly, which is the only reason it was noticed.
///
/// Pure so the mapping is testable; buildService has no seam.
static func builderCPUCount(for service: Service) -> Int64 {
guard let declared = service.deploy?.resources?.limits?.cpus else { return 2 }
return Int64(clampCPULimit(declared).value) ?? 2
}

/// `container run --cpus` takes a whole number, while Compose allows a
/// fraction. Any mapping is lossy, so this rounds up to the smallest
/// expressible limit rather than down: 0.5 would otherwise become 0, which
/// container rejects, and a slightly loose cap fails less destructively than
/// none at all. Callers report the change, as with the memory floor.
/// `Int(Double)` traps rather than saturating, so the range is checked before
/// the conversion: `cpus: 1e400` parses as infinity, `cpus: nan` as NaN, and
/// `cpus: 1e20` is finite but larger than `Int` can hold. All three crashed
/// here. A value this tool cannot express is passed through untouched so the
/// runtime rejects it by name, which is a better outcome than a fatal error
/// with no service in it.
static func clampCPULimit(_ value: String) -> (value: String, clamped: Bool) {
guard let cpus = Double(value), cpus.isFinite else { return (value, false) }
let rounded = cpus.rounded(.up)
guard rounded >= Double(Int.min), rounded <= Double(Int.max) else { return (value, false) }
let whole = max(1, Int(rounded))
guard Double(whole) != cpus else { return (value, false) }
return (String(whole), true)
}

static func clampMemoryLimit(_ value: String) -> (value: String, clamped: Bool) {
let minimumBytes: Int64 = 200 * 1_024 * 1_024
if let bytes = parseMemoryToBytes(value), bytes < minimumBytes {
Expand Down Expand Up @@ -1075,7 +1111,11 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
// the structured form. Both map to `container run --memory`. `mem_limit` takes
// precedence when both are set, matching Docker Compose CLI behaviour.
if let cpus = service.deploy?.resources?.limits?.cpus {
runCommandArgs.append(contentsOf: ["--cpus", cpus])
let (cpuArg, didClamp) = Self.clampCPULimit(cpus)
if didClamp {
print("Note: Service '\(serviceName)' cpus '\(cpus)' is not a whole number; Apple Container needs one, rounding up to \(cpuArg).")
}
runCommandArgs.append(contentsOf: ["--cpus", cpuArg])
}
let effectiveMemoryLimit = service.mem_limit ?? service.deploy?.resources?.limits?.memory
if let memory = effectiveMemoryLimit {
Expand Down Expand Up @@ -1354,7 +1394,7 @@ public struct ComposeUp: AsyncParsableCommand, @unchecked Sendable {
commands.append(contentsOf: ["--tag", imageToRun])

// Add CPU & Memory
let cpuCount = Int64(service.deploy?.resources?.limits?.cpus ?? "2") ?? 2
let cpuCount = Self.builderCPUCount(for: service)
let memoryLimit = service.deploy?.resources?.limits?.memory ?? "2048MB"
commands.append(contentsOf: ["--cpus", "\(cpuCount)"])
commands.append(contentsOf: ["--memory", memoryLimit])
Expand Down
157 changes: 157 additions & 0 deletions Tests/Container-Compose-StaticTests/CPULimitClampTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 Morris Richman and the Container-Compose project authors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//

import Testing
import Foundation
@testable import Yams
@testable import ContainerComposeCore

/// Companion to the memory floor in `clampMemoryLimit`: `container run --cpus`
/// takes a whole number while Compose allows a fraction, so the value has to be
/// adjusted and the adjustment reported.
@Suite("CPU Limit Clamping")
struct CPULimitClampTests {

@Test("a fraction rounds up to the smallest expressible limit")
func fractionRoundsUp() {
let (value, clamped) = ComposeUp.clampCPULimit("0.5")
#expect(value == "1")
#expect(clamped)
}

@Test("rounding is up, never down to zero")
func neverZero() {
// 0.1 down would be 0, which `container run` rejects outright.
#expect(ComposeUp.clampCPULimit("0.1").value == "1")
#expect(ComposeUp.clampCPULimit("0").value == "1")
for (input, expected) in [("1.5", "2"), ("2.5", "3"), ("3.01", "4")] {
#expect(ComposeUp.clampCPULimit(input).value == expected, "\(input)")
}
}

@Test("whole numbers are passed through untouched and unreported")
func wholeNumbersUnchanged() {
for input in ["1", "2", "4", "16"] {
let (value, clamped) = ComposeUp.clampCPULimit(input)
#expect(value == input, "\(input)")
#expect(clamped == false, "\(input) should not be reported as clamped")
}
}

@Test("an unparseable value is left alone rather than guessed at")
func unparseablePassesThrough() {
let (value, clamped) = ComposeUp.clampCPULimit("not-a-number")
#expect(value == "not-a-number")
#expect(clamped == false)
}

/// Regression: the build path read the limit with `Int64(...) ?? 2`, so a
/// fractional request could not be parsed and silently became 2 - four times
/// what a service asking for 0.5 wanted, with no message. The run path failed
/// loudly instead, which is how this was noticed at all.
/// An earlier version of this test called clampCPULimit and asserted on its
/// result, which passes whether or not the build path uses it - confirmed by
/// mutation: reverting that line to `Int64(...) ?? 2` left the test green.
/// It now goes through the build path's own function.
@Test("a fractional limit does not become the fallback on the build path")
func fractionalDoesNotBecomeFallback() throws {
let svc = try YAMLDecoder().decode(Service.self, from: """
image: alpine
deploy:
resources:
limits:
cpus: "0.5"
""")
let count = ComposeUp.builderCPUCount(for: svc)
#expect(count == 1)
#expect(count != 2, "0.5 must not silently become the fallback of 2")
}

@Test("a service with no cpu limit gets the builder default")
func noLimitUsesDefault() throws {
let svc = try YAMLDecoder().decode(Service.self, from: "image: alpine")
#expect(ComposeUp.builderCPUCount(for: svc) == 2)
}

@Test("a whole cpu limit reaches the builder unchanged")
func wholeLimitReachesBuilder() throws {
let svc = try YAMLDecoder().decode(Service.self, from: """
image: alpine
deploy:
resources:
limits:
cpus: "4"
""")
#expect(ComposeUp.builderCPUCount(for: svc) == 4)
}
}

/// `Int(Double)` traps instead of saturating, so every value that reaches the
/// conversion has to be range-checked first. Each case below crashed the process
/// before that check existed.
@Suite("CPU Limit Range Validation")
struct CPULimitRangeValidationTests {

@Test("an infinite cpus value is passed through rather than crashing")
func infiniteIsPassedThrough() {
let (value, clamped) = ComposeUp.clampCPULimit("1e400")
#expect(value == "1e400")
#expect(!clamped)
}

@Test("a NaN cpus value is passed through rather than crashing")
func nanIsPassedThrough() {
let (value, clamped) = ComposeUp.clampCPULimit("nan")
#expect(value == "nan")
#expect(!clamped)
}

/// Finite, but larger than `Int` can hold — the case `isFinite` alone misses.
@Test("a finite value beyond Int's range is passed through rather than crashing")
func beyondIntRangeIsPassedThrough() {
let (value, clamped) = ComposeUp.clampCPULimit("1e20")
#expect(value == "1e20")
#expect(!clamped)
}

@Test("a non-numeric cpus value is still passed through")
func nonNumericIsPassedThrough() {
let (value, clamped) = ComposeUp.clampCPULimit("plenty")
#expect(value == "plenty")
#expect(!clamped)
}

/// The ordinary path is unchanged by the guard.
@Test("ordinary fractions still round up, whole numbers are left alone")
func ordinaryValuesUnchanged() {
#expect(ComposeUp.clampCPULimit("0.5") == ("1", true))
#expect(ComposeUp.clampCPULimit("2.5") == ("3", true))
#expect(ComposeUp.clampCPULimit("4") == ("4", false))
}

/// The builder default survives a value the clamp refuses to touch.
@Test("the builder falls back to its default for an unrepresentable value")
func builderFallsBack() throws {
let svc = try YAMLDecoder().decode(Service.self, from: """
image: alpine
deploy:
resources:
limits:
cpus: "1e400"
""")
#expect(ComposeUp.builderCPUCount(for: svc) == 2)
}
}