mirror of
https://github.com/apple/container.git
synced 2026-09-28 01:46:25 -04: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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user