Add support for layered and plugin configurations (#1543)

- This adds support for reading configurations from
  a three layer hierarchy:
  1. User provided TOML
  2. Install root TOML
  3. Code defaults
- We add some code to support plugin configurations
  via the ConfigurationLoader. Each plugin can provide
  a struct with an accompanying id that gets used to
  parse the scoped section of the TOML.
This commit is contained in:
Noah Thornton
2026-05-12 14:35:21 -07:00
committed by GitHub
parent 940fefb7cd
commit a967399d48
28 changed files with 1165 additions and 712 deletions
+19 -1
View File
@@ -1,5 +1,5 @@
{
"originHash" : "62087b957b4eb6a18c21f4dc5a5bbdb63b747ed5a2db0a2b93fa2b193145baea",
"originHash" : "775975c99100058763670c9f01d28783eeeedbfb84f965f63ec089e6d8696d9c",
"pins" : [
{
"identity" : "async-http-client",
@@ -118,6 +118,15 @@
"version" : "1.2.0"
}
},
{
"identity" : "swift-configuration-toml",
"kind" : "remoteSourceControl",
"location" : "https://github.com/mattt/swift-configuration-toml",
"state" : {
"revision" : "4ea16b4dfa4b023cecae5ae0368402c4d846d613",
"version" : "2.0.0"
}
},
{
"identity" : "swift-crypto",
"kind" : "remoteSourceControl",
@@ -172,6 +181,15 @@
"version" : "1.10.1"
}
},
{
"identity" : "swift-metrics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-metrics",
"state" : {
"revision" : "d51c8d13fa366eec807eedb4e37daa60ff5bfdd5",
"version" : "2.10.1"
}
},
{
"identity" : "swift-nio",
"kind" : "remoteSourceControl",
+3 -1
View File
@@ -61,6 +61,7 @@ let package = Package(
.package(url: "https://github.com/swift-server/async-http-client.git", from: "1.20.1"),
.package(url: "https://github.com/swiftlang/swift-docc-plugin.git", from: "1.1.0"),
.package(url: "https://github.com/mattt/swift-toml.git", from: "2.0.0"),
.package(url: "https://github.com/mattt/swift-configuration-toml", from: "2.0.0"),
.package(url: "https://github.com/jpsim/Yams.git", from: "6.2.1"),
],
targets: [
@@ -408,8 +409,8 @@ let package = Package(
.product(name: "Logging", package: "swift-log"),
.product(name: "Containerization", package: "containerization"),
.product(name: "Configuration", package: "swift-configuration"),
.product(name: "ConfigurationTOML", package: "swift-configuration-toml"),
.product(name: "SystemPackage", package: "swift-system"),
.product(name: "TOML", package: "swift-toml"),
"CVersion",
"ContainerVersion",
]
@@ -418,6 +419,7 @@ let package = Package(
name: "ContainerPersistenceTests",
dependencies: [
.product(name: "Configuration", package: "swift-configuration"),
.product(name: "ContainerizationExtras", package: "containerization"),
.product(name: "Logging", package: "swift-log"),
.product(name: "SystemPackage", package: "swift-system"),
"ContainerPersistence",
+1 -1
View File
@@ -50,7 +50,7 @@ extension APIServer {
var logRoot = LogRoot.path
func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load()
let containerSystemConfig: ContainerSystemConfig = try await 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 -1
View File
@@ -149,7 +149,7 @@ extension Application {
var pull: Bool = false
public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load()
let containerSystemConfig: ContainerSystemConfig = try await ConfigurationLoader.load()
do {
let timeout: Duration = .seconds(300)
let progressConfig = try ProgressConfig(
@@ -55,7 +55,7 @@ extension Application {
public init() {}
public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load()
let containerSystemConfig: ContainerSystemConfig = try await ConfigurationLoader.load()
let progressConfig = try ProgressConfig(
showTasks: true,
showItems: true,
@@ -56,7 +56,7 @@ extension Application {
var arguments: [String] = []
public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load()
let containerSystemConfig: ContainerSystemConfig = try await ConfigurationLoader.load()
let progressConfig = try ProgressConfig(
showTasks: true,
showItems: true,
@@ -63,7 +63,7 @@ extension Application {
var arguments: [String] = []
public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load()
let containerSystemConfig: ContainerSystemConfig = try await ConfigurationLoader.load()
var exitCode: Int32 = 127
let id = Utility.createContainerID(name: self.managementFlags.name)
@@ -109,7 +109,7 @@ extension Application {
}
public mutating func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load()
let containerSystemConfig: ContainerSystemConfig = try await ConfigurationLoader.load()
try await DeleteImageImplementation.removeImage(options: options, containerSystemConfig: containerSystemConfig, log: log)
}
}
@@ -45,7 +45,7 @@ extension Application {
}
public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load()
let containerSystemConfig: ContainerSystemConfig = try await ConfigurationLoader.load()
var printable: [ImageDetail] = []
var succeededImages: [String] = []
var allErrors: [(String, Error)] = []
@@ -45,7 +45,7 @@ extension Application {
public var logOptions: Flags.Logging
public mutating func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load()
let containerSystemConfig: ContainerSystemConfig = try await ConfigurationLoader.load()
try Self.validate(format: format, quiet: quiet, verbose: verbose)
var images = try await ClientImage.list().filter { img in
@@ -69,7 +69,7 @@ extension Application {
}
public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load()
let containerSystemConfig: ContainerSystemConfig = try await ConfigurationLoader.load()
let p = try DefaultPlatform.resolve(platform: platform, os: os, arch: arch, log: log)
let scheme = try RequestScheme(registry.scheme)
@@ -57,7 +57,7 @@ extension Application {
public init() {}
public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load()
let containerSystemConfig: ContainerSystemConfig = try await ConfigurationLoader.load()
let p = try DefaultPlatform.resolve(platform: platform, os: os, arch: arch, log: log)
let scheme = try RequestScheme(registry.scheme)
@@ -62,7 +62,7 @@ extension Application {
@Argument var references: [String]
public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load()
let containerSystemConfig: ContainerSystemConfig = try await ConfigurationLoader.load()
let p = try DefaultPlatform.resolve(platform: platform, os: os, arch: arch, log: log)
let progressConfig = try ProgressConfig(
@@ -36,7 +36,7 @@ extension Application {
public var logOptions: Flags.Logging
public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load()
let containerSystemConfig: ContainerSystemConfig = try await 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,7 +47,7 @@ extension Application {
var server: String
public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load()
let containerSystemConfig: ContainerSystemConfig = try await ConfigurationLoader.load()
var username = self.username
var password = ""
if passwordStdin {
@@ -53,7 +53,7 @@ extension Application {
public init() {}
public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load()
let containerSystemConfig: ContainerSystemConfig = try await ConfigurationLoader.load()
if recommended {
let url = containerSystemConfig.kernel.url
let path: String = containerSystemConfig.kernel.binaryPath
@@ -42,7 +42,7 @@ extension Application {
public init() {}
public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load()
let containerSystemConfig: ContainerSystemConfig = try await ConfigurationLoader.load()
let output =
switch format {
case .json: try Output.renderJSON(containerSystemConfig)
@@ -73,9 +73,18 @@ extension Application {
public func run() async throws {
let appRootPath = FilePath(appRoot.path(percentEncoded: false))
let installRootPath = FilePath(installRoot.path(percentEncoded: false))
try ConfigurationLoader.copyConfigurationToReadOnly(to: appRootPath)
let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load(
configurationFile: ConfigurationLoader.configurationFile(in: appRootPath))
// Pass appRoot before installRoot: ConfigurationLoader uses first-match-wins
// precedence, so user-provided config in appRoot overrides the defaults
// shipped under installRoot. Both layers are passed explicitly because
// users can override --app-root and --install-root from the CLI, and the
// loader's default search would otherwise ignore those overrides.
let containerSystemConfig: ContainerSystemConfig = try await ConfigurationLoader.load(
configurationFiles: [
ConfigurationLoader.configurationFile(in: appRootPath, of: .appRoot),
ConfigurationLoader.configurationFile(in: installRootPath, of: .installRoot),
])
// 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.
@@ -61,6 +61,16 @@ public struct ConfigSnapshotDecoder: Sendable {
_ type: T.Type,
from snapshot: ConfigSnapshotReader
) throws -> T {
if type is any UnsupportedDictionaryDecoding.Type {
throw DecodingError.typeMismatch(
T.self,
DecodingError.Context(
codingPath: [],
debugDescription:
"ConfigSnapshotDecoder does not support decoding dictionaries (got \(T.self)). Represent dynamic keys as nested structs with known property names."
)
)
}
let decoder = ConfigSnapshotDecoderImpl(
snapshot: snapshot,
codingPath: [],
@@ -20,9 +20,11 @@ import Foundation
// MARK: - Shared helpers
extension ConfigSnapshotReader {
// ConfigSnapshotReader stores typed values string(forKey:) returns nil for
// int/double/bool values. Check all primitive accessors to avoid incorrectly
// treating non-string values as nil (e.g. Optional<Int> with an .int value).
/// Returns true when the snapshot holds a primitive value at `key`, regardless of
/// type. `ConfigSnapshotReader` stores typed values each primitive accessor returns
/// nil both when the key is absent and when the stored value is of a different type.
/// Callers that need to distinguish "absent" from "present but wrong type" must check
/// `hasValue` first, then the typed accessor.
func hasValue(forKey key: ConfigKey) -> Bool {
string(forKey: key) != nil
|| int(forKey: key) != nil
@@ -31,6 +33,12 @@ extension ConfigSnapshotReader {
}
}
/// Marker used by `decode<T>` to reject `Dictionary`-valued properties. The snapshot
/// has no key-enumeration API, so a `Dictionary` would silently decode to `[:]` via
/// `allKeys == []`. We reject the attempt explicitly instead.
protocol UnsupportedDictionaryDecoding {}
extension Dictionary: UnsupportedDictionaryDecoding {}
struct KeyedContainer<Key: CodingKey>: KeyedDecodingContainerProtocol {
let snapshot: ConfigSnapshotReader
let codingPath: [any CodingKey]
@@ -45,8 +53,83 @@ struct KeyedContainer<Key: CodingKey>: KeyedDecodingContainerProtocol {
func contains(_ key: Key) -> Bool { true }
// Always return false the flat config snapshot stores nested struct keys as
// dot-separated paths (e.g. "build.cpus") but has no entry for the parent key
// itself (e.g. "build"). Returning true here would cause decodeIfPresent to skip
// structs whose child keys DO exist. Instead, we always attempt to decode and
// rely on decodeIfPresent overrides for primitive optionals.
func decodeNil(forKey key: Key) throws -> Bool {
!snapshot.hasValue(forKey: configKey(appending: key))
false
}
// MARK: - Primitive decodeIfPresent overrides
//
// Each override distinguishes three cases:
// 1. key absent return nil
// 2. key present, right type return the value
// 3. key present, wrong type throw DecodingError.typeMismatch
//
// The typed accessors on ConfigSnapshotReader collapse (1) and (3) into nil,
// so we use `hasValue` to disambiguate. Without this, a user config mistake
// like `cpus = "8"` would silently fall back to the property's default.
func decodeIfPresent(_ type: Bool.Type, forKey key: Key) throws -> Bool? {
let ck = configKey(appending: key)
guard snapshot.hasValue(forKey: ck) else { return nil }
guard let value = snapshot.bool(forKey: ck) else {
throw typeMismatch(Bool.self, at: ck, for: key)
}
return value
}
func decodeIfPresent(_ type: String.Type, forKey key: Key) throws -> String? {
let ck = configKey(appending: key)
guard snapshot.hasValue(forKey: ck) else { return nil }
guard let value = snapshot.string(forKey: ck) else {
throw typeMismatch(String.self, at: ck, for: key)
}
return value
}
func decodeIfPresent(_ type: Int.Type, forKey key: Key) throws -> Int? {
let ck = configKey(appending: key)
guard snapshot.hasValue(forKey: ck) else { return nil }
guard let value = snapshot.int(forKey: ck) else {
throw typeMismatch(Int.self, at: ck, for: key)
}
return value
}
func decodeIfPresent(_ type: Double.Type, forKey key: Key) throws -> Double? {
let ck = configKey(appending: key)
guard snapshot.hasValue(forKey: ck) else { return nil }
guard let value = snapshot.double(forKey: ck) else {
throw typeMismatch(Double.self, at: ck, for: key)
}
return value
}
func decodeIfPresent(_ type: Float.Type, forKey key: Key) throws -> Float? {
let ck = configKey(appending: key)
guard snapshot.hasValue(forKey: ck) else { return nil }
guard let value = snapshot.double(forKey: ck) else {
throw typeMismatch(Float.self, at: ck, for: key)
}
return Float(value)
}
func decodeIfPresent<T: Decodable>(_ type: T.Type, forKey key: Key) throws -> T? {
// For non-primitive Decodable types, always attempt decode.
// If the nested struct's init(from:) uses decodeIfPresent for its own keys,
// missing keys will resolve to defaults correctly.
// Catch keyNotFound/valueNotFound at this level they indicate the key is absent.
do {
return try decode(type, forKey: key)
} catch DecodingError.keyNotFound(let k, _) where k.stringValue == key.stringValue {
return nil
} catch DecodingError.valueNotFound {
return nil
}
}
func decode(_ type: Bool.Type, forKey key: Key) throws -> Bool {
@@ -106,6 +189,16 @@ struct KeyedContainer<Key: CodingKey>: KeyedDecodingContainerProtocol {
}
func decode<T: Decodable>(_ type: T.Type, forKey key: Key) throws -> T {
if type is any UnsupportedDictionaryDecoding.Type {
throw DecodingError.typeMismatch(
T.self,
DecodingError.Context(
codingPath: codingPath + [key],
debugDescription:
"ConfigSnapshotDecoder does not support decoding dictionaries (got \(T.self)). Represent dynamic keys as nested structs with known property names."
)
)
}
let impl = ConfigSnapshotDecoderImpl(
snapshot: snapshot,
codingPath: codingPath + [key],
@@ -202,6 +295,16 @@ struct KeyedContainer<Key: CodingKey>: KeyedDecodingContainerProtocol {
}
return converted
}
private func typeMismatch<T>(_ type: T.Type, at configKey: ConfigKey, for key: Key) -> DecodingError {
DecodingError.typeMismatch(
T.self,
DecodingError.Context(
codingPath: codingPath + [key],
debugDescription: "Expected \(T.self) at \"\(configKey)\" but found a value of a different type."
)
)
}
}
struct SingleValueContainer: SingleValueDecodingContainer {
@@ -211,14 +314,9 @@ struct SingleValueContainer: SingleValueDecodingContainer {
let typeDecodingStrategies: [ObjectIdentifier: AnyConfigDecodingStrategy]
// ConfigSnapshotReader stores typed values string(forKey:) returns nil for
// int/double/bool values. Check all primitive accessors to avoid incorrectly
// treating non-string values as nil (e.g. Optional<Int> with an .int value).
// int/double/bool values. `hasValue` checks all primitive accessors.
func decodeNil() -> Bool {
let key = configKey()
return snapshot.string(forKey: key) == nil
&& snapshot.int(forKey: key) == nil
&& snapshot.double(forKey: key) == nil
&& snapshot.bool(forKey: key) == nil
!snapshot.hasValue(forKey: configKey())
}
func decode(_ type: Bool.Type) throws -> Bool {
@@ -252,6 +350,16 @@ struct SingleValueContainer: SingleValueDecodingContainer {
func decode(_ type: UInt64.Type) throws -> UInt64 { try integerValue() }
func decode<T: Decodable>(_ type: T.Type) throws -> T {
if type is any UnsupportedDictionaryDecoding.Type {
throw DecodingError.typeMismatch(
T.self,
DecodingError.Context(
codingPath: codingPath,
debugDescription:
"ConfigSnapshotDecoder does not support decoding dictionaries (got \(T.self)). Represent dynamic keys as nested structs with known property names."
)
)
}
let impl = ConfigSnapshotDecoderImpl(
snapshot: snapshot,
codingPath: codingPath,
@@ -14,10 +14,11 @@
// limitations under the License.
//===----------------------------------------------------------------------===//
import Configuration
import ConfigurationTOML
import ContainerizationError
import Foundation
import SystemPackage
import TOML
public protocol Initable {
init()
@@ -25,87 +26,192 @@ public protocol Initable {
public typealias LoadableConfiguration = Codable & Sendable & Initable
public protocol LoadablePluginConfiguration: LoadableConfiguration {
static var pluginId: String { get }
}
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)
/// Returns the configuration file path for a given base kind, resolving the base
/// directory via `BaseConfigPath.basePath()` (env-driven, with fallbacks).
///
/// Use `configurationFile(in:of:)` when you need to supply an explicit base
/// e.g. a CLI flag like `--app-root` that bypasses env lookup.
///
/// - Parameter kind: The base directory role to resolve.
public static func configurationFile(_ kind: PathUtils.BaseConfigPath) -> FilePath {
configurationFile(in: kind.basePath(), of: kind)
}
/// Loads and decodes a TOML configuration file as type `T`.
/// Returns the configuration file path under an explicit base directory.
///
/// - 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 {
/// Path shape depends on `kind`:
/// - `.home`: `<base>/runtime-config.toml` (user source under `~/.config/container`)
/// - e.g. `~/.config/container/runtime-config.toml`
/// - `.appRoot`: `<base>/config/runtime-config.toml` (read-only copy of user config)
/// - e.g. `~/Library/Application Support/com.apple.container/config/runtime-config.toml`
/// - `.installRoot`: `<base>/etc/container/runtime-config.toml` (system defaults shipped with install)
/// - e.g. `/usr/local/etc/container/runtime-config.toml`
///
/// - Parameters:
/// - base: Directory to resolve against.
/// - kind: Base directory role. Defaults to `.appRoot`.
public static func configurationFile(
in base: FilePath,
of kind: PathUtils.BaseConfigPath = .appRoot
) -> FilePath {
switch kind {
case .home: base.appending(configFilename)
case .appRoot: base.appending(configDirectory).appending(configFilename)
case .installRoot: base.appending("etc/container").appending(configFilename)
}
}
/// Default ordered TOML layers consumed by `load` and `loadForPlugin`:
/// user config (`.appRoot`) followed by system defaults (`.installRoot`).
public static func defaultConfigFiles() -> [FilePath] {
[
configurationFile(.appRoot),
configurationFile(.installRoot),
]
}
/// Load the `ContainerSystemConfig` by layering TOML files with first-match-wins precedence.
///
/// Providers are consulted in the order given values from earlier files override
/// later ones. The default order is user config (`<appRoot>/config/runtime-config.toml`)
/// > system config (`<installRoot>/etc/container/config/runtime-config.toml`).
///
/// An empty `configurationFiles` array falls back to `defaultConfigFiles()`.
///
/// When a key is absent from every file, `ContainerSystemConfig.init(from:)` uses
/// `decodeIfPresent` and falls back to the property's default value "code defaults"
/// are not a provider layer.
///
/// Missing files are tolerated; malformed TOML still throws.
///
/// - Parameter configurationFiles: Ordered TOML layers, highest precedence first.
/// Defaults to `defaultConfigFiles()`.
/// - Returns: The decoded `ContainerSystemConfig`.
/// - Throws: `ContainerizationError.invalidArgument` if any layer fails to load or decode.
public static func load(
configurationFiles: [FilePath] = defaultConfigFiles()
) async throws -> ContainerSystemConfig {
try await loadAndDecode(
ContainerSystemConfig.self,
configurationFiles: configurationFiles,
decodeErrorContext: "failed to decode configuration"
)
}
/// Load a plugin-scoped configuration from the `[plugin.<P.pluginId>]` section of
/// the layered TOML files.
///
/// Uses the same layering and precedence rules as `load`, but scopes the snapshot
/// to `plugin.<P.pluginId>` before decoding. A missing `[plugin.<P.pluginId>]`
/// section falls back to `P()`.
///
/// - Parameter configurationFiles: Ordered TOML layers, highest precedence first.
/// Defaults to `defaultConfigFiles()`.
/// - Returns: The decoded plugin configuration, or `P()` if no files exist.
/// - Throws: `ContainerizationError.invalidArgument` if `P.pluginId` is empty, a
/// layer fails to load, or the `[plugin.<P.pluginId>]` section is malformed.
public static func loadForPlugin<P: LoadablePluginConfiguration>(
configurationFiles: [FilePath] = defaultConfigFiles()
) async throws -> P {
let id = P.pluginId
guard !id.isEmpty else {
throw ContainerizationError(.invalidArgument, message: "plugin id must not be empty")
}
return try await loadAndDecode(
P.self,
configurationFiles: configurationFiles,
scope: ConfigKey("plugin.\(id)"),
decodeErrorContext: "failed to decode plugin configuration for '\(id)'"
)
}
/// Shared implementation for `load` and `loadForPlugin`. Builds TOML providers
/// from `configurationFiles`, optionally scopes the snapshot, then decodes into `T`.
/// Short-circuits to `T()` when every path is missing on disk.
///
/// - Parameters:
/// - type: The concrete `LoadableConfiguration` type to decode.
/// - configurationFiles: Ordered TOML layers; empty falls back to `defaultConfigFiles()`.
/// - scope: Optional `ConfigKey` to scope the snapshot before decoding.
/// - decodeErrorContext: Prefix used in the `invalidArgument` error thrown on decode failure.
private static func loadAndDecode<T: LoadableConfiguration>(
_ type: T.Type,
configurationFiles: [FilePath],
scope: ConfigKey? = nil,
decodeErrorContext: String
) async throws -> T {
let paths = configurationFiles.isEmpty ? defaultConfigFiles() : configurationFiles
let fm = FileManager.default
if paths.allSatisfy({ !fm.fileExists(atPath: $0.string) }) {
return T()
}
var providers: [FileProvider<TOMLSnapshot>] = []
for path in paths {
do {
try providers.append(await FileProvider<TOMLSnapshot>(filePath: path, allowMissing: true))
} catch {
throw ContainerizationError(
.invalidArgument,
message: "failed to load configuration from '\(path)': \(error)"
)
}
}
let reader = ConfigReader(providers: providers)
let snapshot = scope.map { reader.snapshot().scoped(to: $0) } ?? reader.snapshot()
do {
let data = try Data(contentsOf: URL(filePath: path.string))
return try TOMLDecoder().decode(T.self, from: data)
return try ConfigSnapshotDecoder().decode(T.self, from: snapshot)
} catch {
throw ContainerizationError(
.invalidArgument,
message: "failed to load configuration from '\(path)': \(error)"
message: "\(decodeErrorContext): \(error)"
)
}
}
/// Copies a TOML configuration file into a read-only destination under an appRoot base.
/// Copies the user's runtime configuration into the app-root as a read-only snapshot.
///
/// If `source` does not exist, this is a no-op. Otherwise, any existing destination
/// is deleted and replaced with a fresh copy, which is then marked read-only.
///
/// - 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.
/// - source: File to copy from. Defaults to `<home>/container/runtime-config.toml`.
/// - destination: Directory to copy into the filename is appended automatically.
/// Defaults to `<appRoot>/config/runtime-config.toml`.
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 sourcePath = source ?? configurationFile(.home)
let destBase = destination ?? PathUtils.BaseConfigPath.appRoot.basePath()
let destPath = configurationFile(in: destBase)
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)`")
let fm = FileManager.default
guard fm.fileExists(atPath: sourcePath.string) else { return }
let destDir = destPath.removingLastComponent()
try fm.createDirectory(atPath: destDir.string, withIntermediateDirectories: true)
if fm.fileExists(atPath: destPath.string) {
try fm.setAttributes([.posixPermissions: READ_AND_WRITE], ofItemAtPath: destPath.string)
try fm.removeItem(at: URL(filePath: destPath.string))
}
try fm.copyItem(
at: URL(filePath: sourcePath.string),
to: URL(filePath: destPath.string)
)
try fm.setAttributes([.posixPermissions: READ_ONLY], ofItemAtPath: destPath.string)
}
}
@@ -14,6 +14,7 @@
// limitations under the License.
//===----------------------------------------------------------------------===//
import ContainerVersion
import Foundation
import SystemPackage
@@ -21,6 +22,7 @@ public enum PathUtils {
public enum BaseConfigPath {
case home
case appRoot
case installRoot
public func basePath(env: [String: String] = ProcessInfo.processInfo.environment) -> FilePath {
switch self {
@@ -41,6 +43,19 @@ public enum PathUtils {
in: .userDomainMask
).first!.appendingPathComponent("com.apple.container")
return FilePath(appSupportURL.path(percentEncoded: false))
case .installRoot:
if let envPath = env["CONTAINER_INSTALL_ROOT"], !envPath.isEmpty {
return FilePath(envPath)
}
// Use the kernel-recorded executable path (via _NSGetExecutablePath)
// rather than argv[0]: when the binary is invoked through PATH (e.g.
// `container ...`), argv[0] is just the basename and resolves to an
// empty FilePath, which FileManager treats as CWD-relative.
let installRootURL = CommandLine.executablePathUrl
.deletingLastPathComponent()
.appendingPathComponent("..")
.standardized
return FilePath(installRootURL.path(percentEncoded: false))
}
}
}
@@ -58,7 +58,7 @@ extension ImagesHelper {
var logRoot = LogRoot.path
func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load()
let containerSystemConfig: ContainerSystemConfig = try await ConfigurationLoader.load()
let commandName = ImagesHelper._commandName
let logPath = logRoot.map { $0.appending("\(commandName).log") }
let log = ServiceLogger.bootstrap(category: "ImagesHelper", debug: debug, logPath: logPath)
@@ -1,246 +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 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)
}
}
}
+10 -10
View File
@@ -1201,7 +1201,7 @@ struct ParserTest {
#expect(result.memoryInBytes == 256.mib())
}
@Test func testResourcesBuildPropertyLookup() throws {
@Test func testResourcesBuildPropertyLookup() async throws {
let content = """
[build]
cpus = 8
@@ -1211,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 ConfigurationLoader.load(configurationFile: FilePath(tempFile.path(percentEncoded: false)))
let config: ContainerSystemConfig = try await ConfigurationLoader.load(configurationFiles: [FilePath(tempFile.path(percentEncoded: false))])
let result = try Parser.resources(
cpus: nil, memory: nil,
defaultCPUs: config.build.cpus,
@@ -1221,7 +1221,7 @@ struct ParserTest {
#expect(result.memoryInBytes == 4096.mib())
}
@Test func testResourcesCPUsFromProperty() throws {
@Test func testResourcesCPUsFromProperty() async throws {
let content = """
[container]
cpus = 8
@@ -1230,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 ConfigurationLoader.load(configurationFile: FilePath(tempFile.path(percentEncoded: false)))
let config: ContainerSystemConfig = try await ConfigurationLoader.load(configurationFiles: [FilePath(tempFile.path(percentEncoded: false))])
let result = try Parser.resources(
cpus: nil, memory: nil,
defaultCPUs: config.container.cpus,
@@ -1239,7 +1239,7 @@ struct ParserTest {
#expect(result.cpus == 8)
}
@Test func testResourcesMemoryFromProperty() throws {
@Test func testResourcesMemoryFromProperty() async throws {
let content = """
[container]
memory = "2g"
@@ -1248,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 ConfigurationLoader.load(configurationFile: FilePath(tempFile.path(percentEncoded: false)))
let config: ContainerSystemConfig = try await ConfigurationLoader.load(configurationFiles: [FilePath(tempFile.path(percentEncoded: false))])
let result = try Parser.resources(
cpus: nil, memory: nil,
defaultCPUs: config.container.cpus,
@@ -1257,7 +1257,7 @@ struct ParserTest {
#expect(result.memoryInBytes == 2048.mib())
}
@Test func testResourcesFlagOverridesProperty() throws {
@Test func testResourcesFlagOverridesProperty() async throws {
let content = """
[container]
cpus = 8
@@ -1267,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 ConfigurationLoader.load(configurationFile: FilePath(tempFile.path(percentEncoded: false)))
let config: ContainerSystemConfig = try await ConfigurationLoader.load(configurationFiles: [FilePath(tempFile.path(percentEncoded: false))])
let result = try Parser.resources(
cpus: 1, memory: "256m",
defaultCPUs: config.container.cpus,
@@ -1277,7 +1277,7 @@ struct ParserTest {
#expect(result.memoryInBytes == 256.mib())
}
@Test func testResourcesPropertyKeysAreIsolated() throws {
@Test func testResourcesPropertyKeysAreIsolated() async throws {
let content = """
[container]
cpus = 16
@@ -1287,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 ConfigurationLoader.load(configurationFile: FilePath(tempFile.path(percentEncoded: false)))
let config: ContainerSystemConfig = try await ConfigurationLoader.load(configurationFiles: [FilePath(tempFile.path(percentEncoded: false))])
let result = try Parser.resources(
cpus: nil, memory: nil,
defaultCPUs: config.build.cpus,
@@ -19,6 +19,10 @@ import ContainerPersistence
import Foundation
import Testing
private func makeSnapshot(_ values: [AbsoluteConfigKey: ConfigValue] = [:]) -> ConfigSnapshotReader {
ConfigReader(provider: InMemoryProvider(name: "test", values: values)).snapshot()
}
struct ConfigSnapshotDecoderTests {
struct FlatConfig: Decodable, Equatable {
@@ -29,22 +33,14 @@ struct ConfigSnapshotDecoderTests {
}
@Test func decodeFlatStruct() throws {
let provider = InMemoryProvider(
name: "test",
values: [
"host": ConfigValue(.string("localhost"), isSecret: false),
"port": ConfigValue(.int(8080), isSecret: false),
"debug": ConfigValue(.bool(true), isSecret: false),
"rate": ConfigValue(.double(0.5), isSecret: false),
]
)
let reader = ConfigReader(provider: provider)
let snapshot = reader.snapshot()
let snapshot = makeSnapshot([
"host": "localhost",
"port": 8080,
"debug": true,
"rate": 0.5,
])
let config = try ConfigSnapshotDecoder().decode(FlatConfig.self, from: snapshot)
#expect(config.host == "localhost")
#expect(config.port == 8080)
#expect(config.debug == true)
#expect(config.rate == 0.5)
#expect(config == FlatConfig(host: "localhost", port: 8080, debug: true, rate: 0.5))
}
// MARK: - Nested structs
@@ -59,54 +55,125 @@ struct ConfigSnapshotDecoderTests {
}
@Test func decodeNestedStruct() throws {
let provider = InMemoryProvider(
name: "test",
values: [
"database.host": ConfigValue(.string("db.example.com"), isSecret: false),
"database.port": ConfigValue(.int(5432), isSecret: false),
]
)
let reader = ConfigReader(provider: provider)
let snapshot = reader.snapshot()
let snapshot = makeSnapshot([
"database.host": "db.example.com",
"database.port": 5432,
])
let config = try ConfigSnapshotDecoder().decode(NestedConfig.self, from: snapshot)
#expect(config.database.host == "db.example.com")
#expect(config.database.port == 5432)
#expect(config == NestedConfig(database: DatabaseConfig(host: "db.example.com", port: 5432)))
}
struct AppConfig: Decodable, Equatable {
var cluster: ClusterConfig
}
struct ClusterConfig: Decodable, Equatable {
var primary: NodeConfig
}
struct NodeConfig: Decodable, Equatable {
var host: String
var port: Int
}
@Test func decodeDeeplyNestedStruct() throws {
let snapshot = makeSnapshot([
"cluster.primary.host": "node1.example.com",
"cluster.primary.port": 9090,
])
let config = try ConfigSnapshotDecoder().decode(AppConfig.self, from: snapshot)
#expect(
config
== AppConfig(cluster: ClusterConfig(primary: NodeConfig(host: "node1.example.com", port: 9090)))
)
}
// MARK: - Optional properties
struct OptionalConfig: Decodable, Equatable {
var name: String
var nickname: String?
struct OptionalPrimitivesConfig: Decodable, Equatable {
var name: String?
var count: Int?
var rate: Double?
var ratio: Float?
var flag: Bool?
}
@Test func decodeOptionalPresent() throws {
let provider = InMemoryProvider(
name: "test",
values: [
"name": ConfigValue(.string("Alice"), isSecret: false),
"nickname": ConfigValue(.string("Ali"), isSecret: false),
]
@Test func decodeOptionalsPresent() throws {
let snapshot = makeSnapshot([
"name": "test",
"count": 3,
"rate": 0.75,
"ratio": 0.5,
"flag": true,
])
let config = try ConfigSnapshotDecoder().decode(OptionalPrimitivesConfig.self, from: snapshot)
#expect(
config == OptionalPrimitivesConfig(name: "test", count: 3, rate: 0.75, ratio: 0.5, flag: true)
)
let reader = ConfigReader(provider: provider)
let snapshot = reader.snapshot()
let config = try ConfigSnapshotDecoder().decode(OptionalConfig.self, from: snapshot)
#expect(config.name == "Alice")
#expect(config.nickname == "Ali")
}
@Test func decodeOptionalMissing() throws {
let provider = InMemoryProvider(
name: "test",
values: [
"name": ConfigValue(.string("Alice"), isSecret: false)
]
)
let reader = ConfigReader(provider: provider)
let snapshot = reader.snapshot()
let config = try ConfigSnapshotDecoder().decode(OptionalConfig.self, from: snapshot)
#expect(config.name == "Alice")
#expect(config.nickname == nil)
@Test func decodeOptionalsAbsent() throws {
let snapshot = makeSnapshot()
let config = try ConfigSnapshotDecoder().decode(OptionalPrimitivesConfig.self, from: snapshot)
#expect(config == OptionalPrimitivesConfig())
}
// Each test below provides one wrong-typed value so a mistyped user config
// surfaces as DecodingError rather than silently falling back to nil.
// See ConfigSnapshotDecoderContainers `decodeIfPresent` overrides.
@Test func decodeOptionalIntWithStringThrows() {
let snapshot = makeSnapshot(["count": "8"])
#expect(throws: DecodingError.self) {
try ConfigSnapshotDecoder().decode(OptionalPrimitivesConfig.self, from: snapshot)
}
}
@Test func decodeOptionalBoolWithIntThrows() {
let snapshot = makeSnapshot(["flag": 1])
#expect(throws: DecodingError.self) {
try ConfigSnapshotDecoder().decode(OptionalPrimitivesConfig.self, from: snapshot)
}
}
@Test func decodeOptionalStringWithBoolThrows() {
let snapshot = makeSnapshot(["name": true])
#expect(throws: DecodingError.self) {
try ConfigSnapshotDecoder().decode(OptionalPrimitivesConfig.self, from: snapshot)
}
}
@Test func decodeOptionalDoubleWithStringThrows() {
let snapshot = makeSnapshot(["rate": "0.5"])
#expect(throws: DecodingError.self) {
try ConfigSnapshotDecoder().decode(OptionalPrimitivesConfig.self, from: snapshot)
}
}
@Test func decodeOptionalFloatWithStringThrows() {
let snapshot = makeSnapshot(["ratio": "0.5"])
#expect(throws: DecodingError.self) {
try ConfigSnapshotDecoder().decode(OptionalPrimitivesConfig.self, from: snapshot)
}
}
// Regression: see KeyedContainer.decodeNil(forKey:) docs. The flat snapshot
// stores `build.cpus` but no entry for `build` itself; an earlier version of
// decodeNil returned true for `build`, causing decodeIfPresent to return nil
// even though `build.*` keys existed.
struct BuildConfig: Decodable, Equatable {
var cpus: Int
}
struct ParentConfig: Decodable, Equatable {
var build: BuildConfig?
}
@Test func decodeIfPresentNestedStruct() throws {
let snapshot = makeSnapshot(["build.cpus": 4])
let config = try ConfigSnapshotDecoder().decode(ParentConfig.self, from: snapshot)
#expect(config == ParentConfig(build: BuildConfig(cpus: 4)))
}
// MARK: - Arrays
@@ -114,117 +181,40 @@ struct ConfigSnapshotDecoderTests {
struct ArrayConfig: Decodable, Equatable {
var tags: [String]
var counts: [Int]
}
@Test func decodeArrays() throws {
let provider = InMemoryProvider(
name: "test",
values: [
"tags": ConfigValue(.stringArray(["swift", "config"]), isSecret: false),
"counts": ConfigValue(.intArray([1, 2, 3]), isSecret: false),
]
)
let reader = ConfigReader(provider: provider)
let snapshot = reader.snapshot()
let config = try ConfigSnapshotDecoder().decode(ArrayConfig.self, from: snapshot)
#expect(config.tags == ["swift", "config"])
#expect(config.counts == [1, 2, 3])
}
struct MoreArraysConfig: Decodable, Equatable {
var rates: [Double]
var flags: [Bool]
}
@Test func decodeDoubleAndBoolArrays() throws {
let provider = InMemoryProvider(
name: "test",
values: [
"rates": ConfigValue(.doubleArray([1.5, 2.5, 3.5]), isSecret: false),
"flags": ConfigValue(.boolArray([true, false, true]), isSecret: false),
]
@Test func decodeArrays() throws {
let snapshot = makeSnapshot([
"tags": ConfigValue(.stringArray(["swift", "config"]), isSecret: false),
"counts": ConfigValue(.intArray([1, 2, 3]), isSecret: false),
"rates": ConfigValue(.doubleArray([1.5, 2.5, 3.5]), isSecret: false),
"flags": ConfigValue(.boolArray([true, false, true]), isSecret: false),
])
let config = try ConfigSnapshotDecoder().decode(ArrayConfig.self, from: snapshot)
#expect(
config
== ArrayConfig(
tags: ["swift", "config"],
counts: [1, 2, 3],
rates: [1.5, 2.5, 3.5],
flags: [true, false, true]
)
)
let reader = ConfigReader(provider: provider)
let snapshot = reader.snapshot()
let config = try ConfigSnapshotDecoder().decode(MoreArraysConfig.self, from: snapshot)
#expect(config.rates == [1.5, 2.5, 3.5])
#expect(config.flags == [true, false, true])
}
// MARK: - Error cases
struct RequiredConfig: Decodable {
var name: String
var age: Int
}
@Test func decodeMissingRequiredKeyThrows() throws {
let provider = InMemoryProvider(
name: "test",
values: [
"name": ConfigValue(.string("Alice"), isSecret: false)
]
)
let reader = ConfigReader(provider: provider)
let snapshot = reader.snapshot()
#expect(throws: DecodingError.self) {
try ConfigSnapshotDecoder().decode(RequiredConfig.self, from: snapshot)
}
}
struct IntConfig: Decodable {
var count: Int
}
@Test func decodeTypeMismatchThrows() throws {
let provider = InMemoryProvider(
name: "test",
values: [
"count": ConfigValue(.string("not-a-number"), isSecret: false)
]
)
let reader = ConfigReader(provider: provider)
let snapshot = reader.snapshot()
#expect(throws: DecodingError.self) {
try ConfigSnapshotDecoder().decode(IntConfig.self, from: snapshot)
}
}
struct ArrayOfStructsConfig: Decodable {
var items: [DatabaseConfig]
}
@Test func decodeArrayOfStructsThrows() throws {
let provider = InMemoryProvider(
name: "test",
values: [:]
)
let reader = ConfigReader(provider: provider)
let snapshot = reader.snapshot()
@Test func decodeArrayOfStructsThrows() {
let snapshot = makeSnapshot()
#expect(throws: DecodingError.self) {
try ConfigSnapshotDecoder().decode(ArrayOfStructsConfig.self, from: snapshot)
}
}
// MARK: - Scoped snapshot
@Test func decodeScopedSnapshot() throws {
let provider = InMemoryProvider(
name: "test",
values: [
"app.host": ConfigValue(.string("localhost"), isSecret: false),
"app.port": ConfigValue(.int(3000), isSecret: false),
"app.debug": ConfigValue(.bool(false), isSecret: false),
"app.rate": ConfigValue(.double(1.0), isSecret: false),
]
)
let reader = ConfigReader(provider: provider)
let snapshot = reader.snapshot().scoped(to: "app")
let config = try ConfigSnapshotDecoder().decode(FlatConfig.self, from: snapshot)
#expect(config.host == "localhost")
#expect(config.port == 3000)
}
// MARK: - Custom CodingKeys
struct CustomKeysConfig: Decodable, Equatable {
@@ -238,18 +228,12 @@ struct ConfigSnapshotDecoderTests {
}
@Test func decodeCustomCodingKeys() throws {
let provider = InMemoryProvider(
name: "test",
values: [
"server-host": ConfigValue(.string("example.com"), isSecret: false),
"server-port": ConfigValue(.int(443), isSecret: false),
]
)
let reader = ConfigReader(provider: provider)
let snapshot = reader.snapshot()
let snapshot = makeSnapshot([
"server-host": "example.com",
"server-port": 443,
])
let config = try ConfigSnapshotDecoder().decode(CustomKeysConfig.self, from: snapshot)
#expect(config.serverHost == "example.com")
#expect(config.serverPort == 443)
#expect(config == CustomKeysConfig(serverHost: "example.com", serverPort: 443))
}
// MARK: - Enum with raw value
@@ -265,35 +249,129 @@ struct ConfigSnapshotDecoderTests {
}
@Test func decodeEnum() throws {
let provider = InMemoryProvider(
name: "test",
values: [
"env": ConfigValue(.string("production"), isSecret: false)
]
)
let reader = ConfigReader(provider: provider)
let snapshot = reader.snapshot()
let snapshot = makeSnapshot(["env": "production"])
let config = try ConfigSnapshotDecoder().decode(EnumConfig.self, from: snapshot)
#expect(config.env == .production)
#expect(config == EnumConfig(env: .production))
}
// MARK: - Narrow integer types
struct NarrowIntConfig: Decodable, Equatable {
var small: Int16
var unsigned: UInt8
}
@Test func decodeNarrowIntegers() throws {
let snapshot = makeSnapshot([
"small": 42,
"unsigned": 200,
])
let config = try ConfigSnapshotDecoder().decode(NarrowIntConfig.self, from: snapshot)
#expect(config == NarrowIntConfig(small: 42, unsigned: 200))
}
@Test func decodeIntegerOverflowThrows() {
let snapshot = makeSnapshot([
"small": 42,
"unsigned": 300,
])
#expect(throws: DecodingError.self) {
try ConfigSnapshotDecoder().decode(NarrowIntConfig.self, from: snapshot)
}
}
// MARK: - Float decoding
struct FloatConfig: Decodable, Equatable {
var temperature: Float
var ratio: Float
}
@Test func decodeFloat() throws {
let snapshot = makeSnapshot([
"temperature": 98.6,
"ratio": 0.333,
])
let config = try ConfigSnapshotDecoder().decode(FloatConfig.self, from: snapshot)
#expect(config == FloatConfig(temperature: Float(98.6), ratio: Float(0.333)))
}
// MARK: - Scoped snapshot
@Test func decodeScopedSnapshot() throws {
let snapshot = makeSnapshot([
"app.host": "localhost",
"app.port": 3000,
"app.debug": false,
"app.rate": 1.0,
]).scoped(to: "app")
let config = try ConfigSnapshotDecoder().decode(FlatConfig.self, from: snapshot)
#expect(config == FlatConfig(host: "localhost", port: 3000, debug: false, rate: 1.0))
}
@Test func decodeTopLevelPrimitive() throws {
let snapshot = makeSnapshot(["value": 42]).scoped(to: "value")
let value = try ConfigSnapshotDecoder().decode(Int.self, from: snapshot)
#expect(value == 42)
}
// MARK: - Error cases
struct RequiredConfig: Decodable {
var name: String
var age: Int
}
@Test func decodeMissingRequiredKeyThrows() {
let snapshot = makeSnapshot(["name": "Alice"])
#expect(throws: DecodingError.self) {
try ConfigSnapshotDecoder().decode(RequiredConfig.self, from: snapshot)
}
}
struct IntConfig: Decodable {
var count: Int
}
@Test func decodeTypeMismatchThrows() {
let snapshot = makeSnapshot(["count": "not-a-number"])
#expect(throws: DecodingError.self) {
try ConfigSnapshotDecoder().decode(IntConfig.self, from: snapshot)
}
}
// Regression: see UnsupportedDictionaryDecoding marker docs. Dictionaries
// would otherwise silently decode to [:] because the snapshot has no
// key-enumeration API.
struct DictConfig: Decodable {
var tags: [String: String]
}
@Test func decodeDictionaryPropertyThrows() {
let snapshot = makeSnapshot(["tags.env": "prod"])
#expect(throws: DecodingError.self) {
try ConfigSnapshotDecoder().decode(DictConfig.self, from: snapshot)
}
}
@Test func decodeTopLevelDictionaryThrows() {
let snapshot = makeSnapshot()
#expect(throws: DecodingError.self) {
try ConfigSnapshotDecoder().decode([String: Int].self, from: snapshot)
}
}
// MARK: - URL string fallback
struct URLConfig: Decodable {
struct URLConfig: Decodable, Equatable {
var endpoint: URL
}
@Test func decodeURL() throws {
let provider = InMemoryProvider(
name: "test",
values: [
"endpoint": ConfigValue(.string("https://example.com/api"), isSecret: false)
]
)
let reader = ConfigReader(provider: provider)
let snapshot = reader.snapshot()
let snapshot = makeSnapshot(["endpoint": "https://example.com/api"])
let config = try ConfigSnapshotDecoder().decode(URLConfig.self, from: snapshot)
#expect(config.endpoint == URL(string: "https://example.com/api")!)
#expect(config == URLConfig(endpoint: URL(string: "https://example.com/api")!))
}
// MARK: - Custom decoding strategies
@@ -316,78 +394,60 @@ struct ConfigSnapshotDecoderTests {
}
}
@Test func customStrategyOverridesURL() throws {
let provider = InMemoryProvider(
name: "test",
values: [
"endpoint": ConfigValue(.string("/api/v1"), isSecret: false)
]
)
let reader = ConfigReader(provider: provider)
let snapshot = reader.snapshot()
@Test func decodeURLWithCustomStrategy() throws {
let snapshot = makeSnapshot(["endpoint": "/api/v1"])
let decoder = ConfigSnapshotDecoder(
decodingStrategies: [PrefixURLStrategy(base: "https://example.com")]
)
let config = try decoder.decode(URLConfig.self, from: snapshot)
#expect(config.endpoint == URL(string: "https://example.com/api/v1")!)
#expect(config == URLConfig(endpoint: URL(string: "https://example.com/api/v1")!))
}
// MARK: - Narrow integer types
struct NarrowIntConfig: Decodable, Equatable {
var small: Int16
var unsigned: UInt8
}
@Test func decodeNarrowIntegers() throws {
let provider = InMemoryProvider(
name: "test",
values: [
"small": ConfigValue(.int(42), isSecret: false),
"unsigned": ConfigValue(.int(200), isSecret: false),
]
)
let reader = ConfigReader(provider: provider)
let snapshot = reader.snapshot()
let config = try ConfigSnapshotDecoder().decode(NarrowIntConfig.self, from: snapshot)
#expect(config.small == 42)
#expect(config.unsigned == 200)
}
@Test func decodeIntegerOverflowThrows() throws {
let provider = InMemoryProvider(
name: "test",
values: [
"small": ConfigValue(.int(42), isSecret: false),
"unsigned": ConfigValue(.int(300), isSecret: false),
]
)
let reader = ConfigReader(provider: provider)
let snapshot = reader.snapshot()
#expect(throws: DecodingError.self) {
try ConfigSnapshotDecoder().decode(NarrowIntConfig.self, from: snapshot)
}
}
@Test func removeStrategyRevertsToDecodable() throws {
let provider = InMemoryProvider(
name: "test",
values: [
"endpoint": ConfigValue(.string("https://example.com/api"), isSecret: false)
]
)
let reader = ConfigReader(provider: provider)
let snapshot = reader.snapshot()
@Test func decodeURLWithoutStrategyThrows() {
let snapshot = makeSnapshot(["endpoint": "https://example.com/api"])
let decoder = ConfigSnapshotDecoder(decodingStrategies: [])
// Without the URL strategy, URL.init(from:) is used. URL's default
// Decodable expects a keyed container with "relative" and optional
// "base" keys, so decoding a plain string should fail.
#expect(throws: DecodingError.self) {
try decoder.decode(URLConfig.self, from: snapshot)
}
}
struct Seconds: Decodable {
struct NestedURLConfig: Decodable, Equatable {
var service: ServiceConfig
}
struct ServiceConfig: Decodable, Equatable {
var endpoint: URL
var name: String
}
@Test func decodeNestedStructWithURLStrategy() throws {
let snapshot = makeSnapshot([
"service.endpoint": "https://nested.example.com",
"service.name": "api",
])
let config = try ConfigSnapshotDecoder().decode(NestedURLConfig.self, from: snapshot)
#expect(
config
== NestedURLConfig(
service: ServiceConfig(
endpoint: URL(string: "https://nested.example.com")!,
name: "api"
)
)
)
}
struct OptionalURLConfig: Decodable, Equatable {
var endpoint: URL?
}
@Test func decodeOptionalURLWithStrategy() throws {
let snapshot = makeSnapshot(["endpoint": "https://example.com"])
let config = try ConfigSnapshotDecoder().decode(OptionalURLConfig.self, from: snapshot)
#expect(config == OptionalURLConfig(endpoint: URL(string: "https://example.com")!))
}
struct Seconds: Decodable, Equatable {
var value: Int
}
@@ -399,144 +459,17 @@ struct ConfigSnapshotDecoderTests {
}
}
struct TimerConfig: Decodable {
struct TimerConfig: Decodable, Equatable {
var timeout: Seconds
}
@Test func customStrategyForUserType() throws {
let provider = InMemoryProvider(
name: "test",
values: [
"timeout": ConfigValue(.int(30), isSecret: false)
]
)
let reader = ConfigReader(provider: provider)
let snapshot = reader.snapshot()
@Test func decodeUserTypeWithCustomStrategy() throws {
let snapshot = makeSnapshot(["timeout": 30])
let decoder = ConfigSnapshotDecoder(decodingStrategies: [
URLConfigDecodingStrategy(),
SecondsStrategy(),
])
let config = try decoder.decode(TimerConfig.self, from: snapshot)
#expect(config.timeout.value == 30)
}
struct NestedURLConfig: Decodable {
var service: ServiceConfig
}
struct ServiceConfig: Decodable {
var endpoint: URL
var name: String
}
@Test func strategyWorksInNestedStruct() throws {
let provider = InMemoryProvider(
name: "test",
values: [
"service.endpoint": ConfigValue(.string("https://nested.example.com"), isSecret: false),
"service.name": ConfigValue(.string("api"), isSecret: false),
]
)
let reader = ConfigReader(provider: provider)
let snapshot = reader.snapshot()
let config = try ConfigSnapshotDecoder().decode(NestedURLConfig.self, from: snapshot)
#expect(config.service.endpoint == URL(string: "https://nested.example.com")!)
#expect(config.service.name == "api")
}
// MARK: - Float decoding
struct FloatConfig: Decodable, Equatable {
var temperature: Float
var ratio: Float
}
@Test func decodeFloat() throws {
let provider = InMemoryProvider(
name: "test",
values: [
"temperature": ConfigValue(.double(98.6), isSecret: false),
"ratio": ConfigValue(.double(0.333), isSecret: false),
]
)
let reader = ConfigReader(provider: provider)
let snapshot = reader.snapshot()
let config = try ConfigSnapshotDecoder().decode(FloatConfig.self, from: snapshot)
#expect(config.temperature == Float(98.6))
#expect(config.ratio == Float(0.333))
}
// MARK: - Deeply nested structs (3+ levels)
struct AppConfig: Decodable, Equatable {
var cluster: ClusterConfig
}
struct ClusterConfig: Decodable, Equatable {
var primary: NodeConfig
}
struct NodeConfig: Decodable, Equatable {
var host: String
var port: Int
}
@Test func decodeDeeplyNestedStruct() throws {
let provider = InMemoryProvider(
name: "test",
values: [
"cluster.primary.host": ConfigValue(.string("node1.example.com"), isSecret: false),
"cluster.primary.port": ConfigValue(.int(9090), isSecret: false),
]
)
let reader = ConfigReader(provider: provider)
let snapshot = reader.snapshot()
let config = try ConfigSnapshotDecoder().decode(AppConfig.self, from: snapshot)
#expect(config.cluster.primary.host == "node1.example.com")
#expect(config.cluster.primary.port == 9090)
}
// MARK: - Optional non-string types
struct OptionalIntConfig: Decodable, Equatable {
var name: String
var retries: Int?
var verbose: Bool?
var rate: Double?
}
@Test func decodeOptionalNonStringPresent() throws {
let provider = InMemoryProvider(
name: "test",
values: [
"name": ConfigValue(.string("test"), isSecret: false),
"retries": ConfigValue(.int(3), isSecret: false),
"verbose": ConfigValue(.bool(true), isSecret: false),
"rate": ConfigValue(.double(0.75), isSecret: false),
]
)
let reader = ConfigReader(provider: provider)
let snapshot = reader.snapshot()
let config = try ConfigSnapshotDecoder().decode(OptionalIntConfig.self, from: snapshot)
#expect(config.name == "test")
#expect(config.retries == 3)
#expect(config.verbose == true)
#expect(config.rate == 0.75)
}
@Test func decodeOptionalNonStringMissing() throws {
let provider = InMemoryProvider(
name: "test",
values: [
"name": ConfigValue(.string("test"), isSecret: false)
]
)
let reader = ConfigReader(provider: provider)
let snapshot = reader.snapshot()
let config = try ConfigSnapshotDecoder().decode(OptionalIntConfig.self, from: snapshot)
#expect(config.name == "test")
#expect(config.retries == nil)
#expect(config.verbose == nil)
#expect(config.rate == nil)
#expect(config == TimerConfig(timeout: Seconds(value: 30)))
}
}
@@ -0,0 +1,492 @@
//===----------------------------------------------------------------------===//
// 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
)
}
/// Lenient plugin config used by most `loadForPlugin` tests: missing `cpu`
/// decodes to the default of 99, so tests can assert "default" vs "loaded".
private struct TestPluginConfig: LoadablePluginConfiguration {
static let pluginId = "foo"
var cpu: Int
init() { self.cpu = 99 }
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.cpu = try container.decodeIfPresent(Int.self, forKey: .cpu) ?? 99
}
}
/// A structurally different plugin config strict decode of `memory`.
/// Used by:
/// - `loadForPluginIsolatesFromHostileSibling`: paired with a sibling
/// `[plugin.*]` section that also defines `memory`, so a scoping leak
/// would surface as a wrong value rather than a thrown error.
/// - `loadForPluginMalformedSectionThrows`: exercises strict decode against
/// a malformed target section.
private struct TestPluginConfig2: LoadablePluginConfiguration {
static let pluginId = "mem"
var memory: String
init() { self.memory = "1g" }
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.memory = try container.decode(String.self, forKey: .memory)
}
}
/// Plugin config with an empty `pluginId` to exercise the empty-id guard.
private struct EmptyIdPluginConfig: LoadablePluginConfiguration {
static let pluginId = ""
init() {}
}
@Test func layeredFilesPerKeyPrecedence() async throws {
try await TemporaryStorage.withTempDir { tempDir in
let appRoot = tempDir.appending("appRoot.toml")
let installRoot = tempDir.appending("installRoot.toml")
try Self.writeToml("[build]\nrosetta = false\n", to: appRoot)
try Self.writeToml("[registry]\ndomain = \"foo.bar\"\n", to: installRoot)
let config: ContainerSystemConfig = try await ConfigurationLoader.load(
configurationFiles: [appRoot, installRoot]
)
#expect(config.build.rosetta == false)
#expect(config.registry.domain == "foo.bar")
}
}
@Test func defaultsWithNoFile() async throws {
try await TemporaryStorage.withTempDir { tempDir in
let path = tempDir.appending("nonexistent.toml")
let config: ContainerSystemConfig = try await ConfigurationLoader.load(configurationFiles: [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 tomlOverrideAllKeys() 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 await ConfigurationLoader.load(configurationFiles: [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 partialToml() 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 await ConfigurationLoader.load(configurationFiles: [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 unknownKeysIgnored() 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 await ConfigurationLoader.load(configurationFiles: [tmpFile])
#expect(config.build.cpus == 4)
#expect(config.build.rosetta == true)
}
}
@Test func invalidTomlThrows() 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)
await #expect(throws: (any Error).self) {
let _: ContainerSystemConfig = try await ConfigurationLoader.load(configurationFiles: [tmpFile])
}
}
}
@Test func emptyTomlDecodesToDefaults() async throws {
try await TemporaryStorage.withTempDir { tempDir in
let tmpFile = tempDir.appending("test.toml")
try Self.writeToml("", to: tmpFile)
let config: ContainerSystemConfig = try await ConfigurationLoader.load(configurationFiles: [tmpFile])
#expect(config.build.rosetta == true)
#expect(config.build.cpus == 2)
#expect(config.container.cpus == 4)
#expect(config.registry.domain == "docker.io")
}
}
@Test func copyConfigToAppRoot() 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 = destBase.appending("config").appending("runtime-config.toml")
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 = try #require(attrs[.posixPermissions] as? Int)
#expect(perms == 0o444)
}
}
@Test func copyConfigOverwritesExistingReadOnlyDestination() 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 = destBase.appending("config").appending("runtime-config.toml")
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 = try #require(attrs[.posixPermissions] as? Int)
#expect(perms == 0o444)
}
}
@Test func copyConfigNoOpsWhenSourceMissing() 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 = destBase.appending("config").appending("runtime-config.toml")
#expect(!FileManager.default.fileExists(atPath: destFile.string))
}
}
@Test func loadForPluginDecodesQualifiedSection() async throws {
try await TemporaryStorage.withTempDir { tempDir in
let toml = """
[build]
cpus = 8
[plugin.foo]
cpu = 4
[plugin.other]
unrelated = "value that would not decode as TestPluginConfig"
"""
let tmpFile = tempDir.appending("test.toml")
try Self.writeToml(toml, to: tmpFile)
let foo: TestPluginConfig = try await ConfigurationLoader.loadForPlugin(
configurationFiles: [tmpFile])
#expect(foo.cpu == 4)
}
}
@Test func loadForPluginMissingSectionReturnsDefault() async throws {
try await TemporaryStorage.withTempDir { tempDir in
let toml = """
[build]
cpus = 8
"""
let tmpFile = tempDir.appending("test.toml")
try Self.writeToml(toml, to: tmpFile)
let foo: TestPluginConfig = try await ConfigurationLoader.loadForPlugin(
configurationFiles: [tmpFile])
#expect(foo.cpu == 99)
}
}
@Test func loadForPluginSubsectionMissingReturnsDefault() async throws {
try await TemporaryStorage.withTempDir { tempDir in
let toml = """
[plugin.other]
cpu = 4
"""
let tmpFile = tempDir.appending("test.toml")
try Self.writeToml(toml, to: tmpFile)
let foo: TestPluginConfig = try await ConfigurationLoader.loadForPlugin(
configurationFiles: [tmpFile])
#expect(foo.cpu == 99)
}
}
@Test func loadForPluginFileMissingReturnsDefault() async throws {
try await TemporaryStorage.withTempDir { tempDir in
let missing = tempDir.appending("nonexistent.toml")
let foo: TestPluginConfig = try await ConfigurationLoader.loadForPlugin(
configurationFiles: [missing])
#expect(foo.cpu == 99)
}
}
@Test func loadForPluginIsolatesFromHostileSibling() async throws {
try await TemporaryStorage.withTempDir { tempDir in
let toml = """
[plugin.mem]
memory = "2g"
[plugin.other]
memory = "999g"
"""
let tmpFile = tempDir.appending("test.toml")
try Self.writeToml(toml, to: tmpFile)
let mem: TestPluginConfig2 = try await ConfigurationLoader.loadForPlugin(
configurationFiles: [tmpFile])
#expect(mem.memory == "2g")
}
}
@Test func loadForPluginMalformedSectionThrows() async throws {
try await TemporaryStorage.withTempDir { tempDir in
let toml = """
[plugin.mem]
memory = 42
"""
let tmpFile = tempDir.appending("test.toml")
try Self.writeToml(toml, to: tmpFile)
await #expect(throws: (any Error).self) {
let _: TestPluginConfig2 = try await ConfigurationLoader.loadForPlugin(
configurationFiles: [tmpFile])
}
}
}
@Test func loadForPluginEmptyIdThrows() async throws {
try await TemporaryStorage.withTempDir { tempDir in
let tmpFile = tempDir.appending("test.toml")
try Self.writeToml("[plugin.foo]\ncpu = 4", to: tmpFile)
await #expect(throws: (any Error).self) {
let _: EmptyIdPluginConfig = try await ConfigurationLoader.loadForPlugin(
configurationFiles: [tmpFile])
}
}
}
@Test func layeredPrecedence() async throws {
try await TemporaryStorage.withTempDir { tempDir in
let userFile = tempDir.appending("user.toml")
let systemFile = tempDir.appending("system.toml")
try Self.writeToml(
"""
[build]
cpus = 8
""", to: userFile)
try Self.writeToml(
"""
[build]
cpus = 4
memory = "4096MB"
""", to: systemFile)
let config: ContainerSystemConfig = try await ConfigurationLoader.load(
configurationFiles: [userFile, systemFile]
)
#expect(config.build.cpus == 8)
let expectedMemory = try MemorySize("4096MB")
#expect(config.build.memory == expectedMemory)
}
}
@Test func allFilesMissingReturnsDefaults() async throws {
let config: ContainerSystemConfig = try await ConfigurationLoader.load(
configurationFiles: [FilePath("/nonexistent/a.toml"), FilePath("/nonexistent/b.toml")]
)
#expect(config.build.rosetta == true)
#expect(config.build.cpus == 2)
}
@Test func partialOverlapMergesKeys() async throws {
try await TemporaryStorage.withTempDir { tempDir in
let userFile = tempDir.appending("user.toml")
let systemFile = tempDir.appending("system.toml")
try Self.writeToml(
"""
[dns]
domain = "user.local"
""", to: userFile)
try Self.writeToml(
"""
[build]
cpus = 16
""", to: systemFile)
let config: ContainerSystemConfig = try await ConfigurationLoader.load(
configurationFiles: [userFile, systemFile]
)
#expect(config.dns.domain == "user.local")
#expect(config.build.cpus == 16)
}
}
@Test func pluginLayering() async throws {
try await TemporaryStorage.withTempDir { tempDir in
let userFile = tempDir.appending("user.toml")
let systemFile = tempDir.appending("system.toml")
try Self.writeToml(
"""
[plugin.foo]
cpu = 8
""", to: userFile)
try Self.writeToml(
"""
[plugin.foo]
cpu = 2
""", to: systemFile)
let config: TestPluginConfig = try await ConfigurationLoader.loadForPlugin(
configurationFiles: [userFile, systemFile]
)
#expect(config.cpu == 8)
}
}
@Test func loadErrorIdentifiesMalformedLayerFile() async throws {
try await TemporaryStorage.withTempDir { tempDir in
let userFile = tempDir.appending("user.toml")
let systemFile = tempDir.appending("system.toml")
try Self.writeToml("[build]\ncpus = 8", to: userFile)
try Self.writeToml("this is [not valid toml", to: systemFile)
let error = try #require(
await #expect(throws: (any Error).self) {
let _: ContainerSystemConfig = try await ConfigurationLoader.load(
configurationFiles: [userFile, systemFile])
}
)
#expect(String(describing: error).contains(systemFile.string))
}
}
@Test func loadForPluginErrorIdentifiesMalformedLayerFile() async throws {
try await TemporaryStorage.withTempDir { tempDir in
let userFile = tempDir.appending("user.toml")
let systemFile = tempDir.appending("system.toml")
try Self.writeToml("this is [not valid toml", to: userFile)
try Self.writeToml("[plugin.foo]\ncpu = 2", to: systemFile)
let error = try #require(
await #expect(throws: (any Error).self) {
let _: TestPluginConfig = try await ConfigurationLoader.loadForPlugin(
configurationFiles: [userFile, systemFile])
}
)
#expect(String(describing: error).contains(userFile.string))
}
}
}
@@ -15,6 +15,7 @@
//===----------------------------------------------------------------------===//
import ContainerPersistence
import ContainerVersion
import Foundation
import SystemPackage
import Testing
@@ -68,4 +69,9 @@ struct PathUtilsTests {
let path = PathUtils.BaseConfigPath.home.basePath(env: ["CONTAINER_APP_ROOT": "/tmp/foo"])
#expect(path == Self.homeFallback)
}
@Test func testInstallRootFromEnvVar() {
let path = PathUtils.BaseConfigPath.installRoot.basePath(env: ["CONTAINER_INSTALL_ROOT": "/usr/local"])
#expect(path == FilePath("/usr/local"))
}
}