mirror of
https://github.com/apple/container.git
synced 2026-08-24 10:05:43 -05:00
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:
+19
-1
@@ -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
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user