diff --git a/Sources/ContainerClient/Flags.swift b/Sources/ContainerClient/Flags.swift index 1364157f..a2b081ca 100644 --- a/Sources/ContainerClient/Flags.swift +++ b/Sources/ContainerClient/Flags.swift @@ -65,7 +65,7 @@ public struct Flags { @Option( name: [.customLong("memory"), .customShort("m")], help: - "Amount of memory in bytes, kilobytes (K), megabytes (M), or gigabytes (G) for the container, with MB granularity (for example, 1024K will result in 1MB being allocated for the container)" + "Amount of memory (1MiByte granularity), with optional K, M, G, T, or P suffix" ) public var memory: String? } diff --git a/Sources/ContainerClient/Measurement+Parse.swift b/Sources/ContainerClient/Measurement+Parse.swift index e28bd902..8333a765 100644 --- a/Sources/ContainerClient/Measurement+Parse.swift +++ b/Sources/ContainerClient/Measurement+Parse.swift @@ -16,7 +16,7 @@ import Foundation -private let units: [Character: UnitInformationStorage] = [ +private let binaryUnits: [Character: UnitInformationStorage] = [ "b": .bytes, "k": .kibibytes, "m": .mebibytes, @@ -40,20 +40,22 @@ extension Measurement { } } - /// parse the provided string into a measurement that is able to be converted to various byte sizes + /// parseMemory the provided string into a measurement that is able to be converted to various byte sizes using binary exponents public static func parse(parsing: String) throws -> Measurement { - let check = "01234567890. " - let i = parsing.lastIndex { - check.contains($0) - } - guard let i else { + let check = "01234567890." + let trimmed = parsing.trimmingCharacters(in: .whitespaces).lowercased() + guard !trimmed.isEmpty else { throw ParseError.invalidSize } - let after = parsing.index(after: i) - let rawValue = parsing[.. Character { let s = unit.dropFirst() + let unitSymbol = unit.first ?? "b" + switch s { - case "", "b", "ib": - return unit.first ?? "b" + case "", "ib", "b": + return unitSymbol default: throw ParseError.invalidSymbol(unit) } diff --git a/Sources/ContainerCommands/BuildCommand.swift b/Sources/ContainerCommands/BuildCommand.swift index ab6bcf66..ec44fdc9 100644 --- a/Sources/ContainerCommands/BuildCommand.swift +++ b/Sources/ContainerCommands/BuildCommand.swift @@ -44,7 +44,7 @@ extension Application { @Option( name: [.customLong("memory"), .customShort("m")], help: - "Amount of memory in bytes, kilobytes (K), megabytes (M), or gigabytes (G) for the container, with MB granularity (for example, 1024K will result in 1MB being allocated for the container)" + "Amount of builder container memory (1MiByte granularity), with optional K, M, G, T, or P suffix" ) var memory: String = "2048MB" diff --git a/Tests/ContainerClientTests/Measurement+ParseTests.swift b/Tests/ContainerClientTests/Measurement+ParseTests.swift new file mode 100644 index 00000000..d34318e4 --- /dev/null +++ b/Tests/ContainerClientTests/Measurement+ParseTests.swift @@ -0,0 +1,209 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container 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 Foundation +import Testing + +@testable import ContainerClient + +struct MeasurementParseTests { + + @Test("Parse binary units - bare unit symbols") + func testBinaryUnits() throws { + let result1 = try Measurement.parse(parsing: "4k") + #expect(result1.value == 4.0) + #expect(result1.unit == .kibibytes) + + let result2 = try Measurement.parse(parsing: "2m") + #expect(result2.value == 2.0) + #expect(result2.unit == .mebibytes) + + let result3 = try Measurement.parse(parsing: "1g") + #expect(result3.value == 1.0) + #expect(result3.unit == .gibibytes) + + let result4 = try Measurement.parse(parsing: "512b") + #expect(result4.value == 512.0) + #expect(result4.unit == .bytes) + } + + @Test("Parse binary units - ib suffix") + func testBinaryUnitsWithIbSuffix() throws { + let result1 = try Measurement.parse(parsing: "4kib") + #expect(result1.value == 4.0) + #expect(result1.unit == .kibibytes) + + let result2 = try Measurement.parse(parsing: "2mib") + #expect(result2.value == 2.0) + #expect(result2.unit == .mebibytes) + + let result3 = try Measurement.parse(parsing: "1gib") + #expect(result3.value == 1.0) + #expect(result3.unit == .gibibytes) + + let result4 = try Measurement.parse(parsing: "3tib") + #expect(result4.value == 3.0) + #expect(result4.unit == .tebibytes) + + let result5 = try Measurement.parse(parsing: "1pib") + #expect(result5.value == 1.0) + #expect(result5.unit == .pebibytes) + } + + @Test("Parse binary units - all suffixes now use binary") + func testAllSuffixesUseBinary() throws { + let result1 = try Measurement.parse(parsing: "4kb") + #expect(result1.value == 4.0) + #expect(result1.unit == .kibibytes) + + let result2 = try Measurement.parse(parsing: "2mb") + #expect(result2.value == 2.0) + #expect(result2.unit == .mebibytes) + + let result3 = try Measurement.parse(parsing: "1gb") + #expect(result3.value == 1.0) + #expect(result3.unit == .gibibytes) + + let result4 = try Measurement.parse(parsing: "3tb") + #expect(result4.value == 3.0) + #expect(result4.unit == .tebibytes) + + let result5 = try Measurement.parse(parsing: "1pb") + #expect(result5.value == 1.0) + #expect(result5.unit == .pebibytes) + } + + @Test("Parse with whitespace") + func testParsingWithWhitespace() throws { + let result1 = try Measurement.parse(parsing: " 4k ") + #expect(result1.value == 4.0) + #expect(result1.unit == .kibibytes) + + let result2 = try Measurement.parse(parsing: " 2.5mb ") + #expect(result2.value == 2.5) + #expect(result2.unit == .mebibytes) + } + + @Test("Parse decimal values") + func testDecimalValues() throws { + let result1 = try Measurement.parse(parsing: "4.5k") + #expect(result1.value == 4.5) + #expect(result1.unit == .kibibytes) + + let result2 = try Measurement.parse(parsing: "1.25gb") + #expect(result2.value == 1.25) + #expect(result2.unit == .gibibytes) + + let result3 = try Measurement.parse(parsing: "0.5mib") + #expect(result3.value == 0.5) + #expect(result3.unit == .mebibytes) + } + + @Test("Parse case insensitive") + func testCaseInsensitive() throws { + let result1 = try Measurement.parse(parsing: "4K") + #expect(result1.value == 4.0) + #expect(result1.unit == .kibibytes) + + let result2 = try Measurement.parse(parsing: "2GB") + #expect(result2.value == 2.0) + #expect(result2.unit == .gibibytes) + + let result3 = try Measurement.parse(parsing: "1MIB") + #expect(result3.value == 1.0) + #expect(result3.unit == .mebibytes) + } + + @Test("Parse bytes unit") + func testBytesUnit() throws { + let result1 = try Measurement.parse(parsing: "1024") + #expect(result1.value == 1024.0) + #expect(result1.unit == .bytes) + + let result2 = try Measurement.parse(parsing: "512b") + #expect(result2.value == 512.0) + #expect(result2.unit == .bytes) + } + + @Test("Parse invalid size throws error") + func testInvalidSizeThrowsError() { + #expect { + _ = try Measurement.parse(parsing: "abc") + } throws: { error in + guard let parseError = error as? Measurement.ParseError else { + return false + } + return parseError.description == "invalid size" + } + + #expect { + _ = try Measurement.parse(parsing: "k4") + } throws: { error in + guard let parseError = error as? Measurement.ParseError else { + return false + } + return parseError.description == "invalid size" + } + } + + @Test("Parse invalid symbol throws error") + func testInvalidSymbolThrowsError() { + #expect { + _ = try Measurement.parse(parsing: "4x") + } throws: { error in + guard let parseError = error as? Measurement.ParseError else { + return false + } + return parseError.description == "invalid symbol: x" + } + + #expect { + _ = try Measurement.parse(parsing: "4kx") + } throws: { error in + guard let parseError = error as? Measurement.ParseError else { + return false + } + return parseError.description == "invalid symbol: kx" + } + } + + @Test("Parse empty string throws error") + func testEmptyStringThrowsError() { + #expect { + _ = try Measurement.parse(parsing: "") + } throws: { error in + guard let parseError = error as? Measurement.ParseError else { + return false + } + return parseError.description == "invalid size" + } + } + + @Test("Verify all suffixes now use binary units") + func testAllSuffixesUseBinaryUnits() throws { + let bareK = try Measurement.parse(parsing: "1k") + let kib = try Measurement.parse(parsing: "1kib") + let kb = try Measurement.parse(parsing: "1kb") + + #expect(bareK.unit == .kibibytes) + #expect(kib.unit == .kibibytes) + #expect(kb.unit == .kibibytes) + + let allInBytes = bareK.converted(to: .bytes).value + + #expect(allInBytes == 1024.0) + } +} diff --git a/docs/how-to.md b/docs/how-to.md index 7d55c965..a1c40844 100644 --- a/docs/how-to.md +++ b/docs/how-to.md @@ -4,7 +4,7 @@ How to use the features of `container`. ## Configure memory and CPUs for your containers -Since the containers created by `container` are lightweight virtual machines, consider the needs of your containerized application when you use `container run`. The `--memory` and `--cpus` options allow you to override the default memory and CPU limits for the virtual machine. The default values are 1 gigabyte of RAM and 4 CPUs. You can use abbreviations for memory units; for example, to run a container for image `big` with 8 CPUs and 32 gigabytes of memory, use: +Since the containers created by `container` are lightweight virtual machines, consider the needs of your containerized application when you use `container run`. The `--memory` and `--cpus` options allow you to override the default memory and CPU limits for the virtual machine. The default values are 1 gigabyte of RAM and 4 CPUs. You can use abbreviations for memory units; for example, to run a container for image `big` with 8 CPUs and 32 GiBytes of memory, use: ```bash container run --rm --cpus 8 --memory 32g big @@ -14,7 +14,7 @@ container run --rm --cpus 8 --memory 32g big When you first run `container build`, `container` starts a *builder*, which is a utility container that builds images from your `Dockerfile`s. As with anything you run with `container run`, the builder runs in a lightweight virtual machine, so for resource-intensive builds, you may need to increase the memory and CPU limits for the builder VM. -By default, the builder VM receives 2 gigabytes of RAM and 2 CPUs. You can change these limits by starting the builder container before running `container build`: +By default, the builder VM receives 2 GiBytes of RAM and 2 CPUs. You can change these limits by starting the builder container before running `container build`: ```bash container builder start --cpus 8 --memory 32g diff --git a/docs/technical-overview.md b/docs/technical-overview.md index 728e9b21..83962af0 100644 --- a/docs/technical-overview.md +++ b/docs/technical-overview.md @@ -61,7 +61,7 @@ socat TCP-LISTEN:8000,fork,bind=192.168.64.1 TCP:127.0.0.1:8000 ### Releasing container memory to macOS -The macOS Virtualization framework implements only partial support for memory ballooning, which is a technology that allows virtual machines to dynamically use and relinquish host memory. When you create a container, the underlying virtual machine only uses the amount of memory that the containerized application needs. For example, you might start a container using the option `--memory 16g`, but see that the application is only using 2 gigabytes of RAM in the macOS Activity Monitor. +The macOS Virtualization framework implements only partial support for memory ballooning, which is a technology that allows virtual machines to dynamically use and relinquish host memory. When you create a container, the underlying virtual machine only uses the amount of memory that the containerized application needs. For example, you might start a container using the option `--memory 16g`, but see that the application is only using 2 GiBytes of RAM in the macOS Activity Monitor. Currently, memory pages freed to the Linux operating system by processes running in the container's VM are not relinquished to the host. If you run many memory-intensive containers, you may need to occasionally restart them to reduce memory utilization.