diff --git a/Package.swift b/Package.swift
index 3fc2d5740..59db3c5a6 100644
--- a/Package.swift
+++ b/Package.swift
@@ -229,6 +229,7 @@ let package = Package(
.product(name: "SystemPackage", package: "swift-system"),
"ContainerAPIClient",
"ContainerPersistence",
+ "ContainerTestSupport",
]
),
.executableTarget(
diff --git a/Sources/APIServer/APIServer+Start.swift b/Sources/APIServer/APIServer+Start.swift
index fd4380aa2..4c850fb25 100644
--- a/Sources/APIServer/APIServer+Start.swift
+++ b/Sources/APIServer/APIServer+Start.swift
@@ -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)
diff --git a/Sources/ContainerCommands/BuildCommand.swift b/Sources/ContainerCommands/BuildCommand.swift
index a9c665196..cb6882278 100644
--- a/Sources/ContainerCommands/BuildCommand.swift
+++ b/Sources/ContainerCommands/BuildCommand.swift
@@ -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(
diff --git a/Sources/ContainerCommands/Builder/BuilderStart.swift b/Sources/ContainerCommands/Builder/BuilderStart.swift
index 42b8412e1..00b872105 100644
--- a/Sources/ContainerCommands/Builder/BuilderStart.swift
+++ b/Sources/ContainerCommands/Builder/BuilderStart.swift
@@ -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,
diff --git a/Sources/ContainerCommands/Container/ContainerCreate.swift b/Sources/ContainerCommands/Container/ContainerCreate.swift
index c3ff6a25a..8b2291292 100644
--- a/Sources/ContainerCommands/Container/ContainerCreate.swift
+++ b/Sources/ContainerCommands/Container/ContainerCreate.swift
@@ -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,
diff --git a/Sources/ContainerCommands/Container/ContainerRun.swift b/Sources/ContainerCommands/Container/ContainerRun.swift
index ff6729b05..23dafc70b 100644
--- a/Sources/ContainerCommands/Container/ContainerRun.swift
+++ b/Sources/ContainerCommands/Container/ContainerRun.swift
@@ -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)
diff --git a/Sources/ContainerCommands/Image/ImageDelete.swift b/Sources/ContainerCommands/Image/ImageDelete.swift
index 0353723b6..99ff0f1cc 100644
--- a/Sources/ContainerCommands/Image/ImageDelete.swift
+++ b/Sources/ContainerCommands/Image/ImageDelete.swift
@@ -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)
}
}
diff --git a/Sources/ContainerCommands/Image/ImageInspect.swift b/Sources/ContainerCommands/Image/ImageInspect.swift
index 010fbc8a1..1cf2a9416 100644
--- a/Sources/ContainerCommands/Image/ImageInspect.swift
+++ b/Sources/ContainerCommands/Image/ImageInspect.swift
@@ -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)] = []
diff --git a/Sources/ContainerCommands/Image/ImageList.swift b/Sources/ContainerCommands/Image/ImageList.swift
index f3335b5fc..802b5f12e 100644
--- a/Sources/ContainerCommands/Image/ImageList.swift
+++ b/Sources/ContainerCommands/Image/ImageList.swift
@@ -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
diff --git a/Sources/ContainerCommands/Image/ImagePull.swift b/Sources/ContainerCommands/Image/ImagePull.swift
index 1ff8c4aba..4693f02fc 100644
--- a/Sources/ContainerCommands/Image/ImagePull.swift
+++ b/Sources/ContainerCommands/Image/ImagePull.swift
@@ -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)
diff --git a/Sources/ContainerCommands/Image/ImagePush.swift b/Sources/ContainerCommands/Image/ImagePush.swift
index 96c1118cc..cdeeaf34b 100644
--- a/Sources/ContainerCommands/Image/ImagePush.swift
+++ b/Sources/ContainerCommands/Image/ImagePush.swift
@@ -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)
diff --git a/Sources/ContainerCommands/Image/ImageSave.swift b/Sources/ContainerCommands/Image/ImageSave.swift
index c2e330ad1..d9bcc33ac 100644
--- a/Sources/ContainerCommands/Image/ImageSave.swift
+++ b/Sources/ContainerCommands/Image/ImageSave.swift
@@ -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(
diff --git a/Sources/ContainerCommands/Image/ImageTag.swift b/Sources/ContainerCommands/Image/ImageTag.swift
index a0f83945f..6c9f162f4 100644
--- a/Sources/ContainerCommands/Image/ImageTag.swift
+++ b/Sources/ContainerCommands/Image/ImageTag.swift
@@ -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)
diff --git a/Sources/ContainerCommands/Registry/RegistryLogin.swift b/Sources/ContainerCommands/Registry/RegistryLogin.swift
index 225ea2ee9..a43e9ec24 100644
--- a/Sources/ContainerCommands/Registry/RegistryLogin.swift
+++ b/Sources/ContainerCommands/Registry/RegistryLogin.swift
@@ -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 {
diff --git a/Sources/ContainerCommands/System/Kernel/KernelSet.swift b/Sources/ContainerCommands/System/Kernel/KernelSet.swift
index 3744d5a3c..f22a2254a 100644
--- a/Sources/ContainerCommands/System/Kernel/KernelSet.swift
+++ b/Sources/ContainerCommands/System/Kernel/KernelSet.swift
@@ -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
diff --git a/Sources/ContainerCommands/System/Property/PropertyList.swift b/Sources/ContainerCommands/System/Property/PropertyList.swift
index 8cd50afd4..cf6704e99 100644
--- a/Sources/ContainerCommands/System/Property/PropertyList.swift
+++ b/Sources/ContainerCommands/System/Property/PropertyList.swift
@@ -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)
diff --git a/Sources/ContainerCommands/System/SystemStart.swift b/Sources/ContainerCommands/System/SystemStart.swift
index abd0c2374..d1d00f259 100644
--- a/Sources/ContainerCommands/System/SystemStart.swift
+++ b/Sources/ContainerCommands/System/SystemStart.swift
@@ -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.
diff --git a/Sources/ContainerPersistence/ConfigurationLoader.swift b/Sources/ContainerPersistence/ConfigurationLoader.swift
new file mode 100644
index 000000000..887ed83c1
--- /dev/null
+++ b/Sources/ContainerPersistence/ConfigurationLoader.swift
@@ -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:
+ /// `/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(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
+ /// `/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
+ /// `/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)`")
+ }
+ }
+}
diff --git a/Sources/ContainerPersistence/ContainerSystemConfig.swift b/Sources/ContainerPersistence/ContainerSystemConfig.swift
index ead3a3241..42c425e35 100644
--- a/Sources/ContainerPersistence/ContainerSystemConfig.swift
+++ b/Sources/ContainerPersistence/ContainerSystemConfig.swift
@@ -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()
diff --git a/Sources/ContainerPersistence/PathUtils.swift b/Sources/ContainerPersistence/PathUtils.swift
new file mode 100644
index 000000000..a5a8df10e
--- /dev/null
+++ b/Sources/ContainerPersistence/PathUtils.swift
@@ -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))
+ }
+ }
+ }
+}
diff --git a/Sources/ContainerPersistence/SystemRuntimeOptions.swift b/Sources/ContainerPersistence/SystemRuntimeOptions.swift
deleted file mode 100644
index 098a91278..000000000
--- a/Sources/ContainerPersistence/SystemRuntimeOptions.swift
+++ /dev/null
@@ -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(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)"])
- }
- }
- }
-}
diff --git a/Sources/Plugins/CoreImages/ImagesHelper.swift b/Sources/Plugins/CoreImages/ImagesHelper.swift
index 0a636cbb1..e7a16aa3b 100644
--- a/Sources/Plugins/CoreImages/ImagesHelper.swift
+++ b/Sources/Plugins/CoreImages/ImagesHelper.swift
@@ -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)
diff --git a/Tests/ContainerAPIClientTests/ConfigurationLoaderTests.swift b/Tests/ContainerAPIClientTests/ConfigurationLoaderTests.swift
new file mode 100644
index 000000000..dfa322418
--- /dev/null
+++ b/Tests/ContainerAPIClientTests/ConfigurationLoaderTests.swift
@@ -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)
+ }
+ }
+}
diff --git a/Tests/ContainerAPIClientTests/ParserTest.swift b/Tests/ContainerAPIClientTests/ParserTest.swift
index bc5997f41..396466003 100644
--- a/Tests/ContainerAPIClientTests/ParserTest.swift
+++ b/Tests/ContainerAPIClientTests/ParserTest.swift
@@ -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,
diff --git a/Tests/ContainerAPIClientTests/PathUtilsTests.swift b/Tests/ContainerAPIClientTests/PathUtilsTests.swift
new file mode 100644
index 000000000..4381d1aa3
--- /dev/null
+++ b/Tests/ContainerAPIClientTests/PathUtilsTests.swift
@@ -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)
+ }
+}
diff --git a/Tests/ContainerAPIClientTests/SystemRuntimeOptionsTests.swift b/Tests/ContainerAPIClientTests/SystemRuntimeOptionsTests.swift
deleted file mode 100644
index 6e7f2c4d9..000000000
--- a/Tests/ContainerAPIClientTests/SystemRuntimeOptionsTests.swift
+++ /dev/null
@@ -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")
- }
-
-}