chore: import upstream snapshot with attribution
container project - merge build / Invoke build (push) Successful in 1s
container project - merge build / Invoke build (push) Successful in 1s
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// A type that provides custom decoding logic for a specific `Decodable` type
|
||||
/// when using `ConfigSnapshotDecoder`. This enables modification of `Decodable` behavior
|
||||
/// for types that already conform to `Decodable`, such as `URL` and `Measurement`.
|
||||
public protocol ConfigDecodingStrategy: Sendable {
|
||||
associatedtype Value: Decodable
|
||||
func decode(from decoder: Decoder) throws -> Value
|
||||
}
|
||||
|
||||
/// A type that provides custom encoding logic for a specific `Encodable` type.
|
||||
/// This enables modification of `Encodable` behavior for types that already conform
|
||||
// to `Encodable`, such as `URL` and `Measurement`.
|
||||
public protocol ConfigEncodingStrategy: Sendable {
|
||||
associatedtype Value: Encodable
|
||||
func encode(_ value: Value, to encoder: Encoder) throws
|
||||
}
|
||||
|
||||
/// A type that provides both custom encoding and decoding for the same type. Similar to `Codable`
|
||||
public typealias ConfigCodingStrategy = ConfigDecodingStrategy & ConfigEncodingStrategy
|
||||
|
||||
/// Decodes a `URL` from a string value. e.g. "https://www.apple.com"
|
||||
public struct URLConfigDecodingStrategy: ConfigDecodingStrategy {
|
||||
public init() {}
|
||||
|
||||
public func decode(from decoder: Decoder) throws -> URL {
|
||||
let container = try decoder.singleValueContainer()
|
||||
let string = try container.decode(String.self)
|
||||
guard let url = URL(string: string) else {
|
||||
throw DecodingError.dataCorrupted(
|
||||
DecodingError.Context(
|
||||
codingPath: decoder.codingPath,
|
||||
debugDescription: "Invalid URL string: \"\(string)\"."
|
||||
)
|
||||
)
|
||||
}
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
// A type erased version of `ConfigDecodingStrategy`
|
||||
struct AnyConfigDecodingStrategy: Sendable {
|
||||
let valueTypeID: ObjectIdentifier
|
||||
private let decode: @Sendable (Decoder) throws -> Any
|
||||
|
||||
init<S: ConfigDecodingStrategy>(_ strategy: S) {
|
||||
self.valueTypeID = ObjectIdentifier(S.Value.self)
|
||||
self.decode = { decoder in try strategy.decode(from: decoder) as Any }
|
||||
}
|
||||
|
||||
func decode(from decoder: Decoder) throws -> Any {
|
||||
try decode(decoder)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 Configuration
|
||||
|
||||
/// A decoder that decodes `Decodable` types from a ``ConfigSnapshotReader``.
|
||||
///
|
||||
/// `ConfigSnapshotDecoder` bridges Swift's `Decodable` protocol with the configuration
|
||||
/// provider system, allowing you to decode structured configuration into typed
|
||||
/// Swift values.
|
||||
///
|
||||
/// ```swift
|
||||
/// struct AppConfig: Decodable {
|
||||
/// var host: String
|
||||
/// var port: Int
|
||||
/// var database: DatabaseConfig
|
||||
/// }
|
||||
///
|
||||
/// struct DatabaseConfig: Decodable {
|
||||
/// var connectionString: String
|
||||
/// var maxConnections: Int
|
||||
/// }
|
||||
///
|
||||
/// let reader = ConfigReader(providers: [envProvider, jsonProvider])
|
||||
/// let snapshot = reader.snapshot()
|
||||
/// let config = try ConfigSnapshotDecoder().decode(AppConfig.self, from: snapshot)
|
||||
/// ```
|
||||
///
|
||||
/// Nested structs map to dot-separated key paths. In the example above,
|
||||
/// `database.connectionString` and `database.maxConnections` are looked up
|
||||
/// from the snapshot.
|
||||
public struct ConfigSnapshotDecoder: Sendable {
|
||||
|
||||
public var userInfo: [CodingUserInfoKey: any Sendable]
|
||||
private let typeDecodingStrategies: [ObjectIdentifier: AnyConfigDecodingStrategy]
|
||||
|
||||
public init(decodingStrategies: [any ConfigDecodingStrategy] = [URLConfigDecodingStrategy()]) {
|
||||
self.userInfo = [:]
|
||||
var strategies: [ObjectIdentifier: AnyConfigDecodingStrategy] = [:]
|
||||
for strategy in decodingStrategies {
|
||||
let erased = AnyConfigDecodingStrategy(strategy)
|
||||
strategies[erased.valueTypeID] = erased
|
||||
}
|
||||
self.typeDecodingStrategies = strategies
|
||||
}
|
||||
|
||||
public func decode<T: Decodable>(
|
||||
_ 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: [],
|
||||
userInfo: userInfo.mapValues { $0 as Any },
|
||||
typeDecodingStrategies: typeDecodingStrategies
|
||||
)
|
||||
return try T(from: decoder)
|
||||
}
|
||||
}
|
||||
|
||||
struct ConfigSnapshotDecoderImpl: Decoder {
|
||||
let snapshot: ConfigSnapshotReader
|
||||
let codingPath: [any CodingKey]
|
||||
let userInfo: [CodingUserInfoKey: Any]
|
||||
let typeDecodingStrategies: [ObjectIdentifier: AnyConfigDecodingStrategy]
|
||||
|
||||
func container<Key: CodingKey>(
|
||||
keyedBy type: Key.Type
|
||||
) throws -> KeyedDecodingContainer<Key> {
|
||||
KeyedDecodingContainer(
|
||||
KeyedContainer<Key>(
|
||||
snapshot: snapshot,
|
||||
codingPath: codingPath,
|
||||
userInfo: userInfo,
|
||||
typeDecodingStrategies: typeDecodingStrategies
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func unkeyedContainer() throws -> any UnkeyedDecodingContainer {
|
||||
UnkeyedContainer(
|
||||
snapshot: snapshot,
|
||||
codingPath: codingPath,
|
||||
userInfo: userInfo,
|
||||
typeDecodingStrategies: typeDecodingStrategies
|
||||
)
|
||||
}
|
||||
|
||||
func singleValueContainer() throws -> any SingleValueDecodingContainer {
|
||||
SingleValueContainer(
|
||||
snapshot: snapshot,
|
||||
codingPath: codingPath,
|
||||
userInfo: userInfo,
|
||||
typeDecodingStrategies: typeDecodingStrategies
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,659 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 Configuration
|
||||
import Foundation
|
||||
|
||||
// MARK: - Shared helpers
|
||||
|
||||
extension ConfigSnapshotReader {
|
||||
/// 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
|
||||
|| double(forKey: key) != nil
|
||||
|| bool(forKey: key) != nil
|
||||
}
|
||||
}
|
||||
|
||||
/// 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]
|
||||
let userInfo: [CodingUserInfoKey: Any]
|
||||
let typeDecodingStrategies: [ObjectIdentifier: AnyConfigDecodingStrategy]
|
||||
|
||||
// ConfigSnapshotReader has no "key exists" API, so allKeys cannot enumerate
|
||||
// available keys and contains always returns true. This works for structs with
|
||||
// known properties. Types that iterate allKeys for dynamic keys (e.g. dictionaries)
|
||||
// will see an empty collection.
|
||||
var allKeys: [Key] { [] }
|
||||
|
||||
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 {
|
||||
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 {
|
||||
try decodeValue(forKey: key) { try snapshot.requiredBool(forKey: $0) }
|
||||
}
|
||||
|
||||
func decode(_ type: String.Type, forKey key: Key) throws -> String {
|
||||
try decodeValue(forKey: key) { try snapshot.requiredString(forKey: $0) }
|
||||
}
|
||||
|
||||
func decode(_ type: Double.Type, forKey key: Key) throws -> Double {
|
||||
try decodeValue(forKey: key) { try snapshot.requiredDouble(forKey: $0) }
|
||||
}
|
||||
|
||||
func decode(_ type: Float.Type, forKey key: Key) throws -> Float {
|
||||
Float(try decodeValue(forKey: key) { try snapshot.requiredDouble(forKey: $0) })
|
||||
}
|
||||
|
||||
func decode(_ type: Int.Type, forKey key: Key) throws -> Int {
|
||||
try decodeValue(forKey: key) { try snapshot.requiredInt(forKey: $0) }
|
||||
}
|
||||
|
||||
func decode(_ type: Int8.Type, forKey key: Key) throws -> Int8 {
|
||||
try integerValue(forKey: key)
|
||||
}
|
||||
|
||||
func decode(_ type: Int16.Type, forKey key: Key) throws -> Int16 {
|
||||
try integerValue(forKey: key)
|
||||
}
|
||||
|
||||
func decode(_ type: Int32.Type, forKey key: Key) throws -> Int32 {
|
||||
try integerValue(forKey: key)
|
||||
}
|
||||
|
||||
func decode(_ type: Int64.Type, forKey key: Key) throws -> Int64 {
|
||||
try integerValue(forKey: key)
|
||||
}
|
||||
|
||||
func decode(_ type: UInt.Type, forKey key: Key) throws -> UInt {
|
||||
try integerValue(forKey: key)
|
||||
}
|
||||
|
||||
func decode(_ type: UInt8.Type, forKey key: Key) throws -> UInt8 {
|
||||
try integerValue(forKey: key)
|
||||
}
|
||||
|
||||
func decode(_ type: UInt16.Type, forKey key: Key) throws -> UInt16 {
|
||||
try integerValue(forKey: key)
|
||||
}
|
||||
|
||||
func decode(_ type: UInt32.Type, forKey key: Key) throws -> UInt32 {
|
||||
try integerValue(forKey: key)
|
||||
}
|
||||
|
||||
func decode(_ type: UInt64.Type, forKey key: Key) throws -> UInt64 {
|
||||
try integerValue(forKey: key)
|
||||
}
|
||||
|
||||
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],
|
||||
userInfo: userInfo,
|
||||
typeDecodingStrategies: typeDecodingStrategies
|
||||
)
|
||||
if let strategy = typeDecodingStrategies[ObjectIdentifier(type)] {
|
||||
guard let typed = try strategy.decode(from: impl) as? T else {
|
||||
throw DecodingError.typeMismatch(
|
||||
T.self,
|
||||
DecodingError.Context(
|
||||
codingPath: codingPath + [key],
|
||||
debugDescription: "Strategy returned value of unexpected type for \(T.self)."
|
||||
)
|
||||
)
|
||||
}
|
||||
return typed
|
||||
}
|
||||
return try T(from: impl)
|
||||
}
|
||||
|
||||
func nestedContainer<NestedKey: CodingKey>(
|
||||
keyedBy type: NestedKey.Type,
|
||||
forKey key: Key
|
||||
) throws -> KeyedDecodingContainer<NestedKey> {
|
||||
KeyedDecodingContainer(
|
||||
KeyedContainer<NestedKey>(
|
||||
snapshot: snapshot,
|
||||
codingPath: codingPath + [key],
|
||||
userInfo: userInfo,
|
||||
typeDecodingStrategies: typeDecodingStrategies
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func nestedUnkeyedContainer(forKey key: Key) throws -> any UnkeyedDecodingContainer {
|
||||
UnkeyedContainer(
|
||||
snapshot: snapshot,
|
||||
codingPath: codingPath + [key],
|
||||
userInfo: userInfo,
|
||||
typeDecodingStrategies: typeDecodingStrategies
|
||||
)
|
||||
}
|
||||
|
||||
func superDecoder() throws -> any Decoder {
|
||||
throw DecodingError.dataCorrupted(
|
||||
DecodingError.Context(
|
||||
codingPath: codingPath,
|
||||
debugDescription: "ConfigSnapshotDecoder does not support superDecoder()."
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func superDecoder(forKey key: Key) throws -> any Decoder {
|
||||
throw DecodingError.dataCorrupted(
|
||||
DecodingError.Context(
|
||||
codingPath: codingPath + [key],
|
||||
debugDescription: "ConfigSnapshotDecoder does not support superDecoder(forKey:)."
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Private helpers
|
||||
|
||||
private func configKey(appending key: Key) -> ConfigKey {
|
||||
ConfigKey(codingPath.map(\.stringValue) + [key.stringValue])
|
||||
}
|
||||
|
||||
private func decodeValue<V>(forKey key: Key, _ body: (ConfigKey) throws -> V) throws -> V {
|
||||
let configKey = configKey(appending: key)
|
||||
do {
|
||||
return try body(configKey)
|
||||
} catch {
|
||||
throw DecodingError.keyNotFound(
|
||||
key,
|
||||
DecodingError.Context(
|
||||
codingPath: codingPath,
|
||||
debugDescription: "No value found for key \"\(configKey)\"."
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func integerValue<T: FixedWidthInteger>(forKey key: Key) throws -> T {
|
||||
let intValue: Int = try decodeValue(forKey: key) { try snapshot.requiredInt(forKey: $0) }
|
||||
guard let converted = T(exactly: intValue) else {
|
||||
throw DecodingError.typeMismatch(
|
||||
T.self,
|
||||
DecodingError.Context(
|
||||
codingPath: codingPath + [key],
|
||||
debugDescription: "Value \(intValue) does not fit in \(T.self)."
|
||||
)
|
||||
)
|
||||
}
|
||||
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 {
|
||||
let snapshot: ConfigSnapshotReader
|
||||
let codingPath: [any CodingKey]
|
||||
let userInfo: [CodingUserInfoKey: Any]
|
||||
let typeDecodingStrategies: [ObjectIdentifier: AnyConfigDecodingStrategy]
|
||||
|
||||
// ConfigSnapshotReader stores typed values — string(forKey:) returns nil for
|
||||
// int/double/bool values. `hasValue` checks all primitive accessors.
|
||||
func decodeNil() -> Bool {
|
||||
!snapshot.hasValue(forKey: configKey())
|
||||
}
|
||||
|
||||
func decode(_ type: Bool.Type) throws -> Bool {
|
||||
try decodeValue { try snapshot.requiredBool(forKey: $0) }
|
||||
}
|
||||
|
||||
func decode(_ type: String.Type) throws -> String {
|
||||
try decodeValue { try snapshot.requiredString(forKey: $0) }
|
||||
}
|
||||
|
||||
func decode(_ type: Double.Type) throws -> Double {
|
||||
try decodeValue { try snapshot.requiredDouble(forKey: $0) }
|
||||
}
|
||||
|
||||
func decode(_ type: Float.Type) throws -> Float {
|
||||
Float(try decodeValue { try snapshot.requiredDouble(forKey: $0) })
|
||||
}
|
||||
|
||||
func decode(_ type: Int.Type) throws -> Int {
|
||||
try decodeValue { try snapshot.requiredInt(forKey: $0) }
|
||||
}
|
||||
|
||||
func decode(_ type: Int8.Type) throws -> Int8 { try integerValue() }
|
||||
func decode(_ type: Int16.Type) throws -> Int16 { try integerValue() }
|
||||
func decode(_ type: Int32.Type) throws -> Int32 { try integerValue() }
|
||||
func decode(_ type: Int64.Type) throws -> Int64 { try integerValue() }
|
||||
func decode(_ type: UInt.Type) throws -> UInt { try integerValue() }
|
||||
func decode(_ type: UInt8.Type) throws -> UInt8 { try integerValue() }
|
||||
func decode(_ type: UInt16.Type) throws -> UInt16 { try integerValue() }
|
||||
func decode(_ type: UInt32.Type) throws -> UInt32 { try integerValue() }
|
||||
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,
|
||||
userInfo: userInfo,
|
||||
typeDecodingStrategies: typeDecodingStrategies
|
||||
)
|
||||
if let strategy = typeDecodingStrategies[ObjectIdentifier(type)] {
|
||||
guard let typed = try strategy.decode(from: impl) as? T else {
|
||||
throw DecodingError.typeMismatch(
|
||||
T.self,
|
||||
DecodingError.Context(
|
||||
codingPath: codingPath,
|
||||
debugDescription: "Strategy returned value of unexpected type for \(T.self)."
|
||||
)
|
||||
)
|
||||
}
|
||||
return typed
|
||||
}
|
||||
return try T(from: impl)
|
||||
}
|
||||
|
||||
// MARK: - Private helpers
|
||||
|
||||
private func configKey() -> ConfigKey {
|
||||
ConfigKey(codingPath.map(\.stringValue))
|
||||
}
|
||||
|
||||
private func decodeValue<V>(_ body: (ConfigKey) throws -> V) throws -> V {
|
||||
let configKey = configKey()
|
||||
do {
|
||||
return try body(configKey)
|
||||
} catch {
|
||||
throw DecodingError.valueNotFound(
|
||||
V.self,
|
||||
DecodingError.Context(
|
||||
codingPath: codingPath,
|
||||
debugDescription: "No value found for key \"\(configKey)\"."
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func integerValue<T: FixedWidthInteger>() throws -> T {
|
||||
let intValue: Int = try decodeValue { try snapshot.requiredInt(forKey: $0) }
|
||||
guard let converted = T(exactly: intValue) else {
|
||||
throw DecodingError.typeMismatch(
|
||||
T.self,
|
||||
DecodingError.Context(
|
||||
codingPath: codingPath,
|
||||
debugDescription: "Value \(intValue) does not fit in \(T.self)."
|
||||
)
|
||||
)
|
||||
}
|
||||
return converted
|
||||
}
|
||||
}
|
||||
|
||||
struct UnkeyedContainer: UnkeyedDecodingContainer {
|
||||
let snapshot: ConfigSnapshotReader
|
||||
let codingPath: [any CodingKey]
|
||||
let userInfo: [CodingUserInfoKey: Any]
|
||||
let typeDecodingStrategies: [ObjectIdentifier: AnyConfigDecodingStrategy]
|
||||
|
||||
private enum ArrayValue {
|
||||
case strings([String])
|
||||
case ints([Int])
|
||||
case doubles([Double])
|
||||
case bools([Bool])
|
||||
case unsupported
|
||||
}
|
||||
|
||||
private var resolved: ArrayValue = .unsupported
|
||||
private(set) var currentIndex: Int = 0
|
||||
|
||||
var count: Int? {
|
||||
switch resolved {
|
||||
case .strings(let a): a.count
|
||||
case .ints(let a): a.count
|
||||
case .doubles(let a): a.count
|
||||
case .bools(let a): a.count
|
||||
case .unsupported: nil
|
||||
}
|
||||
}
|
||||
|
||||
var isAtEnd: Bool {
|
||||
switch resolved {
|
||||
case .unsupported: false
|
||||
default: currentIndex >= (count ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
init(
|
||||
snapshot: ConfigSnapshotReader,
|
||||
codingPath: [any CodingKey],
|
||||
userInfo: [CodingUserInfoKey: Any],
|
||||
typeDecodingStrategies: [ObjectIdentifier: AnyConfigDecodingStrategy]
|
||||
) {
|
||||
self.snapshot = snapshot
|
||||
self.codingPath = codingPath
|
||||
self.userInfo = userInfo
|
||||
self.typeDecodingStrategies = typeDecodingStrategies
|
||||
self.resolved = .unsupported
|
||||
}
|
||||
|
||||
mutating func decodeNil() throws -> Bool { false }
|
||||
|
||||
mutating func decode(_ type: String.Type) throws -> String {
|
||||
let array = try resolveStrings()
|
||||
try checkBounds(array.count)
|
||||
defer { currentIndex += 1 }
|
||||
return array[currentIndex]
|
||||
}
|
||||
|
||||
mutating func decode(_ type: Bool.Type) throws -> Bool {
|
||||
let array = try resolveBools()
|
||||
try checkBounds(array.count)
|
||||
defer { currentIndex += 1 }
|
||||
return array[currentIndex]
|
||||
}
|
||||
|
||||
mutating func decode(_ type: Int.Type) throws -> Int {
|
||||
let array = try resolveInts()
|
||||
try checkBounds(array.count)
|
||||
defer { currentIndex += 1 }
|
||||
return array[currentIndex]
|
||||
}
|
||||
|
||||
mutating func decode(_ type: Double.Type) throws -> Double {
|
||||
let array = try resolveDoubles()
|
||||
try checkBounds(array.count)
|
||||
defer { currentIndex += 1 }
|
||||
return array[currentIndex]
|
||||
}
|
||||
|
||||
mutating func decode(_ type: Float.Type) throws -> Float {
|
||||
Float(try decode(Double.self))
|
||||
}
|
||||
|
||||
mutating func decode(_ type: Int8.Type) throws -> Int8 { try integerElement() }
|
||||
mutating func decode(_ type: Int16.Type) throws -> Int16 { try integerElement() }
|
||||
mutating func decode(_ type: Int32.Type) throws -> Int32 { try integerElement() }
|
||||
mutating func decode(_ type: Int64.Type) throws -> Int64 { try integerElement() }
|
||||
mutating func decode(_ type: UInt.Type) throws -> UInt { try integerElement() }
|
||||
mutating func decode(_ type: UInt8.Type) throws -> UInt8 { try integerElement() }
|
||||
mutating func decode(_ type: UInt16.Type) throws -> UInt16 { try integerElement() }
|
||||
mutating func decode(_ type: UInt32.Type) throws -> UInt32 { try integerElement() }
|
||||
mutating func decode(_ type: UInt64.Type) throws -> UInt64 { try integerElement() }
|
||||
|
||||
mutating func decode<T: Decodable>(_ type: T.Type) throws -> T {
|
||||
if type == String.self { return try decode(String.self) as! T }
|
||||
if type == Int.self { return try decode(Int.self) as! T }
|
||||
if type == Double.self { return try decode(Double.self) as! T }
|
||||
if type == Bool.self { return try decode(Bool.self) as! T }
|
||||
if type == Float.self { return try decode(Float.self) as! T }
|
||||
if type == Int8.self { return try decode(Int8.self) as! T }
|
||||
if type == Int16.self { return try decode(Int16.self) as! T }
|
||||
if type == Int32.self { return try decode(Int32.self) as! T }
|
||||
if type == Int64.self { return try decode(Int64.self) as! T }
|
||||
if type == UInt.self { return try decode(UInt.self) as! T }
|
||||
if type == UInt8.self { return try decode(UInt8.self) as! T }
|
||||
if type == UInt16.self { return try decode(UInt16.self) as! T }
|
||||
if type == UInt32.self { return try decode(UInt32.self) as! T }
|
||||
if type == UInt64.self { return try decode(UInt64.self) as! T }
|
||||
throw DecodingError.dataCorrupted(
|
||||
DecodingError.Context(
|
||||
codingPath: codingPath,
|
||||
debugDescription:
|
||||
"ConfigSnapshotDecoder does not support decoding arrays of \(T.self). Only arrays of primitive types (String, Int, Double, Bool) are supported."
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
mutating func nestedContainer<NestedKey: CodingKey>(
|
||||
keyedBy type: NestedKey.Type
|
||||
) throws -> KeyedDecodingContainer<NestedKey> {
|
||||
throw DecodingError.dataCorrupted(
|
||||
DecodingError.Context(
|
||||
codingPath: codingPath,
|
||||
debugDescription: "ConfigSnapshotDecoder does not support nested containers inside arrays."
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
mutating func nestedUnkeyedContainer() throws -> any UnkeyedDecodingContainer {
|
||||
throw DecodingError.dataCorrupted(
|
||||
DecodingError.Context(
|
||||
codingPath: codingPath,
|
||||
debugDescription: "ConfigSnapshotDecoder does not support nested arrays."
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func superDecoder() throws -> any Decoder {
|
||||
throw DecodingError.dataCorrupted(
|
||||
DecodingError.Context(
|
||||
codingPath: codingPath,
|
||||
debugDescription: "ConfigSnapshotDecoder does not support superDecoder()."
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Private helpers
|
||||
|
||||
private func configKey() -> ConfigKey {
|
||||
ConfigKey(codingPath.map(\.stringValue))
|
||||
}
|
||||
|
||||
private mutating func resolveStrings() throws -> [String] {
|
||||
if case .strings(let a) = resolved { return a }
|
||||
let configKey = configKey()
|
||||
guard let result = snapshot.stringArray(forKey: configKey) else {
|
||||
throw DecodingError.valueNotFound(
|
||||
[String].self,
|
||||
DecodingError.Context(
|
||||
codingPath: codingPath,
|
||||
debugDescription: "No string array found for key \"\(configKey)\"."
|
||||
)
|
||||
)
|
||||
}
|
||||
resolved = .strings(result)
|
||||
return result
|
||||
}
|
||||
|
||||
private mutating func resolveInts() throws -> [Int] {
|
||||
if case .ints(let a) = resolved { return a }
|
||||
let configKey = configKey()
|
||||
guard let result = snapshot.intArray(forKey: configKey) else {
|
||||
throw DecodingError.valueNotFound(
|
||||
[Int].self,
|
||||
DecodingError.Context(
|
||||
codingPath: codingPath,
|
||||
debugDescription: "No int array found for key \"\(configKey)\"."
|
||||
)
|
||||
)
|
||||
}
|
||||
resolved = .ints(result)
|
||||
return result
|
||||
}
|
||||
|
||||
private mutating func resolveDoubles() throws -> [Double] {
|
||||
if case .doubles(let a) = resolved { return a }
|
||||
let configKey = configKey()
|
||||
guard let result = snapshot.doubleArray(forKey: configKey) else {
|
||||
throw DecodingError.valueNotFound(
|
||||
[Double].self,
|
||||
DecodingError.Context(
|
||||
codingPath: codingPath,
|
||||
debugDescription: "No double array found for key \"\(configKey)\"."
|
||||
)
|
||||
)
|
||||
}
|
||||
resolved = .doubles(result)
|
||||
return result
|
||||
}
|
||||
|
||||
private mutating func resolveBools() throws -> [Bool] {
|
||||
if case .bools(let a) = resolved { return a }
|
||||
let configKey = configKey()
|
||||
guard let result = snapshot.boolArray(forKey: configKey) else {
|
||||
throw DecodingError.valueNotFound(
|
||||
[Bool].self,
|
||||
DecodingError.Context(
|
||||
codingPath: codingPath,
|
||||
debugDescription: "No bool array found for key \"\(configKey)\"."
|
||||
)
|
||||
)
|
||||
}
|
||||
resolved = .bools(result)
|
||||
return result
|
||||
}
|
||||
|
||||
private mutating func integerElement<T: FixedWidthInteger>() throws -> T {
|
||||
let intValue = try decode(Int.self)
|
||||
guard let converted = T(exactly: intValue) else {
|
||||
throw DecodingError.typeMismatch(
|
||||
T.self,
|
||||
DecodingError.Context(
|
||||
codingPath: codingPath,
|
||||
debugDescription: "Value \(intValue) does not fit in \(T.self)."
|
||||
)
|
||||
)
|
||||
}
|
||||
return converted
|
||||
}
|
||||
|
||||
private func checkBounds(_ count: Int) throws {
|
||||
guard currentIndex < count else {
|
||||
throw DecodingError.valueNotFound(
|
||||
Any.self,
|
||||
DecodingError.Context(
|
||||
codingPath: codingPath,
|
||||
debugDescription: "Unkeyed container is at end (index \(currentIndex), count \(count))."
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 Configuration
|
||||
import ConfigurationTOML
|
||||
import ContainerizationError
|
||||
import Foundation
|
||||
import SystemPackage
|
||||
|
||||
public protocol Initable {
|
||||
init()
|
||||
}
|
||||
|
||||
public typealias LoadableConfiguration = Codable & Sendable & Initable
|
||||
|
||||
public protocol LoadablePluginConfiguration: LoadableConfiguration {
|
||||
static var pluginId: String { get }
|
||||
}
|
||||
|
||||
public enum ConfigurationLoader {
|
||||
private static let configFilename = "config.toml"
|
||||
private static let configDirectory = "config"
|
||||
private static let READ_ONLY: Int = 0o444
|
||||
private static let READ_AND_WRITE: Int = 0o644
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// Returns the configuration file path under an explicit base directory.
|
||||
///
|
||||
/// Path shape depends on `kind`:
|
||||
/// - `.home`: `<base>/config.toml` (user source under `~/.config/container`)
|
||||
/// - e.g. `~/.config/container/config.toml`
|
||||
/// - `.appRoot`: `<base>/config/config.toml` (read-only copy of user config)
|
||||
/// - e.g. `~/Library/Application Support/com.apple.container/config/config.toml`
|
||||
/// - `.installRoot`: `<base>/etc/container/config.toml` (system defaults shipped with install)
|
||||
/// - e.g. `/usr/local/etc/container/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/config.toml`)
|
||||
/// > system config (`<installRoot>/etc/container/config/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 {
|
||||
return try ConfigSnapshotDecoder().decode(T.self, from: snapshot)
|
||||
} catch {
|
||||
throw ContainerizationError(
|
||||
.invalidArgument,
|
||||
message: "\(decodeErrorContext): \(error)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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: File to copy from. Defaults to `<home>/container/config.toml`.
|
||||
/// - destination: Directory to copy into — the filename is appended automatically.
|
||||
/// Defaults to `<appRoot>/config/config.toml`.
|
||||
public static func copyConfigurationToReadOnly(
|
||||
from source: FilePath? = nil,
|
||||
to destination: FilePath? = nil
|
||||
) throws {
|
||||
let sourcePath = source ?? configurationFile(.home)
|
||||
let destBase = destination ?? PathUtils.BaseConfigPath.appRoot.basePath()
|
||||
let destPath = configurationFile(in: destBase)
|
||||
|
||||
let fm = FileManager.default
|
||||
|
||||
if fm.fileExists(atPath: destPath.string) {
|
||||
try fm.setAttributes([.posixPermissions: READ_AND_WRITE], ofItemAtPath: destPath.string)
|
||||
try fm.removeItem(at: URL(filePath: destPath.string))
|
||||
}
|
||||
|
||||
guard fm.fileExists(atPath: sourcePath.string) else { return }
|
||||
|
||||
let destDir = destPath.removingLastComponent()
|
||||
try fm.createDirectory(atPath: destDir.string, withIntermediateDirectories: true)
|
||||
|
||||
try fm.copyItem(
|
||||
at: URL(filePath: sourcePath.string),
|
||||
to: URL(filePath: destPath.string)
|
||||
)
|
||||
try fm.setAttributes([.posixPermissions: READ_ONLY], ofItemAtPath: destPath.string)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 CVersion
|
||||
import ContainerVersion
|
||||
import ContainerizationExtras
|
||||
import Foundation
|
||||
|
||||
/// Top-level configuration decoded from config.toml.
|
||||
///
|
||||
/// Each section maps to a nested struct. Missing keys fall back to
|
||||
/// hardcoded defaults via custom `init(from:)` implementations.
|
||||
public final class ContainerSystemConfig: Codable, Sendable, Initable {
|
||||
public let build: BuildConfig
|
||||
public let container: ContainerConfig
|
||||
public let dns: DNSConfig
|
||||
public let kernel: KernelConfig
|
||||
public let machine: MachineConfig
|
||||
public let network: NetworkConfig
|
||||
public let registry: RegistryConfig
|
||||
public let vminit: VminitConfig
|
||||
|
||||
public init(
|
||||
build: BuildConfig = .init(),
|
||||
container: ContainerConfig = .init(),
|
||||
dns: DNSConfig = .init(),
|
||||
kernel: KernelConfig = .init(),
|
||||
machine: MachineConfig = MachineConfig.default,
|
||||
network: NetworkConfig = .init(),
|
||||
registry: RegistryConfig = .init(),
|
||||
vminit: VminitConfig = .init()
|
||||
) {
|
||||
self.build = build
|
||||
self.container = container
|
||||
self.dns = dns
|
||||
self.kernel = kernel
|
||||
self.machine = machine
|
||||
self.network = network
|
||||
self.registry = registry
|
||||
self.vminit = vminit
|
||||
}
|
||||
|
||||
public init() {
|
||||
self.build = .init()
|
||||
self.container = .init()
|
||||
self.dns = .init()
|
||||
self.kernel = .init()
|
||||
self.machine = MachineConfig.default
|
||||
self.network = .init()
|
||||
self.registry = .init()
|
||||
self.vminit = .init()
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.build = try container.decodeIfPresent(BuildConfig.self, forKey: .build) ?? .init()
|
||||
self.container = try container.decodeIfPresent(ContainerConfig.self, forKey: .container) ?? .init()
|
||||
self.dns = try container.decodeIfPresent(DNSConfig.self, forKey: .dns) ?? .init()
|
||||
self.kernel = try container.decodeIfPresent(KernelConfig.self, forKey: .kernel) ?? .init()
|
||||
self.machine = try container.decodeIfPresent(MachineConfig.self, forKey: .machine) ?? MachineConfig.default
|
||||
self.network = try container.decodeIfPresent(NetworkConfig.self, forKey: .network) ?? .init()
|
||||
self.registry = try container.decodeIfPresent(RegistryConfig.self, forKey: .registry) ?? .init()
|
||||
self.vminit = try container.decodeIfPresent(VminitConfig.self, forKey: .vminit) ?? .init()
|
||||
}
|
||||
}
|
||||
|
||||
final public class BuildConfig: Codable, Sendable {
|
||||
public static let defaultRosetta = true
|
||||
public static let defaultCPUs = 2
|
||||
public static let defaultMemory = try! MemorySize("2048MB")
|
||||
public static var defaultImage: String {
|
||||
let tag = String(cString: get_container_builder_shim_version())
|
||||
return "ghcr.io/apple/container-builder-shim/builder:\(tag)"
|
||||
}
|
||||
|
||||
public let rosetta: Bool
|
||||
public let cpus: Int
|
||||
public let memory: MemorySize
|
||||
public let image: String
|
||||
|
||||
public init(
|
||||
rosetta: Bool = defaultRosetta,
|
||||
cpus: Int = defaultCPUs,
|
||||
memory: MemorySize = defaultMemory,
|
||||
image: String = defaultImage
|
||||
) {
|
||||
self.rosetta = rosetta
|
||||
self.cpus = cpus
|
||||
self.memory = memory
|
||||
self.image = image
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.rosetta = try container.decodeIfPresent(Bool.self, forKey: .rosetta) ?? Self.defaultRosetta
|
||||
self.cpus = try container.decodeIfPresent(Int.self, forKey: .cpus) ?? Self.defaultCPUs
|
||||
self.memory = try container.decodeIfPresent(MemorySize.self, forKey: .memory) ?? Self.defaultMemory
|
||||
self.image = try container.decodeIfPresent(String.self, forKey: .image) ?? Self.defaultImage
|
||||
}
|
||||
}
|
||||
|
||||
final public class ContainerConfig: Codable, Sendable {
|
||||
public static let defaultCPUs = 4
|
||||
public static let defaultMemory = try! MemorySize("1g")
|
||||
|
||||
public let cpus: Int
|
||||
public let memory: MemorySize
|
||||
|
||||
public init(cpus: Int = defaultCPUs, memory: MemorySize = defaultMemory) {
|
||||
self.cpus = cpus
|
||||
self.memory = memory
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.cpus = try container.decodeIfPresent(Int.self, forKey: .cpus) ?? Self.defaultCPUs
|
||||
self.memory = try container.decodeIfPresent(MemorySize.self, forKey: .memory) ?? Self.defaultMemory
|
||||
}
|
||||
}
|
||||
|
||||
final public class DNSConfig: Codable, Sendable {
|
||||
public let domain: String?
|
||||
|
||||
public init(domain: String? = nil) {
|
||||
self.domain = domain
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.domain = try container.decodeIfPresent(String.self, forKey: .domain)
|
||||
}
|
||||
}
|
||||
|
||||
final public class VminitConfig: Codable, Sendable {
|
||||
public static var defaultImage: String {
|
||||
let tag = String(cString: get_swift_containerization_version())
|
||||
return tag == "latest"
|
||||
? "vminit:latest"
|
||||
: "ghcr.io/apple/containerization/vminit:\(tag)"
|
||||
}
|
||||
|
||||
public let image: String
|
||||
|
||||
public init(image: String = defaultImage) {
|
||||
self.image = image
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.image = try container.decodeIfPresent(String.self, forKey: .image) ?? Self.defaultImage
|
||||
}
|
||||
}
|
||||
|
||||
final public class KernelConfig: Codable, Sendable {
|
||||
public static let defaultBinaryPath = "opt/kata/share/kata-containers/vmlinux-6.18.15-186"
|
||||
public static let defaultURL: URL =
|
||||
URL(string: "https://github.com/kata-containers/kata-containers/releases/download/3.28.0/kata-static-3.28.0-arm64.tar.zst")!
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case binaryPath
|
||||
case url
|
||||
}
|
||||
|
||||
public let binaryPath: String
|
||||
public let url: URL
|
||||
|
||||
public init(
|
||||
binaryPath: String = defaultBinaryPath,
|
||||
url: URL = defaultURL
|
||||
) {
|
||||
self.binaryPath = binaryPath
|
||||
self.url = url
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.binaryPath =
|
||||
try container.decodeIfPresent(String.self, forKey: .binaryPath)
|
||||
?? Self.defaultBinaryPath
|
||||
if let urlString = try container.decodeIfPresent(String.self, forKey: .url),
|
||||
let parsed = URL(string: urlString)
|
||||
{
|
||||
self.url = parsed
|
||||
} else {
|
||||
self.url = Self.defaultURL
|
||||
}
|
||||
}
|
||||
|
||||
// JSONEncoder special-cases URL to encode as absoluteString, but third-party
|
||||
// encoders (e.g. TOMLEncoder) hit Foundation's default Codable conformance which
|
||||
// encodes into a keyed container with a "relative" key. Encode as a plain string
|
||||
// so all formats produce a consistent URL representation.
|
||||
// If more config types start using URL, consider a property wrapper or a wrapper
|
||||
// type (like MemorySize) that encodes/decodes URL as a string uniformly.
|
||||
public func encode(to encoder: any Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(binaryPath, forKey: .binaryPath)
|
||||
try container.encode(url.absoluteString, forKey: .url)
|
||||
}
|
||||
}
|
||||
|
||||
final public class NetworkConfig: Codable, Sendable {
|
||||
public let subnet: CIDRv4?
|
||||
public let subnetv6: CIDRv6?
|
||||
|
||||
public init(subnet: CIDRv4? = nil, subnetv6: CIDRv6? = nil) {
|
||||
self.subnet = subnet
|
||||
self.subnetv6 = subnetv6
|
||||
}
|
||||
}
|
||||
|
||||
final public class RegistryConfig: Codable, Sendable {
|
||||
public static let defaultDomain = "docker.io"
|
||||
|
||||
public let domain: String
|
||||
|
||||
public init(domain: String = defaultDomain) {
|
||||
self.domain = domain
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.domain = try container.decodeIfPresent(String.self, forKey: .domain) ?? Self.defaultDomain
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ContainerizationError
|
||||
import Foundation
|
||||
import Logging
|
||||
import SystemPackage
|
||||
|
||||
private let metadataFilename: String = "entity.json"
|
||||
|
||||
public protocol EntityStore<T> {
|
||||
associatedtype T: Codable & Identifiable<String> & Sendable
|
||||
|
||||
func list() async throws -> [T]
|
||||
func create(_ entity: T) async throws
|
||||
func retrieve(_ id: String) async throws -> T?
|
||||
func update(_ entity: T) async throws
|
||||
func upsert(_ entity: T) async throws
|
||||
func delete(_ id: String) async throws
|
||||
}
|
||||
|
||||
public actor FilesystemEntityStore<T>: EntityStore where T: Codable & Identifiable<String> & Sendable {
|
||||
typealias Index = [String: T]
|
||||
|
||||
private let path: FilePath
|
||||
private let type: String
|
||||
private var index: Index
|
||||
private let log: Logger
|
||||
private let encoder = JSONEncoder()
|
||||
|
||||
public init(path: FilePath, type: String, log: Logger) throws {
|
||||
self.path = path
|
||||
self.type = type
|
||||
self.log = log
|
||||
self.index = try Self.load(path: path, log: log)
|
||||
}
|
||||
|
||||
public func list() async throws -> [T] {
|
||||
Array(index.values)
|
||||
}
|
||||
|
||||
public func create(_ entity: T) async throws {
|
||||
let metadataPath = try metadataPath(entity.id)
|
||||
guard !FileManager.default.fileExists(atPath: metadataPath.string) else {
|
||||
throw ContainerizationError(.exists, message: "entity \(entity.id) already exist")
|
||||
}
|
||||
|
||||
let entityPath = try entityPath(entity.id)
|
||||
try FileManager.default.createDirectory(atPath: entityPath.string, withIntermediateDirectories: true)
|
||||
let data = try encoder.encode(entity)
|
||||
try data.write(to: URL(filePath: metadataPath.string))
|
||||
index[entity.id] = entity
|
||||
}
|
||||
|
||||
public func retrieve(_ id: String) throws -> T? {
|
||||
index[id]
|
||||
}
|
||||
|
||||
public func update(_ entity: T) async throws {
|
||||
let metadataPath = try metadataPath(entity.id)
|
||||
guard FileManager.default.fileExists(atPath: metadataPath.string) else {
|
||||
throw ContainerizationError(.notFound, message: "entity \(entity.id) not found")
|
||||
}
|
||||
|
||||
let data = try encoder.encode(entity)
|
||||
try data.write(to: URL(filePath: metadataPath.string))
|
||||
index[entity.id] = entity
|
||||
}
|
||||
|
||||
public func upsert(_ entity: T) async throws {
|
||||
let entityPath = try entityPath(entity.id)
|
||||
try FileManager.default.createDirectory(atPath: entityPath.string, withIntermediateDirectories: true)
|
||||
let metadataPath = try metadataPath(entity.id)
|
||||
let data = try encoder.encode(entity)
|
||||
try data.write(to: URL(filePath: metadataPath.string))
|
||||
index[entity.id] = entity
|
||||
}
|
||||
|
||||
public func delete(_ id: String) async throws {
|
||||
let metadataPath = try entityPath(id)
|
||||
guard FileManager.default.fileExists(atPath: metadataPath.string) else {
|
||||
throw ContainerizationError(.notFound, message: "entity \(id) not found")
|
||||
}
|
||||
try FileManager.default.removeItem(atPath: metadataPath.string)
|
||||
index.removeValue(forKey: id)
|
||||
}
|
||||
|
||||
public nonisolated func entityPath(_ id: String) throws -> FilePath {
|
||||
guard let component = FilePath.Component(id) else {
|
||||
throw ContainerizationError(.invalidArgument, message: "entity ID \(id) cannot be a path component")
|
||||
}
|
||||
return path.appending(component)
|
||||
}
|
||||
|
||||
private static func load(path: FilePath, log: Logger) throws -> Index {
|
||||
let directories = try FileManager.default.contentsOfDirectory(atPath: path.string)
|
||||
var index: FilesystemEntityStore<T>.Index = Index()
|
||||
let decoder = JSONDecoder()
|
||||
|
||||
for filename in directories {
|
||||
let metadataPath = path.appending(filename).appending(metadataFilename)
|
||||
do {
|
||||
let data = try Data(contentsOf: URL(filePath: metadataPath.string))
|
||||
let entity = try decoder.decode(T.self, from: data)
|
||||
index[entity.id] = entity
|
||||
} catch {
|
||||
log.warning(
|
||||
"failed to load entity, ignoring",
|
||||
metadata: [
|
||||
"path": "\(metadataPath.string)"
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
return index
|
||||
}
|
||||
|
||||
private func metadataPath(_ id: String) throws -> FilePath {
|
||||
try entityPath(id).appending(metadataFilename)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 Darwin
|
||||
import SystemPackage
|
||||
|
||||
extension FilePath {
|
||||
/// Returns a new `FilePath` with all symlinks resolved and `.`/`..`
|
||||
/// components normalized, by calling `realpath(3)`.
|
||||
///
|
||||
/// Unlike ``lexicallyNormalized()``, this method accesses the file system.
|
||||
/// It throws ``Errno/noSuchFileOrDirectory`` if any component of the path
|
||||
/// does not exist.
|
||||
///
|
||||
/// The returned path is always absolute. If the receiver is a relative path,
|
||||
/// it is resolved against the process's current working directory.
|
||||
public func resolvingSymlinks() throws -> FilePath {
|
||||
try withPlatformString { cPath in
|
||||
guard let resolved = Darwin.realpath(cPath, nil) else {
|
||||
throw Errno(rawValue: Darwin.errno)
|
||||
}
|
||||
defer { free(resolved) }
|
||||
return FilePath(platformString: resolved)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ContainerizationError
|
||||
import Foundation
|
||||
import SystemPackage
|
||||
|
||||
/// Boot-time configuration for a container machine.
|
||||
///
|
||||
/// These values can be modified without recreating the container machine.
|
||||
/// Changes take effect on the next boot. `nil` values mean
|
||||
/// "use the container runtime default."
|
||||
public struct MachineConfig: Codable, Sendable {
|
||||
public static let `default`: MachineConfig = try! .init(
|
||||
cpus: nil, memory: nil, homeMount: nil, virtualization: nil, kernelPath: nil)
|
||||
|
||||
public static var defaultCPUs: Int {
|
||||
max(ProcessInfo.processInfo.processorCount / 2, 4)
|
||||
}
|
||||
|
||||
public static var defaultMemory: MemorySize {
|
||||
let bytes = max(ProcessInfo.processInfo.physicalMemory / 2, 1024 * 1024 * 1024)
|
||||
let gb = bytes / (1024 * 1024 * 1024)
|
||||
return try! MemorySize("\(gb)gb")
|
||||
}
|
||||
|
||||
public static let defaultHomeMount: HomeMountOption = .rw
|
||||
|
||||
/// Home mount option for the /Users/<name> directory.
|
||||
public enum HomeMountOption: String, Sendable, Codable {
|
||||
case ro
|
||||
case rw
|
||||
case none
|
||||
}
|
||||
|
||||
/// Number of virtual CPUs.
|
||||
public let cpus: Int
|
||||
/// Memory in bytes.
|
||||
public let memory: MemorySize
|
||||
/// Home mount configuration. nil = system default.
|
||||
public let homeMount: HomeMountOption
|
||||
/// Whether to expose nested virtualization to the container machine.
|
||||
public let virtualization: Bool
|
||||
/// Optional path to a custom kernel binary. nil falls back to the system default.
|
||||
public let kernelPath: FilePath?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case cpus
|
||||
case memory
|
||||
case homeMount
|
||||
case virtualization
|
||||
case kernelPath
|
||||
}
|
||||
|
||||
/// Settable keys and their descriptions, for CLI help text generation.
|
||||
public static let settableKeys: [(key: String, valueName: String, description: String)] = [
|
||||
("cpus", "<number>", "Number of virtual CPUs"),
|
||||
("memory", "<size>", "Memory allocation (e.g., 2G, 1G). Default: half of system memory"),
|
||||
("home-mount", "<string>", "User home directory mount option (ro, rw, none). Default: rw"),
|
||||
("virtualization", "<bool>", "Enable nested virtualization (true|false). Requires Apple Silicon M3+ and macOS 15+ and kernel with CONFIG_KVM=y."),
|
||||
("kernel", "<path>", "Path to a custom kernel binary. Empty value resets to the system default."),
|
||||
]
|
||||
|
||||
public init(
|
||||
cpus: Int?,
|
||||
memory: MemorySize?,
|
||||
homeMount: HomeMountOption?,
|
||||
virtualization: Bool?,
|
||||
kernelPath: FilePath?
|
||||
) throws {
|
||||
self.cpus = cpus ?? Self.defaultCPUs
|
||||
self.memory = memory ?? Self.defaultMemory
|
||||
self.homeMount = homeMount ?? Self.defaultHomeMount
|
||||
self.virtualization = virtualization ?? false
|
||||
self.kernelPath = kernelPath
|
||||
|
||||
try self.validate()
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
let cpus = try container.decodeIfPresent(Int.self, forKey: .cpus)
|
||||
let memory = try container.decodeIfPresent(MemorySize.self, forKey: .memory)
|
||||
let homeMount = try container.decodeIfPresent(HomeMountOption.self, forKey: .homeMount)
|
||||
let virtualization = try container.decodeIfPresent(Bool.self, forKey: .virtualization)
|
||||
// FilePath's default Codable conformance encodes its internal SystemChar storage,
|
||||
// which the project's ConfigSnapshotDecoder can't handle. Persist as a plain String
|
||||
// and lift to FilePath in memory.
|
||||
let kernelPath = try container.decodeIfPresent(String.self, forKey: .kernelPath).map { FilePath($0) }
|
||||
|
||||
try self.init(
|
||||
cpus: cpus,
|
||||
memory: memory,
|
||||
homeMount: homeMount,
|
||||
virtualization: virtualization,
|
||||
kernelPath: kernelPath)
|
||||
}
|
||||
|
||||
public func encode(to encoder: any Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(cpus, forKey: .cpus)
|
||||
try container.encode(memory, forKey: .memory)
|
||||
try container.encode(homeMount, forKey: .homeMount)
|
||||
try container.encode(virtualization, forKey: .virtualization)
|
||||
try container.encodeIfPresent(kernelPath?.string, forKey: .kernelPath)
|
||||
}
|
||||
|
||||
private func validate() throws {
|
||||
guard self.cpus > 0 else {
|
||||
throw ContainerizationError(
|
||||
.invalidArgument,
|
||||
message: "invalid CPU count '\(self.cpus)'. Must be a positive integer (e.g., 4)."
|
||||
)
|
||||
}
|
||||
|
||||
guard self.memory.toUInt64(unit: .bytes) >= 1024 * 1024 * 1024 else {
|
||||
throw ContainerizationError(
|
||||
.invalidArgument,
|
||||
message: "invalid memory value '\(self.memory)'. Must be greater than 1gb."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MachineConfig {
|
||||
/// Generate a help discussion string listing all settable keys.
|
||||
public static func helpText() -> String {
|
||||
settableKeys.map { entry in
|
||||
let label = "\(entry.key)=\(entry.valueName)"
|
||||
let padding = String(repeating: " ", count: max(1, 24 - label.count))
|
||||
return "\(label)\(padding)\(entry.description)"
|
||||
}.joined(separator: "\n")
|
||||
}
|
||||
|
||||
/// Create a new MachineConfig from `self`, applying fields defined in `kwargs`
|
||||
/// This function is used in both `machine create` and `machine set`
|
||||
public func with(_ kwargs: [String: String]) throws -> MachineConfig {
|
||||
let validKeys = Set(Self.settableKeys.map(\.key))
|
||||
let unknownKeys = Set(kwargs.keys).subtracting(validKeys)
|
||||
guard unknownKeys.isEmpty else {
|
||||
throw ContainerizationError(
|
||||
.invalidArgument,
|
||||
message: "unknown fields '\(unknownKeys.joined(separator: ", "))'. Valid: \(validKeys.joined(separator: ", "))")
|
||||
}
|
||||
|
||||
let cpus = try kwargs["cpus"].map { try Self.parseInt($0, for: "cpus") }
|
||||
let memory = try kwargs["memory"].map { try MemorySize($0) }
|
||||
let homeMount = try kwargs["home-mount"].map { try Self.parseHomeMount($0) }
|
||||
let virtualization = try kwargs["virtualization"].map { try Self.parseBool($0, for: "virtualization") }
|
||||
// Empty string explicitly clears the kernel override; absent key leaves it unchanged.
|
||||
let kernelPath: FilePath?
|
||||
if let raw = kwargs["kernel"] {
|
||||
kernelPath = raw.isEmpty ? nil : FilePath(raw)
|
||||
} else {
|
||||
kernelPath = self.kernelPath
|
||||
}
|
||||
|
||||
return try .init(
|
||||
cpus: cpus ?? self.cpus,
|
||||
memory: memory ?? self.memory,
|
||||
homeMount: homeMount ?? self.homeMount,
|
||||
virtualization: virtualization ?? self.virtualization,
|
||||
kernelPath: kernelPath
|
||||
)
|
||||
}
|
||||
|
||||
/// Parse and validate a CPU count from user input.
|
||||
private static func parseInt(_ value: String, for key: String) throws -> Int {
|
||||
guard let num = Int(value) else {
|
||||
throw ContainerizationError(
|
||||
.invalidArgument,
|
||||
message: "failed to parse \(value) for \(key)"
|
||||
)
|
||||
}
|
||||
return num
|
||||
}
|
||||
|
||||
/// Parse and validate a home mount option from user input.
|
||||
private static func parseHomeMount(_ value: String) throws -> MachineConfig.HomeMountOption {
|
||||
guard let opt = MachineConfig.HomeMountOption(rawValue: value) else {
|
||||
throw ContainerizationError(
|
||||
.invalidArgument,
|
||||
message: "invalid home mount option '\(value)'. Valid options: ro, rw, none"
|
||||
)
|
||||
}
|
||||
return opt
|
||||
}
|
||||
|
||||
/// Parse a boolean setting accepting only "true" or "false".
|
||||
private static func parseBool(_ value: String, for key: String) throws -> Bool {
|
||||
guard let result = Parsers.parseBool(string: value) else {
|
||||
throw ContainerizationError(
|
||||
.invalidArgument,
|
||||
message: "invalid value '\(value)' for \(key). Expected 'true' or 'false'."
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
extension MachineConfig {
|
||||
/// Resolves the user-supplied kernel path to absolute and confirms it points to a
|
||||
/// readable, non-empty regular file. Used at create, set, and boot time.
|
||||
public static func validateKernelPath(_ path: String) throws -> FilePath {
|
||||
let absolute = URL(fileURLWithPath: path).path
|
||||
let fm = FileManager.default
|
||||
|
||||
var isDirectory: ObjCBool = false
|
||||
guard fm.fileExists(atPath: absolute, isDirectory: &isDirectory) else {
|
||||
throw ContainerizationError(
|
||||
.invalidArgument,
|
||||
message: "kernel binary not found at '\(absolute)'"
|
||||
)
|
||||
}
|
||||
guard !isDirectory.boolValue else {
|
||||
throw ContainerizationError(
|
||||
.invalidArgument,
|
||||
message: "kernel path '\(absolute)' is a directory, expected a file"
|
||||
)
|
||||
}
|
||||
guard fm.isReadableFile(atPath: absolute) else {
|
||||
throw ContainerizationError(
|
||||
.invalidArgument,
|
||||
message: "kernel binary at '\(absolute)' is not readable"
|
||||
)
|
||||
}
|
||||
let attrs = try fm.attributesOfItem(atPath: absolute)
|
||||
let size = (attrs[.size] as? NSNumber)?.uint64Value ?? 0
|
||||
guard size > 0 else {
|
||||
throw ContainerizationError(
|
||||
.invalidArgument,
|
||||
message: "kernel binary at '\(absolute)' is empty"
|
||||
)
|
||||
}
|
||||
return FilePath(absolute)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import Foundation
|
||||
|
||||
let binaryUnits: [Character: UnitInformationStorage] = [
|
||||
"b": .bytes,
|
||||
"k": .kibibytes,
|
||||
"m": .mebibytes,
|
||||
"g": .gibibytes,
|
||||
"t": .tebibytes,
|
||||
"p": .pebibytes,
|
||||
]
|
||||
|
||||
extension Measurement {
|
||||
public enum ParseError: Swift.Error, CustomStringConvertible {
|
||||
case invalidSize
|
||||
case invalidSymbol(String)
|
||||
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .invalidSize:
|
||||
return "invalid size"
|
||||
case .invalidSymbol(let symbol):
|
||||
return "invalid symbol: \(symbol)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// parseMemory the provided string into a measurement that is able to be converted to various byte sizes using binary exponents
|
||||
public static func parse(parsing: String) throws -> Measurement<UnitInformationStorage> {
|
||||
let check = "01234567890."
|
||||
let trimmed = parsing.trimmingCharacters(in: .whitespaces).lowercased()
|
||||
guard !trimmed.isEmpty else {
|
||||
throw ParseError.invalidSize
|
||||
}
|
||||
|
||||
let i = trimmed.firstIndex {
|
||||
!check.contains($0)
|
||||
}
|
||||
let rawValue =
|
||||
i
|
||||
.map { trimmed[..<$0].trimmingCharacters(in: .whitespaces) }
|
||||
?? trimmed
|
||||
let rawUnit = i.map { trimmed[$0...].trimmingCharacters(in: .whitespaces) } ?? ""
|
||||
|
||||
let value = Double(rawValue)
|
||||
guard let value else {
|
||||
throw ParseError.invalidSize
|
||||
}
|
||||
let unitSymbol = try Self.parseUnit(rawUnit)
|
||||
|
||||
let unit = binaryUnits[unitSymbol]
|
||||
guard let unit else {
|
||||
throw ParseError.invalidSymbol(rawUnit)
|
||||
}
|
||||
return Measurement<UnitInformationStorage>(value: value, unit: unit)
|
||||
}
|
||||
|
||||
static func parseUnit(_ unit: String) throws -> Character {
|
||||
let s = unit.dropFirst()
|
||||
let unitSymbol = unit.first ?? "b"
|
||||
|
||||
switch s {
|
||||
case "", "ib", "b":
|
||||
return unitSymbol
|
||||
default:
|
||||
throw ParseError.invalidSymbol(unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// This is a thin wrapper around Measurement<UnitInformationStorage> to enable
|
||||
/// better Codable implementations for user provided options. With this wrapper
|
||||
/// values will get encoded and decoded from the format "1g" or "10mb".
|
||||
public struct MemorySize: Codable, Sendable, Equatable, CustomStringConvertible {
|
||||
public var description: String { formatted }
|
||||
|
||||
public let measurement: Measurement<UnitInformationStorage>
|
||||
|
||||
public init(_ string: String) throws {
|
||||
self.measurement = try .parse(parsing: string)
|
||||
}
|
||||
|
||||
public init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
let string = try container.decode(String.self)
|
||||
try self.init(string)
|
||||
}
|
||||
|
||||
public func encode(to encoder: any Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
try container.encode(formatted)
|
||||
}
|
||||
|
||||
private static let unitLabels: [UnitInformationStorage: String] = [
|
||||
.bytes: "b",
|
||||
.kibibytes: "kb",
|
||||
.mebibytes: "mb",
|
||||
.gibibytes: "gb",
|
||||
.tebibytes: "tb",
|
||||
.pebibytes: "pb",
|
||||
]
|
||||
|
||||
public var formatted: String {
|
||||
let value = Int64(measurement.value)
|
||||
let label = Self.unitLabels[measurement.unit] ?? "unknown"
|
||||
return "\(value)\(label)"
|
||||
}
|
||||
}
|
||||
|
||||
extension MemorySize {
|
||||
public func toUInt64(unit: UnitInformationStorage) -> UInt64 {
|
||||
UInt64(self.measurement.converted(to: unit).value.rounded())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the container project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Generic value parsers shared across the project. Lives in `ContainerPersistence`
|
||||
/// so both higher-level CLI parsers and the persistence layer can reuse the same
|
||||
/// canonical implementations.
|
||||
public enum Parsers {
|
||||
/// Parse a boolean string accepting "true"/"t"/"false"/"f" (case-insensitive).
|
||||
/// Returns nil if the input matches none.
|
||||
public static func parseBool(string: String) -> Bool? {
|
||||
switch string.lowercased() {
|
||||
case "true", "t": return true
|
||||
case "false", "f": return false
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 ContainerVersion
|
||||
import Foundation
|
||||
import SystemPackage
|
||||
|
||||
public enum PathUtils {
|
||||
public enum BaseConfigPath {
|
||||
case home
|
||||
case appRoot
|
||||
case installRoot
|
||||
|
||||
public func basePath(env: [String: String] = ProcessInfo.processInfo.environment) -> FilePath {
|
||||
switch self {
|
||||
case .home:
|
||||
let configHome: String
|
||||
if let xdg = env["XDG_CONFIG_HOME"], !xdg.isEmpty {
|
||||
configHome = xdg
|
||||
} else {
|
||||
configHome = NSHomeDirectory() + "/.config"
|
||||
}
|
||||
return FilePath(configHome).appending("container")
|
||||
case .appRoot:
|
||||
if let envPath = env["CONTAINER_APP_ROOT"], !envPath.isEmpty {
|
||||
return FilePath(envPath)
|
||||
}
|
||||
let appSupportURL = FileManager.default.urls(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask
|
||||
).first!.appendingPathComponent("com.apple.container")
|
||||
return FilePath(appSupportURL.path(percentEncoded: false))
|
||||
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 installRootPath = CommandLine.executablePath
|
||||
.removingLastComponent()
|
||||
.removingLastComponent()
|
||||
return installRootPath
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user