diff --git a/Package.swift b/Package.swift index 3fc2d5740..59db3c5a6 100644 --- a/Package.swift +++ b/Package.swift @@ -229,6 +229,7 @@ let package = Package( .product(name: "SystemPackage", package: "swift-system"), "ContainerAPIClient", "ContainerPersistence", + "ContainerTestSupport", ] ), .executableTarget( diff --git a/Sources/APIServer/APIServer+Start.swift b/Sources/APIServer/APIServer+Start.swift index fd4380aa2..4c850fb25 100644 --- a/Sources/APIServer/APIServer+Start.swift +++ b/Sources/APIServer/APIServer+Start.swift @@ -50,9 +50,7 @@ extension APIServer { var logRoot = LogRoot.path func run() async throws { - let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig( - configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url) - ) + let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load() let commandName = APIServer._commandName let logPath = logRoot.map { $0.appending("\(commandName).log") } let log = ServiceLogger.bootstrap(category: "APIServer", debug: debug, logPath: logPath) diff --git a/Sources/ContainerCommands/BuildCommand.swift b/Sources/ContainerCommands/BuildCommand.swift index a9c665196..cb6882278 100644 --- a/Sources/ContainerCommands/BuildCommand.swift +++ b/Sources/ContainerCommands/BuildCommand.swift @@ -149,9 +149,7 @@ extension Application { var pull: Bool = false public func run() async throws { - let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig( - configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url) - ) + let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load() do { let timeout: Duration = .seconds(300) let progressConfig = try ProgressConfig( diff --git a/Sources/ContainerCommands/Builder/BuilderStart.swift b/Sources/ContainerCommands/Builder/BuilderStart.swift index 42b8412e1..00b872105 100644 --- a/Sources/ContainerCommands/Builder/BuilderStart.swift +++ b/Sources/ContainerCommands/Builder/BuilderStart.swift @@ -55,9 +55,7 @@ extension Application { public init() {} public func run() async throws { - let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig( - configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url) - ) + let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load() let progressConfig = try ProgressConfig( showTasks: true, showItems: true, diff --git a/Sources/ContainerCommands/Container/ContainerCreate.swift b/Sources/ContainerCommands/Container/ContainerCreate.swift index c3ff6a25a..8b2291292 100644 --- a/Sources/ContainerCommands/Container/ContainerCreate.swift +++ b/Sources/ContainerCommands/Container/ContainerCreate.swift @@ -56,9 +56,7 @@ extension Application { var arguments: [String] = [] public func run() async throws { - let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig( - configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url) - ) + let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load() let progressConfig = try ProgressConfig( showTasks: true, showItems: true, diff --git a/Sources/ContainerCommands/Container/ContainerRun.swift b/Sources/ContainerCommands/Container/ContainerRun.swift index ff6729b05..23dafc70b 100644 --- a/Sources/ContainerCommands/Container/ContainerRun.swift +++ b/Sources/ContainerCommands/Container/ContainerRun.swift @@ -63,9 +63,7 @@ extension Application { var arguments: [String] = [] public func run() async throws { - let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig( - configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url) - ) + let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load() var exitCode: Int32 = 127 let id = Utility.createContainerID(name: self.managementFlags.name) diff --git a/Sources/ContainerCommands/Image/ImageDelete.swift b/Sources/ContainerCommands/Image/ImageDelete.swift index 0353723b6..99ff0f1cc 100644 --- a/Sources/ContainerCommands/Image/ImageDelete.swift +++ b/Sources/ContainerCommands/Image/ImageDelete.swift @@ -109,9 +109,7 @@ extension Application { } public mutating func run() async throws { - let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig( - configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url) - ) + let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load() try await DeleteImageImplementation.removeImage(options: options, containerSystemConfig: containerSystemConfig, log: log) } } diff --git a/Sources/ContainerCommands/Image/ImageInspect.swift b/Sources/ContainerCommands/Image/ImageInspect.swift index 010fbc8a1..1cf2a9416 100644 --- a/Sources/ContainerCommands/Image/ImageInspect.swift +++ b/Sources/ContainerCommands/Image/ImageInspect.swift @@ -45,9 +45,7 @@ extension Application { } public func run() async throws { - let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig( - configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url) - ) + let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load() var printable: [ImageDetail] = [] var succeededImages: [String] = [] var allErrors: [(String, Error)] = [] diff --git a/Sources/ContainerCommands/Image/ImageList.swift b/Sources/ContainerCommands/Image/ImageList.swift index f3335b5fc..802b5f12e 100644 --- a/Sources/ContainerCommands/Image/ImageList.swift +++ b/Sources/ContainerCommands/Image/ImageList.swift @@ -45,9 +45,7 @@ extension Application { public var logOptions: Flags.Logging public mutating func run() async throws { - let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig( - configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url) - ) + let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load() try Self.validate(format: format, quiet: quiet, verbose: verbose) var images = try await ClientImage.list().filter { img in diff --git a/Sources/ContainerCommands/Image/ImagePull.swift b/Sources/ContainerCommands/Image/ImagePull.swift index 1ff8c4aba..4693f02fc 100644 --- a/Sources/ContainerCommands/Image/ImagePull.swift +++ b/Sources/ContainerCommands/Image/ImagePull.swift @@ -69,9 +69,7 @@ extension Application { } public func run() async throws { - let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig( - configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url) - ) + let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load() let p = try DefaultPlatform.resolve(platform: platform, os: os, arch: arch, log: log) let scheme = try RequestScheme(registry.scheme) diff --git a/Sources/ContainerCommands/Image/ImagePush.swift b/Sources/ContainerCommands/Image/ImagePush.swift index 96c1118cc..cdeeaf34b 100644 --- a/Sources/ContainerCommands/Image/ImagePush.swift +++ b/Sources/ContainerCommands/Image/ImagePush.swift @@ -57,9 +57,7 @@ extension Application { public init() {} public func run() async throws { - let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig( - configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url) - ) + let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load() let p = try DefaultPlatform.resolve(platform: platform, os: os, arch: arch, log: log) let scheme = try RequestScheme(registry.scheme) diff --git a/Sources/ContainerCommands/Image/ImageSave.swift b/Sources/ContainerCommands/Image/ImageSave.swift index c2e330ad1..d9bcc33ac 100644 --- a/Sources/ContainerCommands/Image/ImageSave.swift +++ b/Sources/ContainerCommands/Image/ImageSave.swift @@ -62,9 +62,7 @@ extension Application { @Argument var references: [String] public func run() async throws { - let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig( - configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url) - ) + let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load() let p = try DefaultPlatform.resolve(platform: platform, os: os, arch: arch, log: log) let progressConfig = try ProgressConfig( diff --git a/Sources/ContainerCommands/Image/ImageTag.swift b/Sources/ContainerCommands/Image/ImageTag.swift index a0f83945f..6c9f162f4 100644 --- a/Sources/ContainerCommands/Image/ImageTag.swift +++ b/Sources/ContainerCommands/Image/ImageTag.swift @@ -36,9 +36,7 @@ extension Application { public var logOptions: Flags.Logging public func run() async throws { - let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig( - configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url) - ) + let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load() let existing = try await ClientImage.get(reference: source, containerSystemConfig: containerSystemConfig) let targetReference = try ClientImage.normalizeReference(target, containerSystemConfig: containerSystemConfig) try await existing.tag(new: targetReference) diff --git a/Sources/ContainerCommands/Registry/RegistryLogin.swift b/Sources/ContainerCommands/Registry/RegistryLogin.swift index 225ea2ee9..a43e9ec24 100644 --- a/Sources/ContainerCommands/Registry/RegistryLogin.swift +++ b/Sources/ContainerCommands/Registry/RegistryLogin.swift @@ -47,9 +47,7 @@ extension Application { var server: String public func run() async throws { - let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig( - configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url) - ) + let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load() var username = self.username var password = "" if passwordStdin { diff --git a/Sources/ContainerCommands/System/Kernel/KernelSet.swift b/Sources/ContainerCommands/System/Kernel/KernelSet.swift index 3744d5a3c..f22a2254a 100644 --- a/Sources/ContainerCommands/System/Kernel/KernelSet.swift +++ b/Sources/ContainerCommands/System/Kernel/KernelSet.swift @@ -53,9 +53,7 @@ extension Application { public init() {} public func run() async throws { - let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig( - configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url) - ) + let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load() if recommended { let url = containerSystemConfig.kernel.url let path: String = containerSystemConfig.kernel.binaryPath diff --git a/Sources/ContainerCommands/System/Property/PropertyList.swift b/Sources/ContainerCommands/System/Property/PropertyList.swift index 8cd50afd4..cf6704e99 100644 --- a/Sources/ContainerCommands/System/Property/PropertyList.swift +++ b/Sources/ContainerCommands/System/Property/PropertyList.swift @@ -42,9 +42,7 @@ extension Application { public init() {} public func run() async throws { - let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig( - configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url) - ) + let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load() let output = switch format { case .json: try Output.renderJSON(containerSystemConfig) diff --git a/Sources/ContainerCommands/System/SystemStart.swift b/Sources/ContainerCommands/System/SystemStart.swift index abd0c2374..d1d00f259 100644 --- a/Sources/ContainerCommands/System/SystemStart.swift +++ b/Sources/ContainerCommands/System/SystemStart.swift @@ -72,12 +72,10 @@ extension Application { public init() {} public func run() async throws { - let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig( - configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url) - ) - // Copy the user's config into appRoot/config/ so that all plugins - // and the apiserver subsequently read the same snapshot. - SystemRuntimeOptions.copyConfigToAppRoot(appRoot: appRoot) + let appRootPath = FilePath(appRoot.path(percentEncoded: false)) + try ConfigurationLoader.copyConfigurationToReadOnly(to: appRootPath) + let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load( + configurationFile: ConfigurationLoader.configurationFile(in: appRootPath)) // Without the true path to the binary in the plist, `container-apiserver` won't launch properly. // Resolve the symlink to get the true binary path before writing the launchd plist. diff --git a/Sources/ContainerPersistence/ConfigurationLoader.swift b/Sources/ContainerPersistence/ConfigurationLoader.swift new file mode 100644 index 000000000..887ed83c1 --- /dev/null +++ b/Sources/ContainerPersistence/ConfigurationLoader.swift @@ -0,0 +1,111 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ContainerizationError +import Foundation +import SystemPackage +import TOML + +public protocol Initable { + init() +} + +public typealias LoadableConfiguration = Codable & Sendable & Initable + +public enum ConfigurationLoader { + private static let configFilename = "runtime-config.toml" + private static let configDirectory = "config" + private static let READ_ONLY: Int = 0o444 + private static let READ_AND_WRITE: Int = 0o644 + + /// Returns the canonical configuration file path under an appRoot base directory: + /// `/config/runtime-config.toml`. + public static func configurationFile(in base: FilePath) -> FilePath { + base.appending(configDirectory).appending(configFilename) + } + + /// Loads and decodes a TOML configuration file as type `T`. + /// + /// - Parameter configurationFile: Absolute path to the configuration file. + /// When `nil`, falls back to + /// `configurationFile(in: PathUtils.BaseConfigPath.appRoot.basePath())`. + /// - Returns: A decoded value of type `T`, or a default-initialized `T` if the + /// configuration file does not exist. + public static func load(configurationFile: FilePath? = nil) throws -> T { + let path = configurationFile ?? Self.configurationFile(in: PathUtils.BaseConfigPath.appRoot.basePath()) + guard FileManager.default.fileExists(atPath: path.string) else { + return T() + } + do { + let data = try Data(contentsOf: URL(filePath: path.string)) + return try TOMLDecoder().decode(T.self, from: data) + } catch { + throw ContainerizationError( + .invalidArgument, + message: "failed to load configuration from '\(path)': \(error)" + ) + } + } + + /// Copies a TOML configuration file into a read-only destination under an appRoot base. + /// + /// - Parameters: + /// - source: The file to copy. When `nil`, defaults to + /// `/container/runtime-config.toml`. If the source does not exist, + /// this is a no-op. + /// - destination: Base directory under which the file is written at + /// `/config/runtime-config.toml`. When `nil`, falls back to + /// `PathUtils.BaseConfigPath.appRoot.basePath()`. The destination file is written + /// with `READ_ONLY` (`0o444`) permissions. + public static func copyConfigurationToReadOnly( + from source: FilePath? = nil, + to destination: FilePath? = nil + ) throws { + let source = + source + ?? PathUtils.BaseConfigPath.home.basePath() + .appending(configFilename) + let destinationFile = Self.configurationFile(in: destination ?? PathUtils.BaseConfigPath.appRoot.basePath()) + do { + let fm = FileManager.default + guard fm.fileExists(atPath: source.string) else { return } + + let destDir = destinationFile.removingLastComponent() + try fm.createDirectory( + atPath: destDir.string, + withIntermediateDirectories: true + ) + if fm.fileExists(atPath: destinationFile.string) { + try fm.setAttributes( + [.posixPermissions: READ_AND_WRITE], + ofItemAtPath: destinationFile.string + ) + try fm.removeItem(at: URL(filePath: destinationFile.string)) + } + try fm.copyItem( + at: URL(filePath: source.string), + to: URL(filePath: destinationFile.string) + ) + try fm.setAttributes( + [.posixPermissions: READ_ONLY], + ofItemAtPath: destinationFile.string + ) + } catch { + throw ContainerizationError( + .invalidState, message: "Failed to copy user TOML to AppRoot `\(error)`") + } + } +} diff --git a/Sources/ContainerPersistence/ContainerSystemConfig.swift b/Sources/ContainerPersistence/ContainerSystemConfig.swift index ead3a3241..42c425e35 100644 --- a/Sources/ContainerPersistence/ContainerSystemConfig.swift +++ b/Sources/ContainerPersistence/ContainerSystemConfig.swift @@ -23,7 +23,7 @@ import Foundation /// /// Each section maps to a nested struct. Missing keys fall back to /// hardcoded defaults via custom `init(from:)` implementations. -final public class ContainerSystemConfig: Codable, Sendable { +public final class ContainerSystemConfig: Codable, Sendable, Initable { public let build: BuildConfig public let container: ContainerConfig public let dns: DNSConfig @@ -50,6 +50,16 @@ final public class ContainerSystemConfig: Codable, Sendable { self.vminit = vminit } + public init() { + self.build = .init() + self.container = .init() + self.dns = .init() + self.kernel = .init() + self.network = .init() + self.registry = .init() + self.vminit = .init() + } + public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.build = try container.decodeIfPresent(BuildConfig.self, forKey: .build) ?? .init() diff --git a/Sources/ContainerPersistence/PathUtils.swift b/Sources/ContainerPersistence/PathUtils.swift new file mode 100644 index 000000000..a5a8df10e --- /dev/null +++ b/Sources/ContainerPersistence/PathUtils.swift @@ -0,0 +1,47 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Foundation +import SystemPackage + +public enum PathUtils { + public enum BaseConfigPath { + case home + case appRoot + + public func basePath(env: [String: String] = ProcessInfo.processInfo.environment) -> FilePath { + switch self { + case .home: + let configHome: String + if let xdg = env["XDG_CONFIG_HOME"], !xdg.isEmpty { + configHome = xdg + } else { + configHome = NSHomeDirectory() + "/.config" + } + return FilePath(configHome).appending("container") + case .appRoot: + if let envPath = env["CONTAINER_APP_ROOT"], !envPath.isEmpty { + return FilePath(envPath) + } + let appSupportURL = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first!.appendingPathComponent("com.apple.container") + return FilePath(appSupportURL.path(percentEncoded: false)) + } + } + } +} diff --git a/Sources/ContainerPersistence/SystemRuntimeOptions.swift b/Sources/ContainerPersistence/SystemRuntimeOptions.swift deleted file mode 100644 index 098a91278..000000000 --- a/Sources/ContainerPersistence/SystemRuntimeOptions.swift +++ /dev/null @@ -1,109 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2026 Apple Inc. and the container project authors. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// https://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -//===----------------------------------------------------------------------===// - -import ContainerizationError -import Foundation -import Logging -import SystemPackage -import TOML - -private let log = Logger(label: "SystemRuntimeOptions") - -/// TOML-backed configuration loader. -/// -/// Decodes a user-provided TOML file into a typed configuration struct. -/// Missing keys fall back to the struct's hardcoded defaults (via custom -/// `init(from:)` implementations using `decodeIfPresent`). -/// -/// Configuration priority (highest to lowest): -/// 1. User config: `$XDG_CONFIG_HOME/container/runtime-config.toml` -/// 2. Hardcoded defaults in the config struct's initializer -public enum SystemRuntimeOptions { - /// Path to the user's configuration file. - public static var defaultUserConfigPath: URL { - let configHome: String - if let xdg = ProcessInfo.processInfo.environment["XDG_CONFIG_HOME"], !xdg.isEmpty { - configHome = xdg - } else { - configHome = NSHomeDirectory() + "/.config" - } - return URL(fileURLWithPath: configHome) - .appendingPathComponent("container") - .appendingPathComponent("runtime-config.toml") - } - - /// The path to the config file within an application root directory. - public static func configFileFromAppRoot(_ appRoot: URL) -> URL { - appRoot - .appendingPathComponent("config") - .appendingPathComponent("runtime-config.toml") - } - - /// Load configuration by decoding a TOML file. - /// - /// - Parameters: - /// - configFile: Full path to the TOML config file. - /// - Returns: The decoded configuration. If the file does not exist, all values - /// fall back to the type's hardcoded defaults. - public static func loadConfig(configFile: URL) throws -> T { - let fm = FileManager.default - let path = configFile.path(percentEncoded: false) - guard fm.fileExists(atPath: path) else { - do { - return try TOMLDecoder().decode(T.self, from: Data("".utf8)) - } catch { - throw ContainerizationError(.internalError, message: "failed to initialize default configuration: \(error)") - } - } - do { - let data = try Data(contentsOf: configFile) - return try TOMLDecoder().decode(T.self, from: data) - } catch { - throw ContainerizationError(.invalidArgument, message: "failed to load configuration from '\(path)': \(error)") - } - } - - public static func copyConfigToAppRoot( - appRoot: URL, - userConfigPath: URL = defaultUserConfigPath, - ) { - let fm = FileManager.default - - if fm.fileExists(atPath: userConfigPath.path(percentEncoded: false)) { - let configDir = appRoot.appendingPathComponent("config") - let destPath = configDir.appendingPathComponent("runtime-config.toml") - do { - try fm.createDirectory(at: configDir, withIntermediateDirectories: true) - if fm.fileExists(atPath: destPath.path(percentEncoded: false)) { - try fm.setAttributes([.posixPermissions: 0o644], ofItemAtPath: destPath.path(percentEncoded: false)) - try fm.removeItem(at: destPath) - } - try fm.copyItem( - at: URL(fileURLWithPath: userConfigPath.path(percentEncoded: false)), - to: destPath - ) - try fm.setAttributes( - [.posixPermissions: 0o444], - ofItemAtPath: destPath.path(percentEncoded: false) - ) - log.info("copied runtime config", metadata: ["dest": "\(destPath.path(percentEncoded: false))"]) - } catch { - // If the config copy-ing fails, we will log an error but it is not fatal since we can utilize the fallback config. - log.error("failed to copy runtime config to app root", metadata: ["error": "\(error)"]) - } - } - } -} diff --git a/Sources/Plugins/CoreImages/ImagesHelper.swift b/Sources/Plugins/CoreImages/ImagesHelper.swift index 0a636cbb1..e7a16aa3b 100644 --- a/Sources/Plugins/CoreImages/ImagesHelper.swift +++ b/Sources/Plugins/CoreImages/ImagesHelper.swift @@ -58,9 +58,7 @@ extension ImagesHelper { var logRoot = LogRoot.path func run() async throws { - let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig( - configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url) - ) + let containerSystemConfig: ContainerSystemConfig = try ConfigurationLoader.load() let commandName = ImagesHelper._commandName let logPath = logRoot.map { $0.appending("\(commandName).log") } let log = ServiceLogger.bootstrap(category: "ImagesHelper", debug: debug, logPath: logPath) diff --git a/Tests/ContainerAPIClientTests/ConfigurationLoaderTests.swift b/Tests/ContainerAPIClientTests/ConfigurationLoaderTests.swift new file mode 100644 index 000000000..dfa322418 --- /dev/null +++ b/Tests/ContainerAPIClientTests/ConfigurationLoaderTests.swift @@ -0,0 +1,246 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ContainerTestSupport +import ContainerizationExtras +import Foundation +import SystemPackage +import Testing + +@testable import ContainerPersistence + +struct ConfigurationLoaderTests { + private static func writeToml(_ contents: String, to path: FilePath) throws { + try contents.write( + to: URL(filePath: path.string), + atomically: true, + encoding: .utf8 + ) + } + + @Test func testDefaultsWithNoFile() async throws { + try await TemporaryStorage.withTempDir { tempDir in + let path = tempDir.appending("nonexistent.toml") + let config: ContainerSystemConfig = try ConfigurationLoader.load(configurationFile: path) + #expect(config.build.rosetta == true) + #expect(config.build.cpus == 2) + #expect(config.build.memory == BuildConfig.defaultMemory) + #expect(config.container.cpus == 4) + #expect(config.container.memory == ContainerConfig.defaultMemory) + #expect(config.dns.domain == nil) + #expect(!config.build.image.isEmpty) + #expect(!config.vminit.image.isEmpty) + #expect(!config.kernel.binaryPath.isEmpty) + #expect(!config.kernel.url.absoluteString.isEmpty) + #expect(config.network.subnet == nil) + #expect(config.network.subnetv6 == nil) + #expect(config.registry.domain == "docker.io") + } + } + + @Test func testTomlOverrideAllKeys() async throws { + try await TemporaryStorage.withTempDir { tempDir in + let toml = """ + [build] + rosetta = false + cpus = 8 + memory = "4096MB" + image = "custom-builder:latest" + + [container] + cpus = 16 + memory = "8g" + + [dns] + domain = "custom" + + [kernel] + binaryPath = "custom/path" + url = "https://example.com/kernel.tar" + + [network] + subnet = "10.0.0.1/16" + subnetv6 = "fd01::/48" + + [registry] + domain = "ghcr.io" + + [vminit] + image = "custom-init:latest" + """ + let tmpFile = tempDir.appending("test.toml") + try Self.writeToml(toml, to: tmpFile) + + let config: ContainerSystemConfig = try ConfigurationLoader.load(configurationFile: tmpFile) + #expect(config.build.rosetta == false) + #expect(config.build.cpus == 8) + let expectedBuildMemory = try MemorySize("4096MB") + #expect(config.build.memory == expectedBuildMemory) + #expect(config.container.cpus == 16) + let expectedContainerMemory = try MemorySize("8g") + #expect(config.container.memory == expectedContainerMemory) + #expect(config.dns.domain == "custom") + #expect(config.build.image == "custom-builder:latest") + #expect(config.vminit.image == "custom-init:latest") + #expect(config.kernel.binaryPath == "custom/path") + #expect(config.kernel.url.absoluteString == "https://example.com/kernel.tar") + let expectedSubnet = try CIDRv4("10.0.0.1/16") + let expectedSubnetV6 = try CIDRv6("fd01::/48") + #expect(config.network.subnet == expectedSubnet) + #expect(config.network.subnetv6 == expectedSubnetV6) + #expect(config.registry.domain == "ghcr.io") + } + } + + @Test func testPartialToml() async throws { + try await TemporaryStorage.withTempDir { tempDir in + let toml = """ + [build] + cpus = 16 + """ + let tmpFile = tempDir.appending("test.toml") + try Self.writeToml(toml, to: tmpFile) + + let config: ContainerSystemConfig = try ConfigurationLoader.load(configurationFile: tmpFile) + #expect(config.build.cpus == 16) + #expect(config.build.rosetta == true) + #expect(config.build.memory == BuildConfig.defaultMemory) + #expect(config.container.cpus == 4) + #expect(config.container.memory == ContainerConfig.defaultMemory) + } + } + + @Test func testUnknownKeysIgnored() async throws { + try await TemporaryStorage.withTempDir { tempDir in + let toml = """ + [build] + cpus = 4 + unknownBuildKey = "ignored" + + [unknownSection] + foo = "bar" + """ + let tmpFile = tempDir.appending("test.toml") + try Self.writeToml(toml, to: tmpFile) + + let config: ContainerSystemConfig = try ConfigurationLoader.load(configurationFile: tmpFile) + #expect(config.build.cpus == 4) + #expect(config.build.rosetta == true) + } + } + + @Test func testInvalidTomlThrows() async throws { + try await TemporaryStorage.withTempDir { tempDir in + let tmpFile = tempDir.appending("test-invalid.toml") + try Self.writeToml("this is [not valid toml", to: tmpFile) + #expect(throws: (any Error).self) { + let _: ContainerSystemConfig = try ConfigurationLoader.load(configurationFile: tmpFile) + } + } + } + + @Test func testEmptyTomlDecodesToDefaults() async throws { + try await TemporaryStorage.withTempDir { tempDir in + let tmpFile = tempDir.appending("test.toml") + try Self.writeToml("", to: tmpFile) + + let config: ContainerSystemConfig = try ConfigurationLoader.load(configurationFile: tmpFile) + #expect(config.build.rosetta == true) + #expect(config.build.cpus == 2) + #expect(config.container.cpus == 4) + #expect(config.registry.domain == "docker.io") + } + } + + @Test func testCopyConfigToAppRoot() async throws { + try await TemporaryStorage.withTempDir { tempDir in + let source = tempDir.appending("runtime-config.toml") + try Self.writeToml("[build]\ncpus = 8", to: source) + + let destBase = tempDir.appending("dest") + try ConfigurationLoader.copyConfigurationToReadOnly(from: source, to: destBase) + + let destFile = ConfigurationLoader.configurationFile(in: destBase) + let copied = try String(contentsOf: URL(filePath: destFile.string), encoding: .utf8) + #expect(copied.contains("cpus = 8")) + + let attrs = try FileManager.default.attributesOfItem(atPath: destFile.string) + let perms = attrs[.posixPermissions] as! Int + #expect(perms == 0o444) + } + } + + @Test func testCopyConfigOverwritesExistingReadOnlyDestination() async throws { + try await TemporaryStorage.withTempDir { tempDir in + let source = tempDir.appending("runtime-config.toml") + let destBase = tempDir.appending("dest") + + try Self.writeToml("[build]\ncpus = 8", to: source) + try ConfigurationLoader.copyConfigurationToReadOnly(from: source, to: destBase) + + try Self.writeToml("[build]\ncpus = 16", to: source) + try ConfigurationLoader.copyConfigurationToReadOnly(from: source, to: destBase) + + let destFile = ConfigurationLoader.configurationFile(in: destBase) + let copied = try String(contentsOf: URL(filePath: destFile.string), encoding: .utf8) + #expect(copied.contains("cpus = 16")) + + let attrs = try FileManager.default.attributesOfItem(atPath: destFile.string) + let perms = attrs[.posixPermissions] as! Int + #expect(perms == 0o444) + } + } + + @Test func testCopyConfigNoOpsWhenSourceMissing() async throws { + try await TemporaryStorage.withTempDir { tempDir in + let source = tempDir.appending("nonexistent.toml") + let destBase = tempDir.appending("dest") + try ConfigurationLoader.copyConfigurationToReadOnly(from: source, to: destBase) + let destFile = ConfigurationLoader.configurationFile(in: destBase) + #expect(!FileManager.default.fileExists(atPath: destFile.string)) + } + } + + @Test func testIndependentPluginConfig() async throws { + struct PluginConfig: Codable, Sendable, Initable { + var network: NetworkConfig + init() { self.network = .init() } + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.network = try container.decodeIfPresent(NetworkConfig.self, forKey: .network) ?? .init() + } + } + + try await TemporaryStorage.withTempDir { tempDir in + let toml = """ + [build] + cpus = 8 + + [network] + subnet = "10.1.2.3/24" + subnetv6 = "fd02::/48" + """ + let tmpFile = tempDir.appending("test.toml") + try Self.writeToml(toml, to: tmpFile) + + let config: PluginConfig = try ConfigurationLoader.load(configurationFile: tmpFile) + let expectedSubnet = try CIDRv4("10.1.2.3/24") + let expectedSubnetV6 = try CIDRv6("fd02::/48") + #expect(config.network.subnet == expectedSubnet) + #expect(config.network.subnetv6 == expectedSubnetV6) + } + } +} diff --git a/Tests/ContainerAPIClientTests/ParserTest.swift b/Tests/ContainerAPIClientTests/ParserTest.swift index bc5997f41..396466003 100644 --- a/Tests/ContainerAPIClientTests/ParserTest.swift +++ b/Tests/ContainerAPIClientTests/ParserTest.swift @@ -14,13 +14,14 @@ // limitations under the License. //===----------------------------------------------------------------------===// -import ContainerPersistence import ContainerizationError import ContainerizationExtras import Foundation +import SystemPackage import Testing @testable import ContainerAPIClient +@testable import ContainerPersistence struct ParserTest { @Test @@ -1210,7 +1211,7 @@ struct ParserTest { FileManager.default.createFile(atPath: tempFile.path(), contents: Data(content.utf8)) defer { try? FileManager.default.removeItem(at: tempFile) } - let config: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(configFile: tempFile) + let config: ContainerSystemConfig = try ConfigurationLoader.load(configurationFile: FilePath(tempFile.path(percentEncoded: false))) let result = try Parser.resources( cpus: nil, memory: nil, defaultCPUs: config.build.cpus, @@ -1229,7 +1230,7 @@ struct ParserTest { FileManager.default.createFile(atPath: tempFile.path(), contents: Data(content.utf8)) defer { try? FileManager.default.removeItem(at: tempFile) } - let config: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(configFile: tempFile) + let config: ContainerSystemConfig = try ConfigurationLoader.load(configurationFile: FilePath(tempFile.path(percentEncoded: false))) let result = try Parser.resources( cpus: nil, memory: nil, defaultCPUs: config.container.cpus, @@ -1247,7 +1248,7 @@ struct ParserTest { FileManager.default.createFile(atPath: tempFile.path(), contents: Data(content.utf8)) defer { try? FileManager.default.removeItem(at: tempFile) } - let config: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(configFile: tempFile) + let config: ContainerSystemConfig = try ConfigurationLoader.load(configurationFile: FilePath(tempFile.path(percentEncoded: false))) let result = try Parser.resources( cpus: nil, memory: nil, defaultCPUs: config.container.cpus, @@ -1266,7 +1267,7 @@ struct ParserTest { FileManager.default.createFile(atPath: tempFile.path(), contents: Data(content.utf8)) defer { try? FileManager.default.removeItem(at: tempFile) } - let config: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(configFile: tempFile) + let config: ContainerSystemConfig = try ConfigurationLoader.load(configurationFile: FilePath(tempFile.path(percentEncoded: false))) let result = try Parser.resources( cpus: 1, memory: "256m", defaultCPUs: config.container.cpus, @@ -1286,7 +1287,7 @@ struct ParserTest { FileManager.default.createFile(atPath: tempFile.path(), contents: Data(content.utf8)) defer { try? FileManager.default.removeItem(at: tempFile) } - let config: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(configFile: tempFile) + let config: ContainerSystemConfig = try ConfigurationLoader.load(configurationFile: FilePath(tempFile.path(percentEncoded: false))) let result = try Parser.resources( cpus: nil, memory: nil, defaultCPUs: config.build.cpus, diff --git a/Tests/ContainerAPIClientTests/PathUtilsTests.swift b/Tests/ContainerAPIClientTests/PathUtilsTests.swift new file mode 100644 index 000000000..4381d1aa3 --- /dev/null +++ b/Tests/ContainerAPIClientTests/PathUtilsTests.swift @@ -0,0 +1,71 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ContainerPersistence +import Foundation +import SystemPackage +import Testing + +struct PathUtilsTests { + private static let homeFallback = FilePath(NSHomeDirectory() + "/.config").appending("container") + private static let appRootFallback: FilePath = { + let url = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first!.appendingPathComponent("com.apple.container") + return FilePath(url.path(percentEncoded: false)) + }() + + @Test func testHomeUsesXdgConfigHomeWhenSet() { + let path = PathUtils.BaseConfigPath.home.basePath(env: ["XDG_CONFIG_HOME": "/tmp/xdg-test"]) + #expect(path == FilePath("/tmp/xdg-test/container")) + } + + @Test func testHomeFallsBackToHomeDirectoryWhenXdgUnset() { + let path = PathUtils.BaseConfigPath.home.basePath(env: [:]) + #expect(path == Self.homeFallback) + } + + @Test func testHomeTreatsEmptyXdgAsUnset() { + let path = PathUtils.BaseConfigPath.home.basePath(env: ["XDG_CONFIG_HOME": ""]) + #expect(path == Self.homeFallback) + } + + @Test func testAppRootUsesContainerAppRootWhenSet() { + let path = PathUtils.BaseConfigPath.appRoot.basePath(env: ["CONTAINER_APP_ROOT": "/tmp/foo"]) + #expect(path == FilePath("/tmp/foo")) + } + + @Test func testAppRootFallsBackToApplicationSupportWhenUnset() { + let path = PathUtils.BaseConfigPath.appRoot.basePath(env: [:]) + #expect(path == Self.appRootFallback) + } + + @Test func testAppRootTreatsEmptyEnvAsUnset() { + let path = PathUtils.BaseConfigPath.appRoot.basePath(env: ["CONTAINER_APP_ROOT": ""]) + #expect(path == Self.appRootFallback) + } + + @Test func testAppRootIgnoresXdgConfigHome() { + let path = PathUtils.BaseConfigPath.appRoot.basePath(env: ["XDG_CONFIG_HOME": "/tmp/xdg-test"]) + #expect(path == Self.appRootFallback) + } + + @Test func testHomeIgnoresContainerAppRoot() { + let path = PathUtils.BaseConfigPath.home.basePath(env: ["CONTAINER_APP_ROOT": "/tmp/foo"]) + #expect(path == Self.homeFallback) + } +} diff --git a/Tests/ContainerAPIClientTests/SystemRuntimeOptionsTests.swift b/Tests/ContainerAPIClientTests/SystemRuntimeOptionsTests.swift deleted file mode 100644 index 6e7f2c4d9..000000000 --- a/Tests/ContainerAPIClientTests/SystemRuntimeOptionsTests.swift +++ /dev/null @@ -1,197 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2026 Apple Inc. and the container project authors. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// https://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -//===----------------------------------------------------------------------===// - -import ContainerPersistence -import ContainerizationExtras -import Foundation -import Testing - -struct SystemRuntimeOptionsTests { - @Test func testDefaultsWithNoToml() throws { - let url = FileManager.default.temporaryDirectory - .appendingPathComponent("nonexistent-\(UUID().uuidString).toml") - let config: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(configFile: url) - #expect(config.build.rosetta == true) - #expect(config.build.cpus == 2) - #expect(config.build.memory == BuildConfig.defaultMemory) - #expect(config.container.cpus == 4) - #expect(config.container.memory == ContainerConfig.defaultMemory) - #expect(config.dns.domain == nil) - #expect(!config.build.image.isEmpty) - #expect(!config.vminit.image.isEmpty) - #expect(!config.kernel.binaryPath.isEmpty) - #expect(!config.kernel.url.absoluteString.isEmpty) - #expect(config.network.subnet == nil) - #expect(config.network.subnetv6 == nil) - #expect(config.registry.domain == "docker.io") - } - - @Test func testTomlOverrideAllKeys() throws { - let toml = """ - [build] - rosetta = false - cpus = 8 - memory = "4096MB" - image = "custom-builder:latest" - - [container] - cpus = 16 - memory = "8g" - - [dns] - domain = "custom" - - [kernel] - binaryPath = "custom/path" - url = "https://example.com/kernel.tar" - - [network] - subnet = "10.0.0.1/16" - subnetv6 = "fd01::/48" - - [registry] - domain = "ghcr.io" - - [vminit] - image = "custom-init:latest" - """ - let tmpFile = FileManager.default.temporaryDirectory - .appendingPathComponent("test-\(UUID().uuidString).toml") - try toml.write(to: tmpFile, atomically: true, encoding: .utf8) - defer { try? FileManager.default.removeItem(at: tmpFile) } - - let config: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(configFile: tmpFile) - #expect(config.build.rosetta == false) - #expect(config.build.cpus == 8) - let expectedBuildMemory = try MemorySize("4096MB") - #expect(config.build.memory == expectedBuildMemory) - #expect(config.container.cpus == 16) - let expectedContainerMemory = try MemorySize("8g") - #expect(config.container.memory == expectedContainerMemory) - #expect(config.dns.domain == "custom") - #expect(config.build.image == "custom-builder:latest") - #expect(config.vminit.image == "custom-init:latest") - #expect(config.kernel.binaryPath == "custom/path") - #expect(config.kernel.url.absoluteString == "https://example.com/kernel.tar") - let expectedSubnet = try CIDRv4("10.0.0.1/16") - let expectedSubnetV6 = try CIDRv6("fd01::/48") - #expect(config.network.subnet == expectedSubnet) - #expect(config.network.subnetv6 == expectedSubnetV6) - #expect(config.registry.domain == "ghcr.io") - } - - @Test func testPartialToml() throws { - let toml = """ - [build] - cpus = 16 - """ - let tmpFile = FileManager.default.temporaryDirectory - .appendingPathComponent("test-\(UUID().uuidString).toml") - try toml.write(to: tmpFile, atomically: true, encoding: .utf8) - defer { try? FileManager.default.removeItem(at: tmpFile) } - - let config: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(configFile: tmpFile) - #expect(config.build.cpus == 16) - // All other fields should have their hardcoded defaults - #expect(config.build.rosetta == true) - #expect(config.build.memory == BuildConfig.defaultMemory) - #expect(config.container.cpus == 4) - #expect(config.container.memory == ContainerConfig.defaultMemory) - #expect(config.dns.domain == nil) - #expect(config.network.subnet == nil) - #expect(config.network.subnetv6 == nil) - #expect(config.registry.domain == "docker.io") - } - - @Test func testUnknownKeysIgnored() throws { - let toml = """ - [build] - cpus = 4 - unknownBuildKey = "ignored" - - [unknownSection] - foo = "bar" - """ - let tmpFile = FileManager.default.temporaryDirectory - .appendingPathComponent("test-\(UUID().uuidString).toml") - try toml.write(to: tmpFile, atomically: true, encoding: .utf8) - defer { try? FileManager.default.removeItem(at: tmpFile) } - - let config: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(configFile: tmpFile) - #expect(config.build.cpus == 4) - #expect(config.build.rosetta == true) - } - - @Test func testIndependentPluginConfig() throws { - struct PluginConfig: Codable, Sendable { - var network: NetworkConfig - init(network: NetworkConfig = .init()) { self.network = network } - init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.network = try container.decodeIfPresent(NetworkConfig.self, forKey: .network) ?? .init() - } - } - - let toml = """ - [build] - cpus = 8 - - [network] - subnet = "10.1.2.3/24" - subnetv6 = "fd02::/48" - """ - let tmpFile = FileManager.default.temporaryDirectory - .appendingPathComponent("test-\(UUID().uuidString).toml") - try toml.write(to: tmpFile, atomically: true, encoding: .utf8) - defer { try? FileManager.default.removeItem(at: tmpFile) } - - let config: PluginConfig = try SystemRuntimeOptions.loadConfig(configFile: tmpFile) - // Only network is decoded; build section is ignored - let expectedSubnet = try CIDRv4("10.1.2.3/24") - let expectedSubnetV6 = try CIDRv6("fd02::/48") - #expect(config.network.subnet == expectedSubnet) - #expect(config.network.subnetv6 == expectedSubnetV6) - } - - @Test func testInvalidTomlThrows() throws { - let tmpFile = FileManager.default.temporaryDirectory - .appendingPathComponent("test-invalid-toml.toml") - try "this is [not valid toml".write(to: tmpFile, atomically: true, encoding: .utf8) - defer { try? FileManager.default.removeItem(at: tmpFile) } - #expect(throws: (any Error).self) { - let _: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(configFile: tmpFile) - } - } - - @Test func testEmptyTomlDecodesToDefaults() throws { - let tmpFile = FileManager.default.temporaryDirectory - .appendingPathComponent("test-\(UUID().uuidString).toml") - try "".write(to: tmpFile, atomically: true, encoding: .utf8) - defer { try? FileManager.default.removeItem(at: tmpFile) } - - let config: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig(configFile: tmpFile) - #expect(config.build.rosetta == true) - #expect(config.build.cpus == 2) - #expect(config.build.memory == BuildConfig.defaultMemory) - #expect(config.container.cpus == 4) - #expect(config.container.memory == ContainerConfig.defaultMemory) - #expect(config.dns.domain == nil) - #expect(config.network.subnet == nil) - #expect(config.network.subnetv6 == nil) - #expect(config.registry.domain == "docker.io") - } - -}