Centralize utilities for configuration loading and path parsing (#1448)

This creates a more centralized utility to find and load configuration files, as a follow up to (pr: https://github.com/apple/container/pull/1425).
This commit is contained in:
Noah Thornton
2026-05-08 12:29:28 -04:00
committed by GitHub
parent 3b724aecf5
commit 0328f5c4c5
26 changed files with 514 additions and 367 deletions
+1
View File
@@ -229,6 +229,7 @@ let package = Package(
.product(name: "SystemPackage", package: "swift-system"),
"ContainerAPIClient",
"ContainerPersistence",
"ContainerTestSupport",
]
),
.executableTarget(
+1 -3
View File
@@ -50,9 +50,7 @@ extension APIServer {
var logRoot = LogRoot.path
func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(
configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url)
)
let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load()
let commandName = APIServer._commandName
let logPath = logRoot.map { $0.appending("\(commandName).log") }
let log = ServiceLogger.bootstrap(category: "APIServer", debug: debug, logPath: logPath)
+1 -3
View File
@@ -149,9 +149,7 @@ extension Application {
var pull: Bool = false
public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(
configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url)
)
let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load()
do {
let timeout: Duration = .seconds(300)
let progressConfig = try ProgressConfig(
@@ -55,9 +55,7 @@ extension Application {
public init() {}
public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(
configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url)
)
let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load()
let progressConfig = try ProgressConfig(
showTasks: true,
showItems: true,
@@ -56,9 +56,7 @@ extension Application {
var arguments: [String] = []
public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(
configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url)
)
let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load()
let progressConfig = try ProgressConfig(
showTasks: true,
showItems: true,
@@ -63,9 +63,7 @@ extension Application {
var arguments: [String] = []
public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(
configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url)
)
let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load()
var exitCode: Int32 = 127
let id = Utility.createContainerID(name: self.managementFlags.name)
@@ -109,9 +109,7 @@ extension Application {
}
public mutating func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(
configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url)
)
let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load()
try await DeleteImageImplementation.removeImage(options: options, containerSystemConfig: containerSystemConfig, log: log)
}
}
@@ -45,9 +45,7 @@ extension Application {
}
public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(
configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url)
)
let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load()
var printable: [ImageDetail] = []
var succeededImages: [String] = []
var allErrors: [(String, Error)] = []
@@ -45,9 +45,7 @@ extension Application {
public var logOptions: Flags.Logging
public mutating func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(
configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url)
)
let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load()
try Self.validate(format: format, quiet: quiet, verbose: verbose)
var images = try await ClientImage.list().filter { img in
@@ -69,9 +69,7 @@ extension Application {
}
public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(
configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url)
)
let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load()
let p = try DefaultPlatform.resolve(platform: platform, os: os, arch: arch, log: log)
let scheme = try RequestScheme(registry.scheme)
@@ -57,9 +57,7 @@ extension Application {
public init() {}
public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(
configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url)
)
let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load()
let p = try DefaultPlatform.resolve(platform: platform, os: os, arch: arch, log: log)
let scheme = try RequestScheme(registry.scheme)
@@ -62,9 +62,7 @@ extension Application {
@Argument var references: [String]
public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(
configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url)
)
let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load()
let p = try DefaultPlatform.resolve(platform: platform, os: os, arch: arch, log: log)
let progressConfig = try ProgressConfig(
@@ -36,9 +36,7 @@ extension Application {
public var logOptions: Flags.Logging
public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(
configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url)
)
let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load()
let existing = try await ClientImage.get(reference: source, containerSystemConfig: containerSystemConfig)
let targetReference = try ClientImage.normalizeReference(target, containerSystemConfig: containerSystemConfig)
try await existing.tag(new: targetReference)
@@ -47,9 +47,7 @@ extension Application {
var server: String
public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(
configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url)
)
let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load()
var username = self.username
var password = ""
if passwordStdin {
@@ -53,9 +53,7 @@ extension Application {
public init() {}
public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(
configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url)
)
let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load()
if recommended {
let url = containerSystemConfig.kernel.url
let path: String = containerSystemConfig.kernel.binaryPath
@@ -42,9 +42,7 @@ extension Application {
public init() {}
public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(
configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url)
)
let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load()
let output =
switch format {
case .json: try Output.renderJSON(containerSystemConfig)
@@ -72,12 +72,10 @@ extension Application {
public init() {}
public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(
configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url)
)
// Copy the user's config into appRoot/config/ so that all plugins
// and the apiserver subsequently read the same snapshot.
SystemRuntimeOptions.copyConfigToAppRoot(appRoot: appRoot)
let appRootPath = FilePath(appRoot.path(percentEncoded: false))
try ConfigurationLoader.copyConfigurationToReadOnly(to: appRootPath)
let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load(
configurationFile: ConfigurationLoader.configurationFile(in: appRootPath))
// Without the true path to the binary in the plist, `container-apiserver` won't launch properly.
// Resolve the symlink to get the true binary path before writing the launchd plist.
@@ -0,0 +1,111 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container project authors.
//
// 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 ContainerizationError
import Foundation
import SystemPackage
import TOML
public protocol Initable {
init()
}
public typealias LoadableConfiguration = Codable & Sendable & Initable
public enum ConfigurationLoader {
private static let configFilename = "runtime-config.toml"
private static let configDirectory = "config"
private static let READ_ONLY: Int = 0o444
private static let READ_AND_WRITE: Int = 0o644
/// Returns the canonical configuration file path under an appRoot base directory:
/// `<base>/config/runtime-config.toml`.
public static func configurationFile(in base: FilePath) -> FilePath {
base.appending(configDirectory).appending(configFilename)
}
/// Loads and decodes a TOML configuration file as type `T`.
///
/// - Parameter configurationFile: Absolute path to the configuration file.
/// When `nil`, falls back to
/// `configurationFile(in: PathUtils.BaseConfigPath.appRoot.basePath())`.
/// - Returns: A decoded value of type `T`, or a default-initialized `T` if the
/// configuration file does not exist.
public static func load<T: LoadableConfiguration>(configurationFile: FilePath? = nil) throws -> T {
let path = configurationFile ?? Self.configurationFile(in: PathUtils.BaseConfigPath.appRoot.basePath())
guard FileManager.default.fileExists(atPath: path.string) else {
return T()
}
do {
let data = try Data(contentsOf: URL(filePath: path.string))
return try TOMLDecoder().decode(T.self, from: data)
} catch {
throw ContainerizationError(
.invalidArgument,
message: "failed to load configuration from '\(path)': \(error)"
)
}
}
/// Copies a TOML configuration file into a read-only destination under an appRoot base.
///
/// - Parameters:
/// - source: The file to copy. When `nil`, defaults to
/// `<home>/container/runtime-config.toml`. If the source does not exist,
/// this is a no-op.
/// - destination: Base directory under which the file is written at
/// `<destination>/config/runtime-config.toml`. When `nil`, falls back to
/// `PathUtils.BaseConfigPath.appRoot.basePath()`. The destination file is written
/// with `READ_ONLY` (`0o444`) permissions.
public static func copyConfigurationToReadOnly(
from source: FilePath? = nil,
to destination: FilePath? = nil
) throws {
let source =
source
?? PathUtils.BaseConfigPath.home.basePath()
.appending(configFilename)
let destinationFile = Self.configurationFile(in: destination ?? PathUtils.BaseConfigPath.appRoot.basePath())
do {
let fm = FileManager.default
guard fm.fileExists(atPath: source.string) else { return }
let destDir = destinationFile.removingLastComponent()
try fm.createDirectory(
atPath: destDir.string,
withIntermediateDirectories: true
)
if fm.fileExists(atPath: destinationFile.string) {
try fm.setAttributes(
[.posixPermissions: READ_AND_WRITE],
ofItemAtPath: destinationFile.string
)
try fm.removeItem(at: URL(filePath: destinationFile.string))
}
try fm.copyItem(
at: URL(filePath: source.string),
to: URL(filePath: destinationFile.string)
)
try fm.setAttributes(
[.posixPermissions: READ_ONLY],
ofItemAtPath: destinationFile.string
)
} catch {
throw ContainerizationError(
.invalidState, message: "Failed to copy user TOML to AppRoot `\(error)`")
}
}
}
@@ -23,7 +23,7 @@ import Foundation
///
/// Each section maps to a nested struct. Missing keys fall back to
/// hardcoded defaults via custom `init(from:)` implementations.
final public class ContainerSystemConfig: Codable, Sendable {
public final class ContainerSystemConfig: Codable, Sendable, Initable {
public let build: BuildConfig
public let container: ContainerConfig
public let dns: DNSConfig
@@ -50,6 +50,16 @@ final public class ContainerSystemConfig: Codable, Sendable {
self.vminit = vminit
}
public init() {
self.build = .init()
self.container = .init()
self.dns = .init()
self.kernel = .init()
self.network = .init()
self.registry = .init()
self.vminit = .init()
}
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.build = try container.decodeIfPresent(BuildConfig.self, forKey: .build) ?? .init()
@@ -0,0 +1,47 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container project authors.
//
// 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 SystemPackage
public enum PathUtils {
public enum BaseConfigPath {
case home
case appRoot
public func basePath(env: [String: String] = ProcessInfo.processInfo.environment) -> FilePath {
switch self {
case .home:
let configHome: String
if let xdg = env["XDG_CONFIG_HOME"], !xdg.isEmpty {
configHome = xdg
} else {
configHome = NSHomeDirectory() + "/.config"
}
return FilePath(configHome).appending("container")
case .appRoot:
if let envPath = env["CONTAINER_APP_ROOT"], !envPath.isEmpty {
return FilePath(envPath)
}
let appSupportURL = FileManager.default.urls(
for: .applicationSupportDirectory,
in: .userDomainMask
).first!.appendingPathComponent("com.apple.container")
return FilePath(appSupportURL.path(percentEncoded: false))
}
}
}
}
@@ -1,109 +0,0 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container project authors.
//
// 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 ContainerizationError
import Foundation
import Logging
import SystemPackage
import TOML
private let log = Logger(label: "SystemRuntimeOptions")
/// TOML-backed configuration loader.
///
/// Decodes a user-provided TOML file into a typed configuration struct.
/// Missing keys fall back to the struct's hardcoded defaults (via custom
/// `init(from:)` implementations using `decodeIfPresent`).
///
/// Configuration priority (highest to lowest):
/// 1. User config: `$XDG_CONFIG_HOME/container/runtime-config.toml`
/// 2. Hardcoded defaults in the config struct's initializer
public enum SystemRuntimeOptions {
/// Path to the user's configuration file.
public static var defaultUserConfigPath: URL {
let configHome: String
if let xdg = ProcessInfo.processInfo.environment["XDG_CONFIG_HOME"], !xdg.isEmpty {
configHome = xdg
} else {
configHome = NSHomeDirectory() + "/.config"
}
return URL(fileURLWithPath: configHome)
.appendingPathComponent("container")
.appendingPathComponent("runtime-config.toml")
}
/// The path to the config file within an application root directory.
public static func configFileFromAppRoot(_ appRoot: URL) -> URL {
appRoot
.appendingPathComponent("config")
.appendingPathComponent("runtime-config.toml")
}
/// Load configuration by decoding a TOML file.
///
/// - Parameters:
/// - configFile: Full path to the TOML config file.
/// - Returns: The decoded configuration. If the file does not exist, all values
/// fall back to the type's hardcoded defaults.
public static func loadConfig<T: Codable & Sendable>(configFile: URL) throws -> T {
let fm = FileManager.default
let path = configFile.path(percentEncoded: false)
guard fm.fileExists(atPath: path) else {
do {
return try TOMLDecoder().decode(T.self, from: Data("".utf8))
} catch {
throw ContainerizationError(.internalError, message: "failed to initialize default configuration: \(error)")
}
}
do {
let data = try Data(contentsOf: configFile)
return try TOMLDecoder().decode(T.self, from: data)
} catch {
throw ContainerizationError(.invalidArgument, message: "failed to load configuration from '\(path)': \(error)")
}
}
public static func copyConfigToAppRoot(
appRoot: URL,
userConfigPath: URL = defaultUserConfigPath,
) {
let fm = FileManager.default
if fm.fileExists(atPath: userConfigPath.path(percentEncoded: false)) {
let configDir = appRoot.appendingPathComponent("config")
let destPath = configDir.appendingPathComponent("runtime-config.toml")
do {
try fm.createDirectory(at: configDir, withIntermediateDirectories: true)
if fm.fileExists(atPath: destPath.path(percentEncoded: false)) {
try fm.setAttributes([.posixPermissions: 0o644], ofItemAtPath: destPath.path(percentEncoded: false))
try fm.removeItem(at: destPath)
}
try fm.copyItem(
at: URL(fileURLWithPath: userConfigPath.path(percentEncoded: false)),
to: destPath
)
try fm.setAttributes(
[.posixPermissions: 0o444],
ofItemAtPath: destPath.path(percentEncoded: false)
)
log.info("copied runtime config", metadata: ["dest": "\(destPath.path(percentEncoded: false))"])
} catch {
// If the config copy-ing fails, we will log an error but it is not fatal since we can utilize the fallback config.
log.error("failed to copy runtime config to app root", metadata: ["error": "\(error)"])
}
}
}
}
@@ -58,9 +58,7 @@ extension ImagesHelper {
var logRoot = LogRoot.path
func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(
configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url)
)
let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load()
let commandName = ImagesHelper._commandName
let logPath = logRoot.map { $0.appending("\(commandName).log") }
let log = ServiceLogger.bootstrap(category: "ImagesHelper", debug: debug, logPath: logPath)
@@ -0,0 +1,246 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container project authors.
//
// 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 ContainerTestSupport
import ContainerizationExtras
import Foundation
import SystemPackage
import Testing
@testable import ContainerPersistence
struct ConfigurationLoaderTests {
private static func writeToml(_ contents: String, to path: FilePath) throws {
try contents.write(
to: URL(filePath: path.string),
atomically: true,
encoding: .utf8
)
}
@Test func testDefaultsWithNoFile() async throws {
try await TemporaryStorage.withTempDir { tempDir in
let path = tempDir.appending("nonexistent.toml")
let config: ContainerSystemConfig = try ConfigurationLoader.load(configurationFile: path)
#expect(config.build.rosetta == true)
#expect(config.build.cpus == 2)
#expect(config.build.memory == BuildConfig.defaultMemory)
#expect(config.container.cpus == 4)
#expect(config.container.memory == ContainerConfig.defaultMemory)
#expect(config.dns.domain == nil)
#expect(!config.build.image.isEmpty)
#expect(!config.vminit.image.isEmpty)
#expect(!config.kernel.binaryPath.isEmpty)
#expect(!config.kernel.url.absoluteString.isEmpty)
#expect(config.network.subnet == nil)
#expect(config.network.subnetv6 == nil)
#expect(config.registry.domain == "docker.io")
}
}
@Test func testTomlOverrideAllKeys() async throws {
try await TemporaryStorage.withTempDir { tempDir in
let toml = """
[build]
rosetta = false
cpus = 8
memory = "4096MB"
image = "custom-builder:latest"
[container]
cpus = 16
memory = "8g"
[dns]
domain = "custom"
[kernel]
binaryPath = "custom/path"
url = "https://example.com/kernel.tar"
[network]
subnet = "10.0.0.1/16"
subnetv6 = "fd01::/48"
[registry]
domain = "ghcr.io"
[vminit]
image = "custom-init:latest"
"""
let tmpFile = tempDir.appending("test.toml")
try Self.writeToml(toml, to: tmpFile)
let config: ContainerSystemConfig = try ConfigurationLoader.load(configurationFile: tmpFile)
#expect(config.build.rosetta == false)
#expect(config.build.cpus == 8)
let expectedBuildMemory = try MemorySize("4096MB")
#expect(config.build.memory == expectedBuildMemory)
#expect(config.container.cpus == 16)
let expectedContainerMemory = try MemorySize("8g")
#expect(config.container.memory == expectedContainerMemory)
#expect(config.dns.domain == "custom")
#expect(config.build.image == "custom-builder:latest")
#expect(config.vminit.image == "custom-init:latest")
#expect(config.kernel.binaryPath == "custom/path")
#expect(config.kernel.url.absoluteString == "https://example.com/kernel.tar")
let expectedSubnet = try CIDRv4("10.0.0.1/16")
let expectedSubnetV6 = try CIDRv6("fd01::/48")
#expect(config.network.subnet == expectedSubnet)
#expect(config.network.subnetv6 == expectedSubnetV6)
#expect(config.registry.domain == "ghcr.io")
}
}
@Test func testPartialToml() async throws {
try await TemporaryStorage.withTempDir { tempDir in
let toml = """
[build]
cpus = 16
"""
let tmpFile = tempDir.appending("test.toml")
try Self.writeToml(toml, to: tmpFile)
let config: ContainerSystemConfig = try ConfigurationLoader.load(configurationFile: tmpFile)
#expect(config.build.cpus == 16)
#expect(config.build.rosetta == true)
#expect(config.build.memory == BuildConfig.defaultMemory)
#expect(config.container.cpus == 4)
#expect(config.container.memory == ContainerConfig.defaultMemory)
}
}
@Test func testUnknownKeysIgnored() async throws {
try await TemporaryStorage.withTempDir { tempDir in
let toml = """
[build]
cpus = 4
unknownBuildKey = "ignored"
[unknownSection]
foo = "bar"
"""
let tmpFile = tempDir.appending("test.toml")
try Self.writeToml(toml, to: tmpFile)
let config: ContainerSystemConfig = try ConfigurationLoader.load(configurationFile: tmpFile)
#expect(config.build.cpus == 4)
#expect(config.build.rosetta == true)
}
}
@Test func testInvalidTomlThrows() async throws {
try await TemporaryStorage.withTempDir { tempDir in
let tmpFile = tempDir.appending("test-invalid.toml")
try Self.writeToml("this is [not valid toml", to: tmpFile)
#expect(throws: (any Error).self) {
let _: ContainerSystemConfig = try ConfigurationLoader.load(configurationFile: tmpFile)
}
}
}
@Test func testEmptyTomlDecodesToDefaults() async throws {
try await TemporaryStorage.withTempDir { tempDir in
let tmpFile = tempDir.appending("test.toml")
try Self.writeToml("", to: tmpFile)
let config: ContainerSystemConfig = try ConfigurationLoader.load(configurationFile: tmpFile)
#expect(config.build.rosetta == true)
#expect(config.build.cpus == 2)
#expect(config.container.cpus == 4)
#expect(config.registry.domain == "docker.io")
}
}
@Test func testCopyConfigToAppRoot() async throws {
try await TemporaryStorage.withTempDir { tempDir in
let source = tempDir.appending("runtime-config.toml")
try Self.writeToml("[build]\ncpus = 8", to: source)
let destBase = tempDir.appending("dest")
try ConfigurationLoader.copyConfigurationToReadOnly(from: source, to: destBase)
let destFile = ConfigurationLoader.configurationFile(in: destBase)
let copied = try String(contentsOf: URL(filePath: destFile.string), encoding: .utf8)
#expect(copied.contains("cpus = 8"))
let attrs = try FileManager.default.attributesOfItem(atPath: destFile.string)
let perms = attrs[.posixPermissions] as! Int
#expect(perms == 0o444)
}
}
@Test func testCopyConfigOverwritesExistingReadOnlyDestination() async throws {
try await TemporaryStorage.withTempDir { tempDir in
let source = tempDir.appending("runtime-config.toml")
let destBase = tempDir.appending("dest")
try Self.writeToml("[build]\ncpus = 8", to: source)
try ConfigurationLoader.copyConfigurationToReadOnly(from: source, to: destBase)
try Self.writeToml("[build]\ncpus = 16", to: source)
try ConfigurationLoader.copyConfigurationToReadOnly(from: source, to: destBase)
let destFile = ConfigurationLoader.configurationFile(in: destBase)
let copied = try String(contentsOf: URL(filePath: destFile.string), encoding: .utf8)
#expect(copied.contains("cpus = 16"))
let attrs = try FileManager.default.attributesOfItem(atPath: destFile.string)
let perms = attrs[.posixPermissions] as! Int
#expect(perms == 0o444)
}
}
@Test func testCopyConfigNoOpsWhenSourceMissing() async throws {
try await TemporaryStorage.withTempDir { tempDir in
let source = tempDir.appending("nonexistent.toml")
let destBase = tempDir.appending("dest")
try ConfigurationLoader.copyConfigurationToReadOnly(from: source, to: destBase)
let destFile = ConfigurationLoader.configurationFile(in: destBase)
#expect(!FileManager.default.fileExists(atPath: destFile.string))
}
}
@Test func testIndependentPluginConfig() async throws {
struct PluginConfig: Codable, Sendable, Initable {
var network: NetworkConfig
init() { self.network = .init() }
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.network = try container.decodeIfPresent(NetworkConfig.self, forKey: .network) ?? .init()
}
}
try await TemporaryStorage.withTempDir { tempDir in
let toml = """
[build]
cpus = 8
[network]
subnet = "10.1.2.3/24"
subnetv6 = "fd02::/48"
"""
let tmpFile = tempDir.appending("test.toml")
try Self.writeToml(toml, to: tmpFile)
let config: PluginConfig = try ConfigurationLoader.load(configurationFile: tmpFile)
let expectedSubnet = try CIDRv4("10.1.2.3/24")
let expectedSubnetV6 = try CIDRv6("fd02::/48")
#expect(config.network.subnet == expectedSubnet)
#expect(config.network.subnetv6 == expectedSubnetV6)
}
}
}
@@ -14,13 +14,14 @@
// limitations under the License.
//===----------------------------------------------------------------------===//
import ContainerPersistence
import ContainerizationError
import ContainerizationExtras
import Foundation
import SystemPackage
import Testing
@testable import ContainerAPIClient
@testable import ContainerPersistence
struct ParserTest {
@Test
@@ -1210,7 +1211,7 @@ struct ParserTest {
FileManager.default.createFile(atPath: tempFile.path(), contents: Data(content.utf8))
defer { try? FileManager.default.removeItem(at: tempFile) }
let config: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(configFile: tempFile)
let config: ContainerSystemConfig = try ConfigurationLoader.load(configurationFile: FilePath(tempFile.path(percentEncoded: false)))
let result = try Parser.resources(
cpus: nil, memory: nil,
defaultCPUs: config.build.cpus,
@@ -1229,7 +1230,7 @@ struct ParserTest {
FileManager.default.createFile(atPath: tempFile.path(), contents: Data(content.utf8))
defer { try? FileManager.default.removeItem(at: tempFile) }
let config: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(configFile: tempFile)
let config: ContainerSystemConfig = try ConfigurationLoader.load(configurationFile: FilePath(tempFile.path(percentEncoded: false)))
let result = try Parser.resources(
cpus: nil, memory: nil,
defaultCPUs: config.container.cpus,
@@ -1247,7 +1248,7 @@ struct ParserTest {
FileManager.default.createFile(atPath: tempFile.path(), contents: Data(content.utf8))
defer { try? FileManager.default.removeItem(at: tempFile) }
let config: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(configFile: tempFile)
let config: ContainerSystemConfig = try ConfigurationLoader.load(configurationFile: FilePath(tempFile.path(percentEncoded: false)))
let result = try Parser.resources(
cpus: nil, memory: nil,
defaultCPUs: config.container.cpus,
@@ -1266,7 +1267,7 @@ struct ParserTest {
FileManager.default.createFile(atPath: tempFile.path(), contents: Data(content.utf8))
defer { try? FileManager.default.removeItem(at: tempFile) }
let config: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(configFile: tempFile)
let config: ContainerSystemConfig = try ConfigurationLoader.load(configurationFile: FilePath(tempFile.path(percentEncoded: false)))
let result = try Parser.resources(
cpus: 1, memory: "256m",
defaultCPUs: config.container.cpus,
@@ -1286,7 +1287,7 @@ struct ParserTest {
FileManager.default.createFile(atPath: tempFile.path(), contents: Data(content.utf8))
defer { try? FileManager.default.removeItem(at: tempFile) }
let config: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(configFile: tempFile)
let config: ContainerSystemConfig = try ConfigurationLoader.load(configurationFile: FilePath(tempFile.path(percentEncoded: false)))
let result = try Parser.resources(
cpus: nil, memory: nil,
defaultCPUs: config.build.cpus,
@@ -0,0 +1,71 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container project authors.
//
// 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 ContainerPersistence
import Foundation
import SystemPackage
import Testing
struct PathUtilsTests {
private static let homeFallback = FilePath(NSHomeDirectory() + "/.config").appending("container")
private static let appRootFallback: FilePath = {
let url = FileManager.default.urls(
for: .applicationSupportDirectory,
in: .userDomainMask
).first!.appendingPathComponent("com.apple.container")
return FilePath(url.path(percentEncoded: false))
}()
@Test func testHomeUsesXdgConfigHomeWhenSet() {
let path = PathUtils.BaseConfigPath.home.basePath(env: ["XDG_CONFIG_HOME": "/tmp/xdg-test"])
#expect(path == FilePath("/tmp/xdg-test/container"))
}
@Test func testHomeFallsBackToHomeDirectoryWhenXdgUnset() {
let path = PathUtils.BaseConfigPath.home.basePath(env: [:])
#expect(path == Self.homeFallback)
}
@Test func testHomeTreatsEmptyXdgAsUnset() {
let path = PathUtils.BaseConfigPath.home.basePath(env: ["XDG_CONFIG_HOME": ""])
#expect(path == Self.homeFallback)
}
@Test func testAppRootUsesContainerAppRootWhenSet() {
let path = PathUtils.BaseConfigPath.appRoot.basePath(env: ["CONTAINER_APP_ROOT": "/tmp/foo"])
#expect(path == FilePath("/tmp/foo"))
}
@Test func testAppRootFallsBackToApplicationSupportWhenUnset() {
let path = PathUtils.BaseConfigPath.appRoot.basePath(env: [:])
#expect(path == Self.appRootFallback)
}
@Test func testAppRootTreatsEmptyEnvAsUnset() {
let path = PathUtils.BaseConfigPath.appRoot.basePath(env: ["CONTAINER_APP_ROOT": ""])
#expect(path == Self.appRootFallback)
}
@Test func testAppRootIgnoresXdgConfigHome() {
let path = PathUtils.BaseConfigPath.appRoot.basePath(env: ["XDG_CONFIG_HOME": "/tmp/xdg-test"])
#expect(path == Self.appRootFallback)
}
@Test func testHomeIgnoresContainerAppRoot() {
let path = PathUtils.BaseConfigPath.home.basePath(env: ["CONTAINER_APP_ROOT": "/tmp/foo"])
#expect(path == Self.homeFallback)
}
}
@@ -1,197 +0,0 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container project authors.
//
// 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 ContainerPersistence
import ContainerizationExtras
import Foundation
import Testing
struct SystemRuntimeOptionsTests {
@Test func testDefaultsWithNoToml() throws {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("nonexistent-\(UUID().uuidString).toml")
let config: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(configFile: url)
#expect(config.build.rosetta == true)
#expect(config.build.cpus == 2)
#expect(config.build.memory == BuildConfig.defaultMemory)
#expect(config.container.cpus == 4)
#expect(config.container.memory == ContainerConfig.defaultMemory)
#expect(config.dns.domain == nil)
#expect(!config.build.image.isEmpty)
#expect(!config.vminit.image.isEmpty)
#expect(!config.kernel.binaryPath.isEmpty)
#expect(!config.kernel.url.absoluteString.isEmpty)
#expect(config.network.subnet == nil)
#expect(config.network.subnetv6 == nil)
#expect(config.registry.domain == "docker.io")
}
@Test func testTomlOverrideAllKeys() throws {
let toml = """
[build]
rosetta = false
cpus = 8
memory = "4096MB"
image = "custom-builder:latest"
[container]
cpus = 16
memory = "8g"
[dns]
domain = "custom"
[kernel]
binaryPath = "custom/path"
url = "https://example.com/kernel.tar"
[network]
subnet = "10.0.0.1/16"
subnetv6 = "fd01::/48"
[registry]
domain = "ghcr.io"
[vminit]
image = "custom-init:latest"
"""
let tmpFile = FileManager.default.temporaryDirectory
.appendingPathComponent("test-\(UUID().uuidString).toml")
try toml.write(to: tmpFile, atomically: true, encoding: .utf8)
defer { try? FileManager.default.removeItem(at: tmpFile) }
let config: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(configFile: tmpFile)
#expect(config.build.rosetta == false)
#expect(config.build.cpus == 8)
let expectedBuildMemory = try MemorySize("4096MB")
#expect(config.build.memory == expectedBuildMemory)
#expect(config.container.cpus == 16)
let expectedContainerMemory = try MemorySize("8g")
#expect(config.container.memory == expectedContainerMemory)
#expect(config.dns.domain == "custom")
#expect(config.build.image == "custom-builder:latest")
#expect(config.vminit.image == "custom-init:latest")
#expect(config.kernel.binaryPath == "custom/path")
#expect(config.kernel.url.absoluteString == "https://example.com/kernel.tar")
let expectedSubnet = try CIDRv4("10.0.0.1/16")
let expectedSubnetV6 = try CIDRv6("fd01::/48")
#expect(config.network.subnet == expectedSubnet)
#expect(config.network.subnetv6 == expectedSubnetV6)
#expect(config.registry.domain == "ghcr.io")
}
@Test func testPartialToml() throws {
let toml = """
[build]
cpus = 16
"""
let tmpFile = FileManager.default.temporaryDirectory
.appendingPathComponent("test-\(UUID().uuidString).toml")
try toml.write(to: tmpFile, atomically: true, encoding: .utf8)
defer { try? FileManager.default.removeItem(at: tmpFile) }
let config: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(configFile: tmpFile)
#expect(config.build.cpus == 16)
// All other fields should have their hardcoded defaults
#expect(config.build.rosetta == true)
#expect(config.build.memory == BuildConfig.defaultMemory)
#expect(config.container.cpus == 4)
#expect(config.container.memory == ContainerConfig.defaultMemory)
#expect(config.dns.domain == nil)
#expect(config.network.subnet == nil)
#expect(config.network.subnetv6 == nil)
#expect(config.registry.domain == "docker.io")
}
@Test func testUnknownKeysIgnored() throws {
let toml = """
[build]
cpus = 4
unknownBuildKey = "ignored"
[unknownSection]
foo = "bar"
"""
let tmpFile = FileManager.default.temporaryDirectory
.appendingPathComponent("test-\(UUID().uuidString).toml")
try toml.write(to: tmpFile, atomically: true, encoding: .utf8)
defer { try? FileManager.default.removeItem(at: tmpFile) }
let config: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(configFile: tmpFile)
#expect(config.build.cpus == 4)
#expect(config.build.rosetta == true)
}
@Test func testIndependentPluginConfig() throws {
struct PluginConfig: Codable, Sendable {
var network: NetworkConfig
init(network: NetworkConfig = .init()) { self.network = network }
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.network = try container.decodeIfPresent(NetworkConfig.self, forKey: .network) ?? .init()
}
}
let toml = """
[build]
cpus = 8
[network]
subnet = "10.1.2.3/24"
subnetv6 = "fd02::/48"
"""
let tmpFile = FileManager.default.temporaryDirectory
.appendingPathComponent("test-\(UUID().uuidString).toml")
try toml.write(to: tmpFile, atomically: true, encoding: .utf8)
defer { try? FileManager.default.removeItem(at: tmpFile) }
let config: PluginConfig = try SystemRuntimeOptions.loadConfig(configFile: tmpFile)
// Only network is decoded; build section is ignored
let expectedSubnet = try CIDRv4("10.1.2.3/24")
let expectedSubnetV6 = try CIDRv6("fd02::/48")
#expect(config.network.subnet == expectedSubnet)
#expect(config.network.subnetv6 == expectedSubnetV6)
}
@Test func testInvalidTomlThrows() throws {
let tmpFile = FileManager.default.temporaryDirectory
.appendingPathComponent("test-invalid-toml.toml")
try "this is [not valid toml".write(to: tmpFile, atomically: true, encoding: .utf8)
defer { try? FileManager.default.removeItem(at: tmpFile) }
#expect(throws: (any Error).self) {
let _: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(configFile: tmpFile)
}
}
@Test func testEmptyTomlDecodesToDefaults() throws {
let tmpFile = FileManager.default.temporaryDirectory
.appendingPathComponent("test-\(UUID().uuidString).toml")
try "".write(to: tmpFile, atomically: true, encoding: .utf8)
defer { try? FileManager.default.removeItem(at: tmpFile) }
let config: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(configFile: tmpFile)
#expect(config.build.rosetta == true)
#expect(config.build.cpus == 2)
#expect(config.build.memory == BuildConfig.defaultMemory)
#expect(config.container.cpus == 4)
#expect(config.container.memory == ContainerConfig.defaultMemory)
#expect(config.dns.domain == nil)
#expect(config.network.subnet == nil)
#expect(config.network.subnetv6 == nil)
#expect(config.registry.domain == "docker.io")
}
}