From e3c49803a00452ef261514bf0e4eef8206f02b83 Mon Sep 17 00:00:00 2001 From: Noah Thornton Date: Mon, 4 May 2026 12:04:24 -0700 Subject: [PATCH] Move to TOML configuration for defaults (#1425) - Discussion topic #1336. - This change migrates away from using `UserDefaults`, instead providing a TOML configuration mechanism for user configurable settings. All existing system property settings keys are supported in the new configuration file. However, users will have to migrate any settings they have configured in the `UserDefaults` into TOML for these settings to take effect. - Breaking changes: * `container system property get` is removed in favor of users directly utilizing `container system property list --format toml | jq<>`. * `container system property set` is removed since the TOML configuration is effectively immutable during the lifetime of the `container` daemon. Uses can edit the TOML they have in their home directory, however no changes will take effect until the daemon is restarted via `container system stop && container system start` * `container system property list --format table` is removed as generating tabular format is non-trivial and the new TOML format is intended to be human readable --- BUILDING.md | 23 +- Package.resolved | 4 +- Package.swift | 6 + Sources/APIServer/APIServer+Start.swift | 19 +- .../ContainerBuild/BuildImageResolver.swift | 11 +- .../ContainerBuild/BuildPipelineHandler.swift | 8 +- Sources/ContainerBuild/Builder.swift | 6 +- Sources/ContainerCommands/BuildCommand.swift | 15 +- .../Builder/BuilderStart.swift | 50 ++-- .../Container/ContainerCreate.swift | 6 + .../Container/ContainerRun.swift | 6 + .../ContainerCommands/Image/ImageDelete.swift | 19 +- .../Image/ImageInspect.swift | 15 +- .../ContainerCommands/Image/ImageList.swift | 21 +- .../ContainerCommands/Image/ImagePull.swift | 10 +- .../ContainerCommands/Image/ImagePush.swift | 9 +- .../ContainerCommands/Image/ImageSave.swift | 11 +- .../ContainerCommands/Image/ImageTag.swift | 9 +- Sources/ContainerCommands/ListFormat.swift | 1 + .../ContainerCommands/OutputRendering.swift | 9 + .../Registry/RegistryLogin.swift | 7 +- .../System/Kernel/KernelSet.swift | 15 +- .../System/Property/PropertyClear.swift | 46 ---- .../System/Property/PropertyGet.swift | 53 ---- .../System/Property/PropertyList.swift | 67 ++--- .../System/Property/PropertySet.swift | 95 ------- .../System/SystemProperty.swift | 5 +- .../System/SystemStart.swift | 50 ++-- .../System/SystemVersion.swift | 2 + .../ContainerSystemConfig.swift | 223 +++++++++++++++++ .../ContainerPersistence/DefaultsStore.swift | 231 ------------------ .../Measurement+Parse.swift | 2 +- Sources/ContainerPersistence/MemorySize.swift | 56 +++++ .../SystemRuntimeOptions.swift | 109 +++++++++ Sources/Plugins/CoreImages/ImagesHelper.swift | 13 +- .../Client/ClientImage.swift | 58 +++-- .../ContainerAPIService/Client/Parser.swift | 16 +- .../Client/RequestScheme.swift | 14 +- .../ContainerAPIService/Client/Utility.swift | 28 ++- .../Server/Containers/ContainersService.swift | 10 +- .../Server/SnapshotStore.swift | 19 +- .../Server/ReservedVmnetNetwork.swift | 4 +- .../Images/TestCLIImagesCommand.swift | 31 --- .../Subcommands/Run/TestCLIRunCommand.swift | 10 +- .../Subcommands/Run/TestCLIRunInitImage.swift | 13 +- .../Subcommands/System/TestKernelSet.swift | 9 +- Tests/CLITests/TestCLINoParallelCases.swift | 72 ------ Tests/CLITests/Utilities/CLITest.swift | 31 +-- .../MemorySizeTests.swift | 75 ++++++ .../ContainerAPIClientTests/ParserTest.swift | 112 +++++++++ .../RequestSchemeTests.swift | 13 +- .../SystemRuntimeOptionsTests.swift | 197 +++++++++++++++ .../ListFormattingTests.swift | 25 +- docs/command-reference.md | 103 +------- docs/how-to.md | 58 +++-- docs/tutorial.md | 9 +- 56 files changed, 1168 insertions(+), 971 deletions(-) delete mode 100644 Sources/ContainerCommands/System/Property/PropertyClear.swift delete mode 100644 Sources/ContainerCommands/System/Property/PropertyGet.swift delete mode 100644 Sources/ContainerCommands/System/Property/PropertySet.swift create mode 100644 Sources/ContainerPersistence/ContainerSystemConfig.swift delete mode 100644 Sources/ContainerPersistence/DefaultsStore.swift rename Sources/{Services/ContainerAPIService/Client => ContainerPersistence}/Measurement+Parse.swift (97%) create mode 100644 Sources/ContainerPersistence/MemorySize.swift create mode 100644 Sources/ContainerPersistence/SystemRuntimeOptions.swift create mode 100644 Tests/ContainerAPIClientTests/MemorySizeTests.swift create mode 100644 Tests/ContainerAPIClientTests/SystemRuntimeOptionsTests.swift diff --git a/BUILDING.md b/BUILDING.md index 8f80dd11..67b02c7b 100644 --- a/BUILDING.md +++ b/BUILDING.md @@ -80,10 +80,11 @@ to prepare your build environment. > > **Note:** If you have already run `swift package edit`, whether intentionally or by accident, follow the steps in the next section to restore the normal `containerization` dependency. Otherwise, the modified `Package.swift` file will not work, and the project may fail to build. -5. If you want `container` to use any changes you made in the `vminit` subproject of Containerization, update the system property to use the locally built init filesystem image: +5. If you want `container` to use any changes you made in the `vminit` subproject of Containerization, set the init image in your runtime configuration file at `~/.config/container/runtime-config.toml`: - ```bash - container system property set image.init vminit:latest + ```toml + [vminit] + image = "vminit:latest" ``` 6. Build `container`. @@ -101,11 +102,7 @@ to prepare your build environment. To revert to using the Containerization dependency from your `Package.swift`: -1. If you were using the local init filesystem, revert the system property to its default value: - - ```bash - container system property clear image.init - ``` +1. If you were using the local init filesystem, remove the `init` override from your `~/.config/container/runtime-config.toml` (or delete the `[vminit]` section if no other image settings are present). 2. Use the Swift package manager to restore the normal `containerization` dependency and update your `Package.resolved` file. If you are using Xcode, revert your `Package.swift` change instead of using `swift package unedit`. @@ -133,14 +130,20 @@ To test changes that require the `container-builder-shim` project: 1. Clone the [container-builder-shim](https://github.com/apple/container-builder-shim) repository and navigate to its directory. -2. After making the necessary changes, build the custom builder image, set it as the active builder image, and remove the existing `buildkit` container so the new image will be used: +2. After making the necessary changes, build the custom builder image, set it as the active builder image in `~/.config/container/runtime-config.toml`, and remove the existing `buildkit` container so the new image will be used: ```bash container build -t builder . -container system property set image.builder builder:latest container rm -f buildkit ``` +Add the following to your `~/.config/container/runtime-config.toml`: + +```toml +[build] +image = "builder:latest" +``` + 3. Run the `container` build as usual: ```bash diff --git a/Package.resolved b/Package.resolved index ab2a2afd..d97081a2 100644 --- a/Package.resolved +++ b/Package.resolved @@ -105,8 +105,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { - "revision" : "8c0c0a8b49e080e54e5e328cc552821ff07cd341", - "version" : "1.2.1" + "revision" : "6675bc0ff86e61436e615df6fc5174e043e57924", + "version" : "1.4.1" } }, { diff --git a/Package.swift b/Package.swift index 536b0ec1..3fc2d574 100644 --- a/Package.swift +++ b/Package.swift @@ -80,8 +80,10 @@ let package = Package( .product(name: "ContainerizationArchive", package: "containerization"), .product(name: "ContainerizationExtras", package: "containerization"), .product(name: "ContainerizationOS", package: "containerization"), + .product(name: "TOML", package: "swift-toml"), "ContainerBuild", "ContainerLog", + "ContainerPersistence", "ContainerResource", "Yams", ], @@ -93,6 +95,7 @@ let package = Package( .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "Logging", package: "swift-log"), .product(name: "SwiftProtobuf", package: "swift-protobuf"), + .product(name: "TOML", package: "swift-toml"), .product(name: "Containerization", package: "containerization"), .product(name: "ContainerizationOCI", package: "containerization"), .product(name: "ContainerizationOS", package: "containerization"), @@ -237,6 +240,7 @@ let package = Package( .product(name: "SystemPackage", package: "swift-system"), "ContainerImagesService", "ContainerLog", + "ContainerPersistence", "ContainerPlugin", "ContainerVersion", "ContainerXPC", @@ -285,6 +289,7 @@ let package = Package( "ContainerLog", "ContainerNetworkService", "ContainerNetworkServiceClient", + "ContainerPersistence", "ContainerPlugin", "ContainerResource", "ContainerVersion", @@ -401,6 +406,7 @@ let package = Package( .product(name: "Logging", package: "swift-log"), .product(name: "Containerization", package: "containerization"), .product(name: "SystemPackage", package: "swift-system"), + .product(name: "TOML", package: "swift-toml"), "CVersion", "ContainerVersion", ] diff --git a/Sources/APIServer/APIServer+Start.swift b/Sources/APIServer/APIServer+Start.swift index c7105ca4..fd4380aa 100644 --- a/Sources/APIServer/APIServer+Start.swift +++ b/Sources/APIServer/APIServer+Start.swift @@ -50,6 +50,9 @@ extension APIServer { var logRoot = LogRoot.path func run() async throws { + let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig( + configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url) + ) let commandName = APIServer._commandName let logPath = logRoot.map { $0.appending("\(commandName).log") } let log = ServiceLogger.bootstrap(category: "APIServer", debug: debug, logPath: logPath) @@ -62,15 +65,18 @@ extension APIServer { log.info("configuring XPC server") var routes = [XPCRoute: XPCServer.RouteHandler]() let pluginLoader = try initializePluginLoader(log: log) + try await initializePlugins(pluginLoader: pluginLoader, log: log, routes: &routes) let containersService = try initializeContainersService( pluginLoader: pluginLoader, + containerSystemConfig: containerSystemConfig, log: log, routes: &routes ) let networkService = try await initializeNetworksService( pluginLoader: pluginLoader, containersService: containersService, + containerSystemConfig: containerSystemConfig, log: log, routes: &routes ) @@ -259,12 +265,18 @@ extension APIServer { routes[XPCRoute.getDefaultKernel] = harness.getDefaultKernel } - private func initializeContainersService(pluginLoader: PluginLoader, log: Logger, routes: inout [XPCRoute: XPCServer.RouteHandler]) throws -> ContainersService { + private func initializeContainersService( + pluginLoader: PluginLoader, + containerSystemConfig: ContainerSystemConfig, + log: Logger, + routes: inout [XPCRoute: XPCServer.RouteHandler] + ) throws -> ContainersService { log.info("initializing containers service") let service = try ContainersService( appRoot: appRoot, pluginLoader: pluginLoader, + containerSystemConfig: containerSystemConfig, log: log, debugHelpers: debug ) @@ -292,6 +304,7 @@ extension APIServer { private func initializeNetworksService( pluginLoader: PluginLoader, containersService: ContainersService, + containerSystemConfig: ContainerSystemConfig, log: Logger, routes: inout [XPCRoute: XPCServer.RouteHandler] ) async throws -> NetworksService { @@ -316,8 +329,8 @@ extension APIServer { let config = try NetworkConfiguration( id: NetworkClient.defaultNetworkName, mode: .nat, - ipv4Subnet: try? DefaultsStore.getOptional(key: .defaultSubnet).map { try CIDRv4($0) }, - ipv6Subnet: try? DefaultsStore.getOptional(key: .defaultIPv6Subnet).map { try CIDRv6($0) }, + ipv4Subnet: containerSystemConfig.network.subnet, + ipv6Subnet: containerSystemConfig.network.subnetv6, labels: try .init([ResourceLabelKeys.role: ResourceRoleValues.builtin]), pluginInfo: NetworkPluginInfo(plugin: "container-network-vmnet") ) diff --git a/Sources/ContainerBuild/BuildImageResolver.swift b/Sources/ContainerBuild/BuildImageResolver.swift index c2b8c50d..77f5b49e 100644 --- a/Sources/ContainerBuild/BuildImageResolver.swift +++ b/Sources/ContainerBuild/BuildImageResolver.swift @@ -15,6 +15,7 @@ //===----------------------------------------------------------------------===// import ContainerAPIClient +import ContainerPersistence import Containerization import ContainerizationOCI import Foundation @@ -27,12 +28,16 @@ struct BuildImageResolver: BuildPipelineHandler { let quiet: Bool let output: FileHandle let pull: Bool + let containerSystemConfig: ContainerSystemConfig - public init(_ contentStore: ContentStore, quiet: Bool = false, output: FileHandle = FileHandle.standardError, pull: Bool = false) throws { + public init(_ contentStore: ContentStore, quiet: Bool = false, output: FileHandle = FileHandle.standardError, pull: Bool = false, containerSystemConfig: ContainerSystemConfig) + throws + { self.contentStore = contentStore self.quiet = quiet self.output = output self.pull = pull + self.containerSystemConfig = containerSystemConfig } func accept(_ packet: ServerStream) throws -> Bool { @@ -75,10 +80,10 @@ struct BuildImageResolver: BuildPipelineHandler { progress.start() if self.pull { - return try await ClientImage.pull(reference: ref, platform: platform, progressUpdate: progress.handler) + return try await ClientImage.pull(reference: ref, platform: platform, containerSystemConfig: containerSystemConfig, progressUpdate: progress.handler) } // Use fetch() which checks cache first, then pulls if needed - return try await ClientImage.fetch(reference: ref, platform: platform, progressUpdate: progress.handler) + return try await ClientImage.fetch(reference: ref, platform: platform, containerSystemConfig: containerSystemConfig, progressUpdate: progress.handler) }() let index: Index = try await img.index() diff --git a/Sources/ContainerBuild/BuildPipelineHandler.swift b/Sources/ContainerBuild/BuildPipelineHandler.swift index 143d42eb..6ee36e8f 100644 --- a/Sources/ContainerBuild/BuildPipelineHandler.swift +++ b/Sources/ContainerBuild/BuildPipelineHandler.swift @@ -30,7 +30,13 @@ public actor BuildPipeline { [ try BuildFSSync(URL(filePath: config.contextDir)), try BuildRemoteContentProxy(config.contentStore), - try BuildImageResolver(config.contentStore, quiet: config.quiet, output: config.terminal?.handle ?? FileHandle.standardError, pull: config.pull), + try BuildImageResolver( + config.contentStore, + quiet: config.quiet, + output: config.terminal?.handle ?? FileHandle.standardError, + pull: config.pull, + containerSystemConfig: config.containerSystemConfig + ), try BuildStdio(quiet: config.quiet, output: config.terminal?.handle ?? FileHandle.standardError), ] } diff --git a/Sources/ContainerBuild/Builder.swift b/Sources/ContainerBuild/Builder.swift index deb77041..7a0f4520 100644 --- a/Sources/ContainerBuild/Builder.swift +++ b/Sources/ContainerBuild/Builder.swift @@ -15,6 +15,7 @@ //===----------------------------------------------------------------------===// import ContainerAPIClient +import ContainerPersistence import Containerization import ContainerizationOCI import ContainerizationOS @@ -279,6 +280,7 @@ public struct Builder: Sendable { public let cacheIn: [String] public let cacheOut: [String] public let pull: Bool + public let containerSystemConfig: ContainerSystemConfig public init( buildID: String, @@ -298,7 +300,8 @@ public struct Builder: Sendable { exports: [BuildExport], cacheIn: [String], cacheOut: [String], - pull: Bool + pull: Bool, + containerSystemConfig: ContainerSystemConfig ) { self.buildID = buildID self.contentStore = contentStore @@ -318,6 +321,7 @@ public struct Builder: Sendable { self.cacheIn = cacheIn self.cacheOut = cacheOut self.pull = pull + self.containerSystemConfig = containerSystemConfig } } diff --git a/Sources/ContainerCommands/BuildCommand.swift b/Sources/ContainerCommands/BuildCommand.swift index 892fc185..a9c66519 100644 --- a/Sources/ContainerCommands/BuildCommand.swift +++ b/Sources/ContainerCommands/BuildCommand.swift @@ -18,6 +18,8 @@ import ArgumentParser import ContainerAPIClient import ContainerBuild import ContainerImagesServiceClient +import ContainerPersistence +import ContainerPlugin import Containerization import ContainerizationError import ContainerizationOCI @@ -147,6 +149,9 @@ extension Application { var pull: Bool = false public func run() async throws { + let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig( + configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url) + ) do { let timeout: Duration = .seconds(300) let progressConfig = try ProgressConfig( @@ -190,7 +195,8 @@ extension Application { memory: memory, log: log, dnsNameservers: dnsNameservers, - progressUpdate: progress.handler + progressUpdate: progress.handler, + containerSystemConfig: containerSystemConfig, ) // wait (seconds) for builder to start listening on vsock @@ -330,7 +336,9 @@ extension Application { return results }() group.addTask { - [terminal, buildArg, secretsData, contextDir, ignoreFileData, label, noCache, target, quiet, cacheIn, cacheOut, pull, exports, imageNames, tempURL, log] in + [ + terminal, buildArg, secretsData, contextDir, ignoreFileData, label, noCache, target, quiet, cacheIn, cacheOut, pull, exports, imageNames, tempURL, log, + ] in let config = Builder.BuildConfig( buildID: buildID, contentStore: RemoteContentStoreClient(), @@ -349,7 +357,8 @@ extension Application { exports: exports, cacheIn: cacheIn, cacheOut: cacheOut, - pull: pull + pull: pull, + containerSystemConfig: containerSystemConfig, ) progress.finish() diff --git a/Sources/ContainerCommands/Builder/BuilderStart.swift b/Sources/ContainerCommands/Builder/BuilderStart.swift index c0b3d576..42b8412e 100644 --- a/Sources/ContainerCommands/Builder/BuilderStart.swift +++ b/Sources/ContainerCommands/Builder/BuilderStart.swift @@ -18,6 +18,7 @@ import ArgumentParser import ContainerAPIClient import ContainerBuild import ContainerPersistence +import ContainerPlugin import ContainerResource import Containerization import ContainerizationError @@ -29,9 +30,6 @@ import TerminalProgress extension Application { public struct BuilderStart: AsyncLoggableCommand { - static let defaultCPUs = 2 - static let defaultMemoryInBytes: UInt64 = 2048.mib() - public static var configuration: CommandConfiguration { var config = CommandConfiguration() config.commandName = "start" @@ -57,6 +55,9 @@ extension Application { public init() {} public func run() async throws { + let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig( + configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url) + ) let progressConfig = try ProgressConfig( showTasks: true, showItems: true, @@ -67,7 +68,7 @@ extension Application { progress.finish() } progress.start() - try await Self.start( + try await BuilderStart.start( cpus: self.cpus, memory: self.memory, log: log, @@ -75,7 +76,8 @@ extension Application { dnsDomain: self.dns.domain, dnsSearchDomains: self.dns.searchDomains, dnsOptions: self.dns.options, - progressUpdate: progress.handler + progressUpdate: progress.handler, + containerSystemConfig: containerSystemConfig, ) progress.finish() } @@ -88,7 +90,8 @@ extension Application { dnsDomain: String? = nil, dnsSearchDomains: [String] = [], dnsOptions: [String] = [], - progressUpdate: @escaping ProgressUpdateHandler + progressUpdate: @escaping ProgressUpdateHandler, + containerSystemConfig: ContainerSystemConfig, ) async throws { await progressUpdate([ .setDescription("Fetching BuildKit image"), @@ -97,7 +100,7 @@ extension Application { let taskManager = ProgressTaskCoordinator() let fetchTask = await taskManager.startTask() - let builderImage: String = DefaultsStore.get(key: .defaultBuilderImage) + let builderImage: String = containerSystemConfig.build.image let systemHealth = try await ClientHealthCheck.ping(timeout: .seconds(10)) let exportsMount: String = systemHealth.appRoot .appendingPathComponent(Application.BuilderCommand.builderResourceDir) @@ -122,6 +125,15 @@ extension Application { } targetEnvVars.sort() + let defaultBuildCPUs: Int = containerSystemConfig.build.cpus + let defaultBuildMemory = containerSystemConfig.build.memory + let resources = try Parser.resources( + cpus: cpus, + memory: memory, + defaultCPUs: defaultBuildCPUs, + defaultMemory: defaultBuildMemory, + ) + let client = ContainerClient() let existingContainer = try? await client.get(id: "buildkit") if let existingContainer { @@ -138,16 +150,8 @@ extension Application { // Check if we need to recreate the builder due to different image let imageChanged = existingImage != builderImage - let resolvedResources = try Parser.resources( - cpus: cpus, - memory: memory, - cpuPropertyKey: .defaultBuildCPUs, - memoryPropertyKey: .defaultBuildMemory, - defaultCPUs: Self.defaultCPUs, - defaultMemoryInBytes: Self.defaultMemoryInBytes - ) - let cpuChanged = existingResources.cpus != resolvedResources.cpus - let memChanged = existingResources.memoryInBytes != resolvedResources.memoryInBytes + let cpuChanged = existingResources.cpus != resources.cpus + let memChanged = existingResources.memoryInBytes != resources.memoryInBytes let dnsChanged = { if !dnsNameservers.isEmpty { return existingDNS?.nameservers != dnsNameservers @@ -191,7 +195,7 @@ extension Application { } } - let useRosetta = DefaultsStore.getBool(key: .buildRosetta) ?? true + let useRosetta = containerSystemConfig.build.rosetta let shimArguments = [ "--debug", "--vsock", @@ -203,6 +207,7 @@ extension Application { let image = try await ClientImage.fetch( reference: builderImage, platform: builderPlatform, + containerSystemConfig: containerSystemConfig, progressUpdate: ProgressTaskCoordinator.handler(for: fetchTask, from: progressUpdate) ) // Unpack fetched image before use @@ -235,15 +240,6 @@ extension Application { user: .id(uid: 0, gid: 0) ) - let resources = try Parser.resources( - cpus: cpus, - memory: memory, - cpuPropertyKey: .defaultBuildCPUs, - memoryPropertyKey: .defaultBuildMemory, - defaultCPUs: Self.defaultCPUs, - defaultMemoryInBytes: Self.defaultMemoryInBytes - ) - var config = ContainerConfiguration(id: Builder.builderContainerId, image: imageDesc, process: processConfig) config.resources = resources config.labels = [ diff --git a/Sources/ContainerCommands/Container/ContainerCreate.swift b/Sources/ContainerCommands/Container/ContainerCreate.swift index ac26d205..c3ff6a25 100644 --- a/Sources/ContainerCommands/Container/ContainerCreate.swift +++ b/Sources/ContainerCommands/Container/ContainerCreate.swift @@ -16,6 +16,8 @@ import ArgumentParser import ContainerAPIClient +import ContainerPersistence +import ContainerPlugin import ContainerResource import ContainerizationError import Foundation @@ -54,6 +56,9 @@ extension Application { var arguments: [String] = [] public func run() async throws { + let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig( + configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url) + ) let progressConfig = try ProgressConfig( showTasks: true, showItems: true, @@ -78,6 +83,7 @@ extension Application { resource: resourceFlags, registry: registryFlags, imageFetch: imageFetchFlags, + containerSystemConfig: containerSystemConfig, progressUpdate: progress.handler, log: log ) diff --git a/Sources/ContainerCommands/Container/ContainerRun.swift b/Sources/ContainerCommands/Container/ContainerRun.swift index 0bb4d20d..ff6729b0 100644 --- a/Sources/ContainerCommands/Container/ContainerRun.swift +++ b/Sources/ContainerCommands/Container/ContainerRun.swift @@ -16,6 +16,8 @@ import ArgumentParser import ContainerAPIClient +import ContainerPersistence +import ContainerPlugin import ContainerResource import Containerization import ContainerizationError @@ -61,6 +63,9 @@ extension Application { var arguments: [String] = [] public func run() async throws { + let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig( + configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url) + ) var exitCode: Int32 = 127 let id = Utility.createContainerID(name: self.managementFlags.name) @@ -98,6 +103,7 @@ extension Application { resource: resourceFlags, registry: registryFlags, imageFetch: imageFetchFlags, + containerSystemConfig: containerSystemConfig, progressUpdate: progress.handler, log: log ) diff --git a/Sources/ContainerCommands/Image/ImageDelete.swift b/Sources/ContainerCommands/Image/ImageDelete.swift index c4e3f41f..0353723b 100644 --- a/Sources/ContainerCommands/Image/ImageDelete.swift +++ b/Sources/ContainerCommands/Image/ImageDelete.swift @@ -16,6 +16,8 @@ import ArgumentParser import ContainerAPIClient +import ContainerPersistence +import ContainerPlugin import Containerization import ContainerizationError import Foundation @@ -45,19 +47,25 @@ extension Application { } } - static func removeImage(options: RemoveImageOptions, log: Logger) async throws { + static func removeImage(options: RemoveImageOptions, containerSystemConfig: ContainerSystemConfig, log: Logger) async throws { let (found, notFound) = try await { if options.all { let found = try await ClientImage.list() let notFound: [String] = [] return (found, notFound) } - return try await ClientImage.get(names: options.images) + return try await ClientImage.get(names: options.images, containerSystemConfig: containerSystemConfig) }() var failures: [String] = options.force ? [] : notFound var didDeleteAnyImage = false for image in found { - guard !Utility.isInfraImage(name: image.reference) else { + guard + !Utility.isInfraImage( + name: image.reference, + builderImage: containerSystemConfig.build.image, + initImage: containerSystemConfig.vminit.image + ) + else { continue } do { @@ -101,7 +109,10 @@ extension Application { } public mutating func run() async throws { - try await DeleteImageImplementation.removeImage(options: options, log: log) + let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig( + configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url) + ) + 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 2250e740..010fbc8a 100644 --- a/Sources/ContainerCommands/Image/ImageInspect.swift +++ b/Sources/ContainerCommands/Image/ImageInspect.swift @@ -17,6 +17,8 @@ import ArgumentParser import ContainerAPIClient import ContainerLog +import ContainerPersistence +import ContainerPlugin import ContainerResource import ContainerizationError import Foundation @@ -43,14 +45,23 @@ extension Application { } public func run() async throws { + let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig( + configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url) + ) var printable: [ImageDetail] = [] var succeededImages: [String] = [] var allErrors: [(String, Error)] = [] - let result = try await ClientImage.get(names: images) + let result = try await ClientImage.get(names: images, containerSystemConfig: containerSystemConfig) for image in result.images { - guard !Utility.isInfraImage(name: image.reference) else { continue } + guard + !Utility.isInfraImage( + name: image.reference, + builderImage: containerSystemConfig.build.image, + initImage: containerSystemConfig.vminit.image + ) + else { continue } printable.append(try await image.details()) succeededImages.append(image.reference) } diff --git a/Sources/ContainerCommands/Image/ImageList.swift b/Sources/ContainerCommands/Image/ImageList.swift index 9816677f..f3335b5f 100644 --- a/Sources/ContainerCommands/Image/ImageList.swift +++ b/Sources/ContainerCommands/Image/ImageList.swift @@ -16,6 +16,8 @@ import ArgumentParser import ContainerAPIClient +import ContainerPersistence +import ContainerPlugin import Containerization import ContainerizationError import ContainerizationOCI @@ -43,10 +45,13 @@ extension Application { public var logOptions: Flags.Logging public mutating func run() async throws { + let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig( + configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url) + ) try Self.validate(format: format, quiet: quiet, verbose: verbose) var images = try await ClientImage.list().filter { img in - !Utility.isInfraImage(name: img.reference) + !Utility.isInfraImage(name: img.reference, builderImage: containerSystemConfig.build.image, initImage: containerSystemConfig.vminit.image) } images.sort { $0.reference < $1.reference } @@ -57,19 +62,19 @@ extension Application { if quiet { for image in images { - let processedReferenceString = try ClientImage.denormalizeReference(image.reference) + let processedReferenceString = try ClientImage.denormalizeReference(image.reference, containerSystemConfig: containerSystemConfig) print(processedReferenceString) } return } if verbose { - let items = try await Self.buildVerboseItems(images: images) + let items = try await Self.buildVerboseItems(images: images, containerSystemConfig: containerSystemConfig) Output.emit(Output.renderTable(items)) return } - let items = try await Self.buildTableItems(images: images) + let items = try await Self.buildTableItems(images: images, containerSystemConfig: containerSystemConfig) Output.emit(Output.renderTable(items)) } @@ -96,10 +101,10 @@ extension Application { try Output.emit(Output.renderJSON(printableImages)) } - private static func buildTableItems(images: [ClientImage]) async throws -> [ImageRow] { + private static func buildTableItems(images: [ClientImage], containerSystemConfig: ContainerSystemConfig) async throws -> [ImageRow] { var items: [ImageRow] = [] for image in images { - let processedReferenceString = try ClientImage.denormalizeReference(image.reference) + let processedReferenceString = try ClientImage.denormalizeReference(image.reference, containerSystemConfig: containerSystemConfig) let reference = try ContainerizationOCI.Reference.parse(processedReferenceString) let digest = try await image.resolved().digest items.append( @@ -112,12 +117,12 @@ extension Application { return items } - private static func buildVerboseItems(images: [ClientImage]) async throws -> [VerboseImageRow] { + private static func buildVerboseItems(images: [ClientImage], containerSystemConfig: ContainerSystemConfig) async throws -> [VerboseImageRow] { let formatter = ByteCountFormatter() var items: [VerboseImageRow] = [] for image in images { let imageDigest = try await image.resolved().digest - let processedReferenceString = try ClientImage.denormalizeReference(image.reference) + let processedReferenceString = try ClientImage.denormalizeReference(image.reference, containerSystemConfig: containerSystemConfig) let reference = try ContainerizationOCI.Reference.parse(processedReferenceString) for descriptor in try await image.index().manifests { if let referenceType = descriptor.annotations?["vnd.docker.reference.type"], diff --git a/Sources/ContainerCommands/Image/ImagePull.swift b/Sources/ContainerCommands/Image/ImagePull.swift index 6365de15..1ff8c4ab 100644 --- a/Sources/ContainerCommands/Image/ImagePull.swift +++ b/Sources/ContainerCommands/Image/ImagePull.swift @@ -16,6 +16,8 @@ import ArgumentParser import ContainerAPIClient +import ContainerPersistence +import ContainerPlugin import Containerization import ContainerizationOCI import TerminalProgress @@ -67,11 +69,14 @@ extension Application { } public func run() async throws { + let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig( + configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url) + ) let p = try DefaultPlatform.resolve(platform: platform, os: os, arch: arch, log: log) let scheme = try RequestScheme(registry.scheme) - let processedReference = try ClientImage.normalizeReference(reference) + let processedReference = try ClientImage.normalizeReference(reference, containerSystemConfig: containerSystemConfig) let progressConfig = try self.progressFlags.makeConfig( showTasks: true, @@ -91,7 +96,8 @@ extension Application { let taskManager = ProgressTaskCoordinator() let fetchTask = await taskManager.startTask() let image = try await ClientImage.pull( - reference: processedReference, platform: p, scheme: scheme, progressUpdate: ProgressTaskCoordinator.handler(for: fetchTask, from: progress.handler), + reference: processedReference, platform: p, scheme: scheme, containerSystemConfig: containerSystemConfig, + progressUpdate: ProgressTaskCoordinator.handler(for: fetchTask, from: progress.handler), maxConcurrentDownloads: self.imageFetchFlags.maxConcurrentDownloads ) diff --git a/Sources/ContainerCommands/Image/ImagePush.swift b/Sources/ContainerCommands/Image/ImagePush.swift index f0781435..96c1118c 100644 --- a/Sources/ContainerCommands/Image/ImagePush.swift +++ b/Sources/ContainerCommands/Image/ImagePush.swift @@ -16,6 +16,8 @@ import ArgumentParser import ContainerAPIClient +import ContainerPersistence +import ContainerPlugin import Containerization import ContainerizationOCI import TerminalProgress @@ -55,10 +57,13 @@ extension Application { public init() {} public func run() async throws { + let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig( + configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url) + ) let p = try DefaultPlatform.resolve(platform: platform, os: os, arch: arch, log: log) let scheme = try RequestScheme(registry.scheme) - let image = try await ClientImage.get(reference: reference) + let image = try await ClientImage.get(reference: reference, containerSystemConfig: containerSystemConfig) let progressConfig = try self.progressFlags.makeConfig( description: "Pushing image \(image.reference)", @@ -73,7 +78,7 @@ extension Application { progress.finish() } progress.start() - _ = try await image.push(platform: p, scheme: scheme, progressUpdate: progress.handler) + _ = try await image.push(platform: p, scheme: scheme, containerSystemConfig: containerSystemConfig, progressUpdate: progress.handler) progress.finish() print(image.reference) } diff --git a/Sources/ContainerCommands/Image/ImageSave.swift b/Sources/ContainerCommands/Image/ImageSave.swift index af301782..c2e330ad 100644 --- a/Sources/ContainerCommands/Image/ImageSave.swift +++ b/Sources/ContainerCommands/Image/ImageSave.swift @@ -16,6 +16,8 @@ import ArgumentParser import ContainerAPIClient +import ContainerPersistence +import ContainerPlugin import ContainerResource import Containerization import ContainerizationError @@ -60,6 +62,9 @@ extension Application { @Argument var references: [String] public func run() async throws { + let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig( + configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url) + ) let p = try DefaultPlatform.resolve(platform: platform, os: os, arch: arch, log: log) let progressConfig = try ProgressConfig( @@ -74,7 +79,7 @@ extension Application { var images: [ImageDescription] = [] for reference in references { do { - images.append(try await ClientImage.get(reference: reference).description) + images.append(try await ClientImage.get(reference: reference, containerSystemConfig: containerSystemConfig).description) } catch { print("failed to get image for reference \(reference): \(error)") } @@ -116,7 +121,7 @@ extension Application { throw ContainerizationError(.internalError, message: "unable to create temporary file") } - try await ClientImage.save(references: references, out: tempFile.path(), platform: p) + try await ClientImage.save(references: references, out: tempFile.path(), platform: p, containerSystemConfig: containerSystemConfig) guard let fileHandle = try? FileHandle(forReadingFrom: tempFile) else { throw ContainerizationError(.internalError, message: "unable to open temporary file for reading") @@ -130,7 +135,7 @@ extension Application { } try fileHandle.close() } else { - try await ClientImage.save(references: references, out: output!, platform: p) + try await ClientImage.save(references: references, out: output!, platform: p, containerSystemConfig: containerSystemConfig) } progress.finish() diff --git a/Sources/ContainerCommands/Image/ImageTag.swift b/Sources/ContainerCommands/Image/ImageTag.swift index 42c3df71..a0f83945 100644 --- a/Sources/ContainerCommands/Image/ImageTag.swift +++ b/Sources/ContainerCommands/Image/ImageTag.swift @@ -16,6 +16,8 @@ import ArgumentParser import ContainerAPIClient +import ContainerPersistence +import ContainerPlugin extension Application { public struct ImageTag: AsyncLoggableCommand { @@ -34,8 +36,11 @@ extension Application { public var logOptions: Flags.Logging public func run() async throws { - let existing = try await ClientImage.get(reference: source) - let targetReference = try ClientImage.normalizeReference(target) + let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig( + configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url) + ) + let existing = try await ClientImage.get(reference: source, containerSystemConfig: containerSystemConfig) + let targetReference = try ClientImage.normalizeReference(target, containerSystemConfig: containerSystemConfig) try await existing.tag(new: targetReference) print(target) } diff --git a/Sources/ContainerCommands/ListFormat.swift b/Sources/ContainerCommands/ListFormat.swift index 6b7bd866..fa67ccf0 100644 --- a/Sources/ContainerCommands/ListFormat.swift +++ b/Sources/ContainerCommands/ListFormat.swift @@ -20,4 +20,5 @@ public enum ListFormat: String, CaseIterable, ExpressibleByArgument, Sendable { case json case table case yaml + case toml } diff --git a/Sources/ContainerCommands/OutputRendering.swift b/Sources/ContainerCommands/OutputRendering.swift index fb84bac5..20560f6e 100644 --- a/Sources/ContainerCommands/OutputRendering.swift +++ b/Sources/ContainerCommands/OutputRendering.swift @@ -15,6 +15,7 @@ //===----------------------------------------------------------------------===// import Foundation +import TOML import Yams /// Options for JSON rendering, wrapping the knobs on `JSONEncoder`. @@ -55,6 +56,13 @@ public enum Output { return data } + /// Renders an `Encodable` value as a TOML string. + public static func renderTOML(_ value: T) throws -> String { + let encoder = TOMLEncoder() + encoder.outputFormatting = .sortedKeys + return try encoder.encodeToString(value) + } + /// Renders a list of displayable items as a table (with header) or quiet-mode identifiers. public static func renderList(_ items: [T], quiet: Bool) -> String { if quiet { @@ -83,6 +91,7 @@ public enum Output { case .json: try emit(renderJSON(json)) case .yaml: try emit(renderYAML(json)) case .table: emit(renderList(display, quiet: quiet)) + case .toml: try emit(renderTOML(json)) } } diff --git a/Sources/ContainerCommands/Registry/RegistryLogin.swift b/Sources/ContainerCommands/Registry/RegistryLogin.swift index 96d5919f..225ea2ee 100644 --- a/Sources/ContainerCommands/Registry/RegistryLogin.swift +++ b/Sources/ContainerCommands/Registry/RegistryLogin.swift @@ -16,6 +16,8 @@ import ArgumentParser import ContainerAPIClient +import ContainerPersistence +import ContainerPlugin import Containerization import ContainerizationError import ContainerizationOCI @@ -45,6 +47,9 @@ extension Application { var server: String public func run() async throws { + let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig( + configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url) + ) var username = self.username var password = "" if passwordStdin { @@ -67,7 +72,7 @@ extension Application { } let server = Reference.resolveDomain(domain: server) - let scheme = try RequestScheme(registry.scheme).schemeFor(host: server) + let scheme = try RequestScheme(registry.scheme).schemeFor(host: server, internalDnsDomain: containerSystemConfig.dns.domain) let _url = "\(scheme)://\(server)" guard let url = URL(string: _url) else { throw ContainerizationError(.invalidArgument, message: "cannot convert \(_url) to URL") diff --git a/Sources/ContainerCommands/System/Kernel/KernelSet.swift b/Sources/ContainerCommands/System/Kernel/KernelSet.swift index cd86e569..3744d5a3 100644 --- a/Sources/ContainerCommands/System/Kernel/KernelSet.swift +++ b/Sources/ContainerCommands/System/Kernel/KernelSet.swift @@ -17,6 +17,7 @@ import ArgumentParser import ContainerAPIClient import ContainerPersistence +import ContainerPlugin import Containerization import ContainerizationError import ContainerizationExtras @@ -52,9 +53,12 @@ extension Application { public init() {} public func run() async throws { + let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig( + configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url) + ) if recommended { - let url = DefaultsStore.get(key: .defaultKernelURL) - let path = DefaultsStore.get(key: .defaultKernelBinaryPath) + let url = containerSystemConfig.kernel.url + let path: String = containerSystemConfig.kernel.binaryPath print("Installing the recommended kernel from \(url)...") try await Self.downloadAndInstallWithProgressBar(tarRemoteURL: url, kernelFilePath: path, force: force) return @@ -91,7 +95,7 @@ extension Application { guard let remoteURL = URL(string: tarPath) else { throw ContainerizationError(.invalidArgument, message: "invalid remote URL '\(tarPath)' for argument '--tar'. Missing protocol?") } - try await Self.downloadAndInstallWithProgressBar(tarRemoteURL: remoteURL.absoluteString, kernelFilePath: binaryPath, platform: platform, force: force) + try await Self.downloadAndInstallWithProgressBar(tarRemoteURL: remoteURL, kernelFilePath: binaryPath, platform: platform, force: force) } private func getSystemPlatform() throws -> SystemPlatform { @@ -105,7 +109,7 @@ extension Application { } } - static func downloadAndInstallWithProgressBar(tarRemoteURL: String, kernelFilePath: String, platform: SystemPlatform = .current, force: Bool) async throws { + static func downloadAndInstallWithProgressBar(tarRemoteURL: URL, kernelFilePath: String, platform: SystemPlatform = .current, force: Bool) async throws { let progressConfig = try ProgressConfig( showTasks: true, totalTasks: 2 @@ -115,7 +119,8 @@ extension Application { progress.finish() } progress.start() - try await ClientKernel.installKernelFromTar(tarFile: tarRemoteURL, kernelFilePath: kernelFilePath, platform: platform, progressUpdate: progress.handler, force: force) + try await ClientKernel.installKernelFromTar( + tarFile: tarRemoteURL.absoluteString, kernelFilePath: kernelFilePath, platform: platform, progressUpdate: progress.handler, force: force) progress.finish() } diff --git a/Sources/ContainerCommands/System/Property/PropertyClear.swift b/Sources/ContainerCommands/System/Property/PropertyClear.swift deleted file mode 100644 index c3648575..00000000 --- a/Sources/ContainerCommands/System/Property/PropertyClear.swift +++ /dev/null @@ -1,46 +0,0 @@ -//===----------------------------------------------------------------------===// -// 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 ArgumentParser -import ContainerAPIClient -import ContainerPersistence -import ContainerizationError -import Foundation - -extension Application { - public struct PropertyClear: AsyncLoggableCommand { - public static let configuration = CommandConfiguration( - commandName: "clear", - abstract: "Clear a property value" - ) - - @OptionGroup - public var logOptions: Flags.Logging - - @Argument(help: "The property ID") - var id: String - - public init() {} - - public func run() async throws { - guard let key = DefaultsStore.Keys(rawValue: id) else { - throw ContainerizationError(.invalidArgument, message: "invalid property ID: \(id)") - } - - DefaultsStore.unset(key: key) - } - } -} diff --git a/Sources/ContainerCommands/System/Property/PropertyGet.swift b/Sources/ContainerCommands/System/Property/PropertyGet.swift deleted file mode 100644 index cbb3146e..00000000 --- a/Sources/ContainerCommands/System/Property/PropertyGet.swift +++ /dev/null @@ -1,53 +0,0 @@ -//===----------------------------------------------------------------------===// -// 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 ArgumentParser -import ContainerAPIClient -import ContainerPersistence -import ContainerizationError -import Foundation - -extension Application { - public struct PropertyGet: AsyncLoggableCommand { - public static let configuration = CommandConfiguration( - commandName: "get", - abstract: "Retrieve a property value" - ) - - @OptionGroup - public var logOptions: Flags.Logging - - @Argument(help: "The property ID") - var id: String - - public init() {} - - public func run() async throws { - let value = DefaultsStore.allValues() - .filter { id == $0.id } - .first - guard let value else { - throw ContainerizationError(.invalidArgument, message: "property ID \(id) not found") - } - - guard let val = value.value?.description else { - return - } - - print(val) - } - } -} diff --git a/Sources/ContainerCommands/System/Property/PropertyList.swift b/Sources/ContainerCommands/System/Property/PropertyList.swift index 8de24b9a..8cd50afd 100644 --- a/Sources/ContainerCommands/System/Property/PropertyList.swift +++ b/Sources/ContainerCommands/System/Property/PropertyList.swift @@ -17,8 +17,14 @@ import ArgumentParser import ContainerAPIClient import ContainerPersistence +import ContainerPlugin import Foundation +enum ListOutputFormat: String, Decodable, ExpressibleByArgument { + case json + case toml +} + extension Application { public struct PropertyList: AsyncLoggableCommand { public static let configuration = CommandConfiguration( @@ -28,10 +34,7 @@ extension Application { ) @Option(name: .long, help: "Format of the output") - var format: ListFormat = .table - - @Flag(name: .shortAndLong, help: "Only output the property ID") - var quiet = false + var format: ListOutputFormat = .toml @OptionGroup public var logOptions: Flags.Logging @@ -39,55 +42,15 @@ extension Application { public init() {} public func run() async throws { - let vals = DefaultsStore.allValues() - - try Output.render( - json: vals, - display: vals.map { PrintableProperty($0) }, - format: format, quiet: quiet + let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig( + configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url) ) + let output = + switch format { + case .json: try Output.renderJSON(containerSystemConfig) + case .toml: try Output.renderTOML(containerSystemConfig) + } + Output.emit(output) } } } - -private struct PrintableProperty: ListDisplayable { - let id: String - let typeName: String - let valueDescription: String - let description: String - - init(_ value: DefaultsStoreValue) { - self.id = value.id - self.typeName = String(describing: value.type) - self.valueDescription = value.value?.description.elided(to: 40) ?? "*undefined*" - self.description = value.description - } - - static var tableHeader: [String] { - ["ID", "TYPE", "VALUE", "DESCRIPTION"] - } - - var tableRow: [String] { - [id, typeName, valueDescription, description] - } - - var quietValue: String { - id - } -} - -extension String { - func elided(to maxCount: Int) -> String { - let ellipsis = "..." - guard self.count > maxCount else { - return self - } - - if maxCount < ellipsis.count { - return String(ellipsis.prefix(maxCount)) - } - - let prefixCount = maxCount - ellipsis.count - return self.prefix(prefixCount) + ellipsis - } -} diff --git a/Sources/ContainerCommands/System/Property/PropertySet.swift b/Sources/ContainerCommands/System/Property/PropertySet.swift deleted file mode 100644 index de79b296..00000000 --- a/Sources/ContainerCommands/System/Property/PropertySet.swift +++ /dev/null @@ -1,95 +0,0 @@ -//===----------------------------------------------------------------------===// -// 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 ArgumentParser -import ContainerAPIClient -import ContainerPersistence -import ContainerizationError -import ContainerizationExtras -import ContainerizationOCI -import Foundation - -extension Application { - public struct PropertySet: AsyncLoggableCommand { - public static let configuration = CommandConfiguration( - commandName: "set", - abstract: "Set a property value" - ) - - @OptionGroup - public var logOptions: Flags.Logging - - @Argument(help: "The property ID") - var id: String - - @Argument(help: "The property value") - var value: String - - public init() {} - - public func run() async throws { - guard let key = DefaultsStore.Keys(rawValue: id) else { - throw ContainerizationError(.invalidArgument, message: "invalid property ID: \(id)") - } - - switch key { - case .buildRosetta: - guard let boolValue = Parser.parseBool(string: value) else { - throw ContainerizationError(.invalidArgument, message: "invalid boolean value: \(value)") - } - DefaultsStore.setBool(value: boolValue, key: key) - case .defaultBuildCPUs, .defaultContainerCPUs: - guard let cpuCount = Int(value), cpuCount > 0 else { - throw ContainerizationError(.invalidArgument, message: "invalid CPU count: \(value)") - } - DefaultsStore.set(value: value, key: key) - case .defaultBuildMemory, .defaultContainerMemory: - guard let memoryMiB = try? Parser.memoryStringAsMiB(value), memoryMiB > 0 else { - throw ContainerizationError(.invalidArgument, message: "invalid memory value: \(value)") - } - DefaultsStore.set(value: value, key: key) - case .defaultDNSDomain, .defaultRegistryDomain: - guard Parser.isValidDomainName(value) else { - throw ContainerizationError(.invalidArgument, message: "invalid domain name: \(value)") - } - DefaultsStore.set(value: value, key: key) - case .defaultBuilderImage, .defaultInitImage: - guard (try? Reference.parse(value)) != nil else { - throw ContainerizationError(.invalidArgument, message: "invalid image reference: \(value)") - } - DefaultsStore.set(value: value, key: key) - case .defaultKernelBinaryPath: - DefaultsStore.set(value: value, key: key) - case .defaultKernelURL: - guard URL(string: value) != nil else { - throw ContainerizationError(.invalidArgument, message: "invalid URL: \(value)") - } - DefaultsStore.set(value: value, key: key) - return - case .defaultSubnet: - guard (try? CIDRv4(value)) != nil else { - throw ContainerizationError(.invalidArgument, message: "invalid CIDRv4 address: \(value)") - } - DefaultsStore.set(value: value, key: key) - case .defaultIPv6Subnet: - guard (try? CIDRv6(value)) != nil else { - throw ContainerizationError(.invalidArgument, message: "invalid CIDRv6 address: \(value)") - } - DefaultsStore.set(value: value, key: key) - } - } - } -} diff --git a/Sources/ContainerCommands/System/SystemProperty.swift b/Sources/ContainerCommands/System/SystemProperty.swift index 4737cb67..d3e346f0 100644 --- a/Sources/ContainerCommands/System/SystemProperty.swift +++ b/Sources/ContainerCommands/System/SystemProperty.swift @@ -28,10 +28,7 @@ extension Application { commandName: "property", abstract: "Manage system property values", subcommands: [ - PropertyClear.self, - PropertyGet.self, - PropertyList.self, - PropertySet.self, + PropertyList.self ] ) diff --git a/Sources/ContainerCommands/System/SystemStart.swift b/Sources/ContainerCommands/System/SystemStart.swift index f4dd37a7..abd0c237 100644 --- a/Sources/ContainerCommands/System/SystemStart.swift +++ b/Sources/ContainerCommands/System/SystemStart.swift @@ -72,6 +72,14 @@ 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) + + // 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. // Gatekeeper / amfid validates code signatures relative to the enclosing .app bundle // hierarchy; launching via a symlink outside the bundle fails that check. @@ -127,20 +135,19 @@ extension Application { ) } - if await !initImageExists() { - try? await installInitialFilesystem() + if await !initImageExists(containerSystemConfig: containerSystemConfig) { + try? await installInitialFilesystem(initImage: containerSystemConfig.vminit.image) } guard await !kernelExists() else { return } - try await installDefaultKernel() + try await installDefaultKernel(kernelURL: containerSystemConfig.kernel.url, kernelBinaryPath: containerSystemConfig.kernel.binaryPath) } - private func installInitialFilesystem() async throws { - let dep = Dependencies.initFs + private func installInitialFilesystem(initImage: String) async throws { var pullCommand = try ImagePull.parse() - pullCommand.reference = dep.source + pullCommand.reference = initImage print("Installing base container filesystem...") do { try await pullCommand.run() @@ -149,15 +156,11 @@ extension Application { } } - private func installDefaultKernel() async throws { - let kernelDependency = Dependencies.kernel - let defaultKernelURL = kernelDependency.source - let defaultKernelBinaryPath = DefaultsStore.get(key: .defaultKernelBinaryPath) - + private func installDefaultKernel(kernelURL: URL, kernelBinaryPath: String) async throws { var shouldInstallKernel = false if kernelInstall == nil { print("No default kernel configured.") - print("Install the recommended default kernel from [\(kernelDependency.source)]? [Y/n]: ", terminator: "") + print("Install the recommended default kernel from [\(kernelURL)]? [Y/n]: ", terminator: "") guard let read = readLine(strippingNewline: true) else { throw ContainerizationError(.internalError, message: "failed to read user input") } @@ -173,12 +176,15 @@ extension Application { return } print("Installing kernel...") - try await KernelSet.downloadAndInstallWithProgressBar(tarRemoteURL: defaultKernelURL, kernelFilePath: defaultKernelBinaryPath, force: true) + try await KernelSet.downloadAndInstallWithProgressBar(tarRemoteURL: kernelURL, kernelFilePath: kernelBinaryPath, force: true) } - private func initImageExists() async -> Bool { + private func initImageExists(containerSystemConfig: ContainerSystemConfig) async -> Bool { do { - let img = try await ClientImage.get(reference: Dependencies.initFs.source) + let img = try await ClientImage.get( + reference: containerSystemConfig.vminit.image, + containerSystemConfig: containerSystemConfig + ) let _ = try await img.getSnapshot(platform: .current) return true } catch { @@ -195,18 +201,4 @@ extension Application { } } } - - private enum Dependencies: String { - case kernel - case initFs - - var source: String { - switch self { - case .initFs: - return DefaultsStore.get(key: .defaultInitImage) - case .kernel: - return DefaultsStore.get(key: .defaultKernelURL) - } - } - } } diff --git a/Sources/ContainerCommands/System/SystemVersion.swift b/Sources/ContainerCommands/System/SystemVersion.swift index 8cc87704..7a13a702 100644 --- a/Sources/ContainerCommands/System/SystemVersion.swift +++ b/Sources/ContainerCommands/System/SystemVersion.swift @@ -65,6 +65,8 @@ extension Application { try Output.emit(Output.renderJSON(versions)) case .yaml: try Output.emit(Output.renderYAML(versions)) + case .toml: + try Output.emit(Output.renderTOML(versions)) } } diff --git a/Sources/ContainerPersistence/ContainerSystemConfig.swift b/Sources/ContainerPersistence/ContainerSystemConfig.swift new file mode 100644 index 00000000..ead3a324 --- /dev/null +++ b/Sources/ContainerPersistence/ContainerSystemConfig.swift @@ -0,0 +1,223 @@ +//===----------------------------------------------------------------------===// +// 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 runtime-config.toml. +/// +/// 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 let build: BuildConfig + public let container: ContainerConfig + public let dns: DNSConfig + public let kernel: KernelConfig + 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(), + network: NetworkConfig = .init(), + registry: RegistryConfig = .init(), + vminit: VminitConfig = .init() + ) { + self.build = build + self.container = container + self.dns = dns + self.kernel = kernel + self.network = network + self.registry = registry + self.vminit = vminit + } + + 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.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 + } +} diff --git a/Sources/ContainerPersistence/DefaultsStore.swift b/Sources/ContainerPersistence/DefaultsStore.swift deleted file mode 100644 index 5e8e7bfc..00000000 --- a/Sources/ContainerPersistence/DefaultsStore.swift +++ /dev/null @@ -1,231 +0,0 @@ -//===----------------------------------------------------------------------===// -// 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 CVersion -import ContainerVersion -import ContainerizationError -import Foundation - -public enum DefaultsStore { - public static let userDefaultDomain = "com.apple.container.defaults" - - public enum Keys: String { - case buildRosetta = "build.rosetta" - case defaultBuildCPUs = "build.cpus" - case defaultBuildMemory = "build.memory" - case defaultContainerCPUs = "container.cpus" - case defaultContainerMemory = "container.memory" - case defaultDNSDomain = "dns.domain" - case defaultBuilderImage = "image.builder" - case defaultInitImage = "image.init" - case defaultKernelBinaryPath = "kernel.binaryPath" - case defaultKernelURL = "kernel.url" - case defaultSubnet = "network.subnet" - case defaultIPv6Subnet = "network.subnetv6" - case defaultRegistryDomain = "registry.domain" - } - - public static func set(value: String, key: DefaultsStore.Keys) { - udSuite.set(value, forKey: key.rawValue) - } - - public static func unset(key: DefaultsStore.Keys) { - udSuite.removeObject(forKey: key.rawValue) - } - - public static func get(key: DefaultsStore.Keys) -> String { - let appBundle = Bundle.appBundle(executableURL: CommandLine.executablePathUrl) - return udSuite.string(forKey: key.rawValue) - ?? appBundle?.infoDictionary?["\(Self.userDefaultDomain).\(key.rawValue)"] as? String - ?? key.defaultValue - } - - public static func getOptional(key: DefaultsStore.Keys) -> String? { - udSuite.string(forKey: key.rawValue) - } - - public static func setBool(value: Bool, key: DefaultsStore.Keys) { - udSuite.set(value, forKey: key.rawValue) - } - - public static func getBool(key: DefaultsStore.Keys) -> Bool? { - if udSuite.object(forKey: key.rawValue) != nil { - return udSuite.bool(forKey: key.rawValue) - } - let appBundle = Bundle.appBundle(executableURL: CommandLine.executablePathUrl) - return appBundle?.infoDictionary?["\(Self.userDefaultDomain).\(key.rawValue)"] as? Bool - ?? Bool(key.defaultValue) - } - - public static func allValues() -> [DefaultsStoreValue] { - let allKeys: [(Self.Keys, (Self.Keys) -> Any?)] = [ - (.buildRosetta, { Self.getBool(key: $0) }), - (.defaultBuildCPUs, { Self.getOptional(key: $0) }), - (.defaultBuildMemory, { Self.getOptional(key: $0) }), - (.defaultContainerCPUs, { Self.getOptional(key: $0) }), - (.defaultContainerMemory, { Self.getOptional(key: $0) }), - (.defaultBuilderImage, { Self.get(key: $0) }), - (.defaultInitImage, { Self.get(key: $0) }), - (.defaultKernelBinaryPath, { Self.get(key: $0) }), - (.defaultKernelURL, { Self.get(key: $0) }), - (.defaultSubnet, { Self.getOptional(key: $0) }), - (.defaultIPv6Subnet, { Self.getOptional(key: $0) }), - (.defaultDNSDomain, { Self.getOptional(key: $0) }), - (.defaultRegistryDomain, { Self.get(key: $0) }), - ] - return - allKeys - .map { DefaultsStoreValue(id: $0.rawValue, description: $0.summary, value: $1($0) as? (Encodable & CustomStringConvertible), type: $0.type) } - .sorted(by: { $0.id < $1.id }) - } - - private static var udSuite: UserDefaults { - guard let ud = UserDefaults.init(suiteName: self.userDefaultDomain) else { - fatalError("failed to initialize UserDefaults for domain \(self.userDefaultDomain)") - } - return ud - } -} - -public struct DefaultsStoreValue: Identifiable, CustomStringConvertible, Encodable { - public let id: String - public let description: String - public let value: (Encodable & CustomStringConvertible)? - public let type: Any.Type - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(id, forKey: .id) - try container.encode(description, forKey: .description) - - if let value = value { - try container.encode(value, forKey: .value) - } else { - try container.encodeNil(forKey: .value) - } - - try container.encode(String(describing: type), forKey: .type) - } - - enum CodingKeys: String, CodingKey { - case id, description, value, type - } -} - -extension DefaultsStore.Keys { - public var summary: String { - switch self { - case .buildRosetta: - return "Build amd64 images on arm64 using Rosetta, instead of QEMU." - case .defaultBuildCPUs: - return "If defined, the default number of CPUs to allocate to the builder container." - case .defaultBuildMemory: - return "If defined, the default amount of memory to allocate to the builder container." - case .defaultContainerCPUs: - return "If defined, the default number of CPUs to allocate to a container." - case .defaultContainerMemory: - return "If defined, the default amount of memory to allocate to a container." - case .defaultDNSDomain: - return "If defined, the local DNS domain to use for containers with unqualified names." - case .defaultBuilderImage: - return "The image reference for the utility container that `container build` uses." - case .defaultInitImage: - return "The image reference for the default initial filesystem image." - case .defaultKernelBinaryPath: - return "If the kernel URL is for an archive, the archive member pathname for the kernel file." - case .defaultKernelURL: - return "The URL for the kernel file to install, or the URL for an archive containing the kernel file." - case .defaultSubnet: - return "Default subnet for IPv4 allocation." - case .defaultIPv6Subnet: - return "Default IPv6 network prefix." - case .defaultRegistryDomain: - return "The default registry to use for image references that do not specify a registry." - } - } - - public var type: Any.Type { - switch self { - case .buildRosetta: - return Bool.self - case .defaultBuildCPUs: - return String.self - case .defaultBuildMemory: - return String.self - case .defaultContainerCPUs: - return String.self - case .defaultContainerMemory: - return String.self - case .defaultDNSDomain: - return String.self - case .defaultBuilderImage: - return String.self - case .defaultInitImage: - return String.self - case .defaultKernelBinaryPath: - return String.self - case .defaultKernelURL: - return String.self - case .defaultSubnet: - return String.self - case .defaultIPv6Subnet: - return String.self - case .defaultRegistryDomain: - return String.self - } - } - - fileprivate var defaultValue: String { - switch self { - case .buildRosetta: - // This is a boolean key, not used with the string get() method - return "true" - case .defaultBuildCPUs: - // This key is read with getOptional(), not get(); this value is never used - return "2" - case .defaultBuildMemory: - // This key is read with getOptional(), not get(); this value is never used - return "2048MB" - case .defaultContainerCPUs: - // This key is read with getOptional(), not get(); this value is never used - return "4" - case .defaultContainerMemory: - // This key is read with getOptional(), not get(); this value is never used - return "1g" - case .defaultDNSDomain: - return "test" - case .defaultBuilderImage: - let tag = String(cString: get_container_builder_shim_version()) - return "ghcr.io/apple/container-builder-shim/builder:\(tag)" - case .defaultInitImage: - let tag = String(cString: get_swift_containerization_version()) - guard tag != "latest" else { - return "vminit:latest" - } - return "ghcr.io/apple/containerization/vminit:\(tag)" - case .defaultKernelBinaryPath: - return "opt/kata/share/kata-containers/vmlinux-6.18.15-186" - case .defaultKernelURL: - return "https://github.com/kata-containers/kata-containers/releases/download/3.28.0/kata-static-3.28.0-arm64.tar.zst" - case .defaultSubnet: - return "192.168.64.1/24" - case .defaultIPv6Subnet: - return "fd00::/64" - case .defaultRegistryDomain: - return "docker.io" - } - } -} diff --git a/Sources/Services/ContainerAPIService/Client/Measurement+Parse.swift b/Sources/ContainerPersistence/Measurement+Parse.swift similarity index 97% rename from Sources/Services/ContainerAPIService/Client/Measurement+Parse.swift rename to Sources/ContainerPersistence/Measurement+Parse.swift index ae94c3df..8661ad8e 100644 --- a/Sources/Services/ContainerAPIService/Client/Measurement+Parse.swift +++ b/Sources/ContainerPersistence/Measurement+Parse.swift @@ -16,7 +16,7 @@ import Foundation -private let binaryUnits: [Character: UnitInformationStorage] = [ +let binaryUnits: [Character: UnitInformationStorage] = [ "b": .bytes, "k": .kibibytes, "m": .mebibytes, diff --git a/Sources/ContainerPersistence/MemorySize.swift b/Sources/ContainerPersistence/MemorySize.swift new file mode 100644 index 00000000..176b76d3 --- /dev/null +++ b/Sources/ContainerPersistence/MemorySize.swift @@ -0,0 +1,56 @@ +//===----------------------------------------------------------------------===// +// 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 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 + + 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)" + } +} diff --git a/Sources/ContainerPersistence/SystemRuntimeOptions.swift b/Sources/ContainerPersistence/SystemRuntimeOptions.swift new file mode 100644 index 00000000..098a9127 --- /dev/null +++ b/Sources/ContainerPersistence/SystemRuntimeOptions.swift @@ -0,0 +1,109 @@ +//===----------------------------------------------------------------------===// +// 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 e65a78ad..0a636cbb 100644 --- a/Sources/Plugins/CoreImages/ImagesHelper.swift +++ b/Sources/Plugins/CoreImages/ImagesHelper.swift @@ -18,6 +18,7 @@ import ArgumentParser import ContainerImagesService import ContainerImagesServiceClient import ContainerLog +import ContainerPersistence import ContainerPlugin import ContainerVersion import ContainerXPC @@ -56,9 +57,10 @@ extension ImagesHelper { var logRoot = LogRoot.path - private static let unpackStrategy = SnapshotStore.defaultUnpackStrategy - func run() async throws { + let containerSystemConfig: ContainerSystemConfig = try SystemRuntimeOptions.loadConfig( + configFile: SystemRuntimeOptions.configFileFromAppRoot(ApplicationRoot.url) + ) let commandName = ImagesHelper._commandName let logPath = logRoot.map { $0.appending("\(commandName).log") } let log = ServiceLogger.bootstrap(category: "ImagesHelper", debug: debug, logPath: logPath) @@ -71,7 +73,7 @@ extension ImagesHelper { log.info("configuring XPC server") var routes = [String: XPCServer.RouteHandler]() try self.initializeContentService(root: appRoot, log: log, routes: &routes) - try self.initializeImagesService(root: appRoot, log: log, routes: &routes) + try self.initializeImagesService(root: appRoot, containerSystemConfig: containerSystemConfig, log: log, routes: &routes) let xpc = XPCServer( identifier: serviceIdentifier, routes: routes, @@ -90,10 +92,11 @@ extension ImagesHelper { } } - private func initializeImagesService(root: URL, log: Logger, routes: inout [String: XPCServer.RouteHandler]) throws { + private func initializeImagesService(root: URL, containerSystemConfig: ContainerSystemConfig, log: Logger, routes: inout [String: XPCServer.RouteHandler]) throws { let contentStore = RemoteContentStoreClient() let imageStore = try ImageStore(path: root, contentStore: contentStore) - let snapshotStore = try SnapshotStore(path: root, unpackStrategy: Self.unpackStrategy, log: log) + let unpackStrategy = SnapshotStore.defaultUnpackStrategy(initImage: containerSystemConfig.vminit.image) + let snapshotStore = try SnapshotStore(path: root, unpackStrategy: unpackStrategy, log: log) let service = try ImagesService(contentStore: contentStore, imageStore: imageStore, snapshotStore: snapshotStore, log: log) let harness = ImagesServiceHarness(service: service, log: log) diff --git a/Sources/Services/ContainerAPIService/Client/ClientImage.swift b/Sources/Services/ContainerAPIService/Client/ClientImage.swift index 42b242c8..49e7b54c 100644 --- a/Sources/Services/ContainerAPIService/Client/ClientImage.swift +++ b/Sources/Services/ContainerAPIService/Client/ClientImage.swift @@ -95,7 +95,6 @@ public struct ClientImage: Sendable { extension ClientImage { private static let serviceIdentifier = "com.apple.container.core.container-core-images" - public static let initImageRef = DefaultsStore.get(key: .defaultInitImage) private static func newXPCClient() -> XPCClient { XPCClient(service: Self.serviceIdentifier) @@ -105,9 +104,6 @@ extension ClientImage { XPCMessage(route: route) } - private static var defaultRegistryDomain: String { - DefaultsStore.get(key: .defaultRegistryDomain) - } } // MARK: Static methods @@ -117,8 +113,8 @@ extension ClientImage { private static let dockerRegistryHost = "registry-1.docker.io" private static let defaultDockerRegistryRepo = "library" - public static func normalizeReference(_ ref: String) throws -> String { - guard ref != Self.initImageRef else { + public static func normalizeReference(_ ref: String, containerSystemConfig: ContainerSystemConfig) throws -> String { + guard ref != containerSystemConfig.vminit.image else { // Don't modify the default init image reference. // This is to allow for easier local development against // an updated containerization. @@ -128,7 +124,7 @@ extension ClientImage { var updatedRawReference: String = ref let r = try Reference.parse(ref) if r.domain == nil { - updatedRawReference = "\(Self.defaultRegistryDomain)/\(ref)" + updatedRawReference = "\(containerSystemConfig.registry.domain)/\(ref)" } let updatedReference = try Reference.parse(updatedRawReference) @@ -139,10 +135,10 @@ extension ClientImage { return updatedReference.description } - public static func denormalizeReference(_ ref: String) throws -> String { + public static func denormalizeReference(_ ref: String, containerSystemConfig: ContainerSystemConfig) throws -> String { var updatedRawReference: String = ref let r = try Reference.parse(ref) - let defaultRegistry = Self.defaultRegistryDomain + let defaultRegistry = containerSystemConfig.registry.domain if r.domain == defaultRegistry { updatedRawReference = "\(r.path)" if let tag = r.tag { @@ -168,13 +164,13 @@ extension ClientImage { } } - public static func get(names: [String]) async throws -> (images: [ClientImage], error: [String]) { + public static func get(names: [String], containerSystemConfig: ContainerSystemConfig) async throws -> (images: [ClientImage], error: [String]) { let all = try await self.list() var errors: [String] = [] var found: [ClientImage] = [] for name in names { do { - guard let img = try Self._search(reference: name, in: all) else { + guard let img = try Self._search(reference: name, in: all, containerSystemConfig: containerSystemConfig) else { errors.append(name) continue } @@ -186,9 +182,9 @@ extension ClientImage { return (found, errors) } - public static func get(reference: String) async throws -> ClientImage { + public static func get(reference: String, containerSystemConfig: ContainerSystemConfig) async throws -> ClientImage { let all = try await self.list() - guard let found = try self._search(reference: reference, in: all) else { + guard let found = try self._search(reference: reference, in: all, containerSystemConfig: containerSystemConfig) else { throw ContainerizationError(.notFound, message: "image with reference \(reference)") } return found @@ -221,7 +217,7 @@ extension ClientImage { return 0 } - private static func _search(reference: String, in all: [ClientImage]) throws -> ClientImage? { + private static func _search(reference: String, in all: [ClientImage], containerSystemConfig: ContainerSystemConfig) throws -> ClientImage? { let locallyBuiltImage = try { // Check if we have an image whose index descriptor contains the image name // as an annotation. Prefer this in all cases, since these are locally built images. @@ -242,14 +238,19 @@ extension ClientImage { // If we don't find a match, try matching `ImageDescription.name` against the given // input string, while also checking against its normalized form. // Return the first match. - let normalizedReference = try Self.normalizeReference(reference) + let normalizedReference = try Self.normalizeReference(reference, containerSystemConfig: containerSystemConfig) return all.first(where: { image in image.reference == reference || image.reference == normalizedReference }) } public static func pull( - reference: String, platform: Platform? = nil, scheme: RequestScheme = .auto, progressUpdate: ProgressUpdateHandler? = nil, maxConcurrentDownloads: Int = 3 + reference: String, + platform: Platform? = nil, + scheme: RequestScheme = .auto, + containerSystemConfig: ContainerSystemConfig, + progressUpdate: ProgressUpdateHandler? = nil, + maxConcurrentDownloads: Int = 3 ) async throws -> ClientImage { guard maxConcurrentDownloads > 0 else { throw ContainerizationError(.invalidArgument, message: "maximum number of concurrent downloads must be greater than 0, got \(maxConcurrentDownloads)") @@ -258,7 +259,7 @@ extension ClientImage { let client = newXPCClient() let request = newRequest(.imagePull) - let reference = try self.normalizeReference(reference) + let reference = try self.normalizeReference(reference, containerSystemConfig: containerSystemConfig) guard let host = try Reference.parse(reference).domain else { throw ContainerizationError(.invalidArgument, message: "could not extract host from reference \(reference)") } @@ -266,7 +267,7 @@ extension ClientImage { request.set(key: .imageReference, value: reference) try request.set(platform: platform) - let insecure = try scheme.schemeFor(host: host) == .http + let insecure = try scheme.schemeFor(host: host, internalDnsDomain: containerSystemConfig.dns.domain) == .http request.set(key: .insecureFlag, value: insecure) request.set(key: .maxConcurrentDownloads, value: Int64(maxConcurrentDownloads)) @@ -291,8 +292,8 @@ extension ClientImage { let _ = try await client.send(request) } - public static func save(references: [String], out: String, platform: Platform? = nil) async throws { - let (clientImages, errors) = try await get(names: references) + public static func save(references: [String], out: String, platform: Platform? = nil, containerSystemConfig: ContainerSystemConfig) async throws { + let (clientImages, errors) = try await get(names: references, containerSystemConfig: containerSystemConfig) guard errors.isEmpty else { // TODO: Improve error handling here throw ContainerizationError(.invalidArgument, message: "one or more image references are invalid: \(errors.joined(separator: ", "))") @@ -351,10 +352,15 @@ extension ClientImage { } public static func fetch( - reference: String, platform: Platform? = nil, scheme: RequestScheme = .auto, progressUpdate: ProgressUpdateHandler? = nil, maxConcurrentDownloads: Int = 3 + reference: String, + platform: Platform? = nil, + scheme: RequestScheme = .auto, + containerSystemConfig: ContainerSystemConfig, + progressUpdate: ProgressUpdateHandler? = nil, + maxConcurrentDownloads: Int = 3 ) async throws -> ClientImage { do { - let match = try await self.get(reference: reference) + let match = try await self.get(reference: reference, containerSystemConfig: containerSystemConfig) if let platform { // The image exists, but we dont know if we have the right platform pulled // Check if we do, if not pull the requested platform @@ -365,7 +371,9 @@ extension ClientImage { guard err.isCode(.notFound) else { throw err } - return try await Self.pull(reference: reference, platform: platform, scheme: scheme, progressUpdate: progressUpdate, maxConcurrentDownloads: maxConcurrentDownloads) + return try await Self.pull( + reference: reference, platform: platform, scheme: scheme, containerSystemConfig: containerSystemConfig, progressUpdate: progressUpdate, + maxConcurrentDownloads: maxConcurrentDownloads) } } } @@ -373,7 +381,7 @@ extension ClientImage { // MARK: Instance methods extension ClientImage { - public func push(platform: Platform? = nil, scheme: RequestScheme, progressUpdate: ProgressUpdateHandler?) async throws { + public func push(platform: Platform? = nil, scheme: RequestScheme, containerSystemConfig: ContainerSystemConfig, progressUpdate: ProgressUpdateHandler?) async throws { let client = Self.newXPCClient() let request = Self.newRequest(.imagePush) @@ -382,7 +390,7 @@ extension ClientImage { } request.set(key: .imageReference, value: reference) - let insecure = try scheme.schemeFor(host: host) == .http + let insecure = try scheme.schemeFor(host: host, internalDnsDomain: containerSystemConfig.dns.domain) == .http request.set(key: .insecureFlag, value: insecure) try request.set(platform: platform) diff --git a/Sources/Services/ContainerAPIService/Client/Parser.swift b/Sources/Services/ContainerAPIService/Client/Parser.swift index 3ecd24a3..76aee086 100644 --- a/Sources/Services/ContainerAPIService/Client/Parser.swift +++ b/Sources/Services/ContainerAPIService/Client/Parser.swift @@ -90,27 +90,21 @@ public struct Parser { public static func resources( cpus: Int64?, memory: String?, - cpuPropertyKey: DefaultsStore.Keys = .defaultContainerCPUs, - memoryPropertyKey: DefaultsStore.Keys = .defaultContainerMemory, - defaultCPUs: Int = 4, - defaultMemoryInBytes: UInt64 = 1024.mib() + defaultCPUs: Int, + defaultMemory: MemorySize, ) throws -> ContainerConfiguration.Resources { var resource = ContainerConfiguration.Resources() resource.cpus = defaultCPUs - resource.memoryInBytes = defaultMemoryInBytes + resource.memoryInBytes = Int64(defaultMemory.measurement.converted(to: .mebibytes).value).mib() if let cpus { resource.cpus = Int(cpus) - } else if let cpuStr = DefaultsStore.getOptional(key: cpuPropertyKey), - let cpuVal = Int(cpuStr), cpuVal > 0 - { - resource.cpus = cpuVal } + if let memory { resource.memoryInBytes = try Parser.memoryStringAsMiB(memory).mib() - } else if let memStr = DefaultsStore.getOptional(key: memoryPropertyKey) { - resource.memoryInBytes = try Parser.memoryStringAsMiB(memStr).mib() } + return resource } diff --git a/Sources/Services/ContainerAPIService/Client/RequestScheme.swift b/Sources/Services/ContainerAPIService/Client/RequestScheme.swift index 54435ad7..b2ab8c8f 100644 --- a/Sources/Services/ContainerAPIService/Client/RequestScheme.swift +++ b/Sources/Services/ContainerAPIService/Client/RequestScheme.swift @@ -14,12 +14,13 @@ // limitations under the License. //===----------------------------------------------------------------------===// -import ContainerPersistence import ContainerizationError import ContainerizationExtras /// The URL scheme to be used for a HTTP request. public enum RequestScheme: String, Sendable { + private static let defaultDomain = "test" + case http = "http" case https = "https" @@ -40,8 +41,9 @@ public enum RequestScheme: String, Sendable { /// Returns the prescribed protocol to use while making a HTTP request to a webserver /// - Parameter host: The domain or IP address of the webserver + /// - Parameter internalDnsDomain: The DNS domain used for container name resolution /// - Returns: RequestScheme - public func schemeFor(host: String) throws -> Self { + public func schemeFor(host: String, internalDnsDomain: String?) throws -> Self { guard host.count > 0 else { throw ContainerizationError(.invalidArgument, message: "host cannot be empty") } @@ -49,21 +51,21 @@ public enum RequestScheme: String, Sendable { case .http, .https: return self case .auto: - return Self.isInternalHost(host: host, dnsDomain: DefaultsStore.getOptional(key: .defaultDNSDomain)) ? .http : .https + return Self.isInternalHost(host: host, internalDnsDomain: internalDnsDomain) ? .http : .https } } /// Checks if the given `host` string is a private IP address /// or a domain typically reachable only on the local system. - internal static func isInternalHost(host: String, dnsDomain: String? = nil) -> Bool { + public static func isInternalHost(host: String, internalDnsDomain: String?) -> Bool { // The localhost hostname is private. if host == "localhost" { return true } // If hostname uses the provided DNS domain, treat it as private. - if let dnsDomain { - if host.hasSuffix(".\(dnsDomain)") { + if let internalDnsDomain { + if host.hasSuffix(".\(internalDnsDomain)") { return true } } diff --git a/Sources/Services/ContainerAPIService/Client/Utility.swift b/Sources/Services/ContainerAPIService/Client/Utility.swift index 1c4a37d8..87c24082 100644 --- a/Sources/Services/ContainerAPIService/Client/Utility.swift +++ b/Sources/Services/ContainerAPIService/Client/Utility.swift @@ -27,11 +27,6 @@ import TerminalProgress public struct Utility { static let publishedPortCountLimit = 64 - private static let infraImages = [ - DefaultsStore.get(key: .defaultBuilderImage), - DefaultsStore.get(key: .defaultInitImage), - ] - public static func createContainerID(name: String?) -> String { guard let name else { return UUID().uuidString.lowercased() @@ -39,8 +34,8 @@ public struct Utility { return name } - public static func isInfraImage(name: String) -> Bool { - for infraImage in infraImages { + public static func isInfraImage(name: String, builderImage: String, initImage: String) -> Bool { + for infraImage in [builderImage, initImage] { if name == infraImage { return true } @@ -82,6 +77,7 @@ public struct Utility { resource: Flags.Resource, registry: Flags.Registry, imageFetch: Flags.ImageFetch, + containerSystemConfig: ContainerSystemConfig, progressUpdate: @escaping ProgressUpdateHandler, log: Logger ) async throws -> (ContainerConfiguration, Kernel, String?) { @@ -103,6 +99,7 @@ public struct Utility { reference: image, platform: requestedPlatform, scheme: scheme, + containerSystemConfig: containerSystemConfig, progressUpdate: ProgressTaskCoordinator.handler(for: fetchTask, from: progressUpdate), maxConcurrentDownloads: imageFetch.maxConcurrentDownloads ) @@ -130,9 +127,10 @@ public struct Utility { .setItemsName("blobs"), ]) let fetchInitTask = await taskManager.startTask() - let initImageRef = management.initImage ?? ClientImage.initImageRef + let initImageRef = management.initImage ?? containerSystemConfig.vminit.image let initImage = try await ClientImage.fetch( reference: initImageRef, platform: .current, scheme: scheme, + containerSystemConfig: containerSystemConfig, progressUpdate: ProgressTaskCoordinator.handler(for: fetchInitTask, from: progressUpdate), maxConcurrentDownloads: imageFetch.maxConcurrentDownloads) @@ -161,7 +159,9 @@ public struct Utility { config.resources = try Parser.resources( cpus: resource.cpus, - memory: resource.memory + memory: resource.memory, + defaultCPUs: containerSystemConfig.container.cpus, + defaultMemory: containerSystemConfig.container.memory ) let tmpfs = try Parser.tmpfsMounts(management.tmpFs) @@ -212,7 +212,8 @@ public struct Utility { config.networks = try getAttachmentConfigurations( containerId: config.id, builtinNetworkId: builtinNetworkId, - networks: parsedNetworks + networks: parsedNetworks, + dnsDomain: containerSystemConfig.dns.domain, ) for attachmentConfiguration in config.networks { let network = try await networkClient.get(id: attachmentConfiguration.network) @@ -225,7 +226,7 @@ public struct Utility { if management.dnsDisabled { config.dns = nil } else { - let domain = management.dns.domain ?? DefaultsStore.getOptional(key: .defaultDNSDomain) + let domain = management.dns.domain ?? containerSystemConfig.dns.domain config.dns = .init( nameservers: management.dns.nameservers, domain: domain, @@ -272,7 +273,8 @@ public struct Utility { static func getAttachmentConfigurations( containerId: String, builtinNetworkId: String?, - networks: [Parser.ParsedNetwork] + networks: [Parser.ParsedNetwork], + dnsDomain: String?, ) throws -> [AttachmentConfiguration] { // Validate MAC addresses if provided for network in networks { @@ -285,7 +287,7 @@ public struct Utility { let fqdn: String? if !containerId.contains(".") { // add default domain if it exists, and container ID is unqualified - if let dnsDomain = DefaultsStore.getOptional(key: .defaultDNSDomain) { + if let dnsDomain { fqdn = "\(containerId).\(dnsDomain)." } else { fqdn = nil diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift index 2c401f3b..512a4639 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift @@ -16,6 +16,7 @@ import CVersion import ContainerAPIClient +import ContainerPersistence import ContainerPlugin import ContainerResource import ContainerSandboxServiceClient @@ -57,6 +58,7 @@ public actor ContainersService { private let pluginLoader: PluginLoader private let runtimePlugins: [Plugin] private let exitMonitor: ExitMonitor + private let containerSystemConfig: ContainerSystemConfig private let lock: AsyncLock private var containers: [String: ContainerState] @@ -67,6 +69,7 @@ public actor ContainersService { public init( appRoot: URL, pluginLoader: PluginLoader, + containerSystemConfig: ContainerSystemConfig, log: Logger, debugHelpers: Bool = false ) throws { @@ -76,6 +79,7 @@ public actor ContainersService { self.lock = AsyncLock(log: log) self.containerRoot = containerRoot self.pluginLoader = pluginLoader + self.containerSystemConfig = containerSystemConfig self.log = log self.debugHelpers = debugHelpers self.runtimePlugins = pluginLoader.findPlugins().filter { $0.hasType(.runtime) } @@ -371,7 +375,7 @@ public actor ContainersService { metadata: [ "id": "\(configuration.id)", "kernel": "\(kernel.path)", - "initfs": "\(initImage ?? ClientImage.initImageRef)", + "initfs": "\(initImage ?? self.containerSystemConfig.vminit.image)", ]) let runtimeConfig = RuntimeConfiguration( path: path, @@ -1108,8 +1112,8 @@ public actor ContainersService { } private func getInitBlock(for platform: Platform, imageRef: String? = nil) async throws -> Filesystem { - let ref = imageRef ?? ClientImage.initImageRef - let initImage = try await ClientImage.fetch(reference: ref, platform: platform) + let ref = imageRef ?? containerSystemConfig.vminit.image + let initImage = try await ClientImage.fetch(reference: ref, platform: platform, containerSystemConfig: containerSystemConfig) var fs = try await initImage.getCreateSnapshot(platform: platform) fs.options = ["ro"] return fs diff --git a/Sources/Services/ContainerImagesService/Server/SnapshotStore.swift b/Sources/Services/ContainerImagesService/Server/SnapshotStore.swift index fd036945..282569a9 100644 --- a/Sources/Services/ContainerImagesService/Server/SnapshotStore.swift +++ b/Sources/Services/ContainerImagesService/Server/SnapshotStore.swift @@ -15,7 +15,6 @@ //===----------------------------------------------------------------------===// import ContainerAPIClient -import ContainerPersistence import ContainerResource import Containerization import ContainerizationError @@ -35,15 +34,17 @@ public actor SnapshotStore { /// If the given platform for the image cannot be unpacked return `nil`. public typealias UnpackStrategy = @Sendable (Containerization.Image, Platform) async throws -> Unpacker? - public static let defaultUnpackStrategy: UnpackStrategy = { image, platform in - guard platform.os == "linux" else { - return nil + public static func defaultUnpackStrategy(initImage: String) -> UnpackStrategy { + { image, platform in + guard platform.os == "linux" else { + return nil + } + var minBlockSize = 512.gib() + if image.reference == initImage { + minBlockSize = 512.mib() + } + return EXT4Unpacker(blockSizeInBytes: minBlockSize) } - var minBlockSize = 512.gib() - if image.reference == DefaultsStore.get(key: .defaultInitImage) { - minBlockSize = 512.mib() - } - return EXT4Unpacker(blockSizeInBytes: minBlockSize) } let path: URL diff --git a/Sources/Services/ContainerNetworkService/Server/ReservedVmnetNetwork.swift b/Sources/Services/ContainerNetworkService/Server/ReservedVmnetNetwork.swift index 39ef9c2a..6b7977cc 100644 --- a/Sources/Services/ContainerNetworkService/Server/ReservedVmnetNetwork.swift +++ b/Sources/Services/ContainerNetworkService/Server/ReservedVmnetNetwork.swift @@ -119,7 +119,7 @@ public final class ReservedVmnetNetwork: Network { let ipv4Subnet = configuration.ipv4Subnet let ipv6Subnet = configuration.ipv6Subnet - // set the IPv4 subnet if the caller provided one + // set the IPv4 subnet if let ipv4Subnet { let gateway = IPv4Address(ipv4Subnet.lower.value + 1) var gatewayAddr = in_addr() @@ -137,7 +137,7 @@ public final class ReservedVmnetNetwork: Network { } } - // set the IPv6 network prefix if the caller provided one + // set the IPv6 network prefix if let ipv6Subnet { let gateway = IPv6Address(ipv6Subnet.lower.value + 1) var gatewayAddr = in6_addr() diff --git a/Tests/CLITests/Subcommands/Images/TestCLIImagesCommand.swift b/Tests/CLITests/Subcommands/Images/TestCLIImagesCommand.swift index dadfe660..003d308f 100644 --- a/Tests/CLITests/Subcommands/Images/TestCLIImagesCommand.swift +++ b/Tests/CLITests/Subcommands/Images/TestCLIImagesCommand.swift @@ -195,37 +195,6 @@ class TestCLIImagesCommand: CLITest { } } - @Test func testImageDefaultRegistry() throws { - do { - let defaultDomain = "ghcr.io" - let imageName = "linuxcontainers/alpine:3.20" - defer { - try? doDefaultRegistrySet(domain: "docker.io") - } - try doDefaultRegistrySet(domain: defaultDomain) - try doPull(imageName: imageName, args: ["--platform", "linux/arm64"]) - guard let alpineImageDetails = try doInspectImages(image: imageName).first else { - Issue.record("alpine image not found") - return - } - #expect(alpineImageDetails.name == "\(defaultDomain)/\(imageName)") - - try doImageTag(image: imageName, newName: "username/image-name:mytag") - guard let taggedImage = try doInspectImages(image: "username/image-name:mytag").first else { - Issue.record("Tagged image not found") - return - } - #expect(taggedImage.name == "\(defaultDomain)/username/image-name:mytag") - - let listOutput = try doImageListQuite() - #expect(listOutput.contains("username/image-name:mytag")) - #expect(listOutput.contains(imageName)) - } catch { - Issue.record("failed default registry test") - return - } - } - @Test func testImageSaveAndLoad() throws { do { // 1. pull image diff --git a/Tests/CLITests/Subcommands/Run/TestCLIRunCommand.swift b/Tests/CLITests/Subcommands/Run/TestCLIRunCommand.swift index f7326215..4d7ec61b 100644 --- a/Tests/CLITests/Subcommands/Run/TestCLIRunCommand.swift +++ b/Tests/CLITests/Subcommands/Run/TestCLIRunCommand.swift @@ -913,14 +913,8 @@ class TestCLIRunCommand3: CLITest { } func getDefaultDomain() throws -> String? { - let (_, output, err, status) = try run(arguments: ["system", "property", "get", "dns.domain"]) - try #require(status == 0, "default DNS domain retrieval returned status \(status): \(err)") - let trimmedOutput = output.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmedOutput == "" { - return nil - } - - return trimmedOutput + let config = try getSystemConfig() + return config.dns.domain } @Test func testPrivilegedPortError() throws { diff --git a/Tests/CLITests/Subcommands/Run/TestCLIRunInitImage.swift b/Tests/CLITests/Subcommands/Run/TestCLIRunInitImage.swift index 56b73e40..623f0a6f 100644 --- a/Tests/CLITests/Subcommands/Run/TestCLIRunInitImage.swift +++ b/Tests/CLITests/Subcommands/Run/TestCLIRunInitImage.swift @@ -94,17 +94,8 @@ class TestCLIRunInitImage: CLITest { @Test func testRunWithExplicitDefaultInitImage() throws { let name = getTestName() - // Get the default init image reference - let (_, defaultInitImage, _, propStatus) = try run(arguments: [ - "system", "property", "get", "image.init", - ]) - - guard propStatus == 0 else { - print("Skipping testRunWithExplicitDefaultInitImage: could not get default init image") - return - } - - let initImage = defaultInitImage.trimmingCharacters(in: .whitespacesAndNewlines) + let config = try getSystemConfig() + let initImage = config.vminit.image // Run container with explicit default init image try doLongRun(name: name, args: ["--init-image", initImage]) diff --git a/Tests/CLITests/Subcommands/System/TestKernelSet.swift b/Tests/CLITests/Subcommands/System/TestKernelSet.swift index eb0b8d70..f88e19e1 100644 --- a/Tests/CLITests/Subcommands/System/TestKernelSet.swift +++ b/Tests/CLITests/Subcommands/System/TestKernelSet.swift @@ -23,11 +23,8 @@ import Testing // This suite is run serialized since each test modifies the global default kernel @Suite(.serialSuites, .serialized) class TestCLIKernelSet: CLITest { - let defaultKernelTar = DefaultsStore.get(key: .defaultKernelURL) - var remoteTar: URL! { - URL(string: defaultKernelTar) - } - let defaultBinaryPath = DefaultsStore.get(key: .defaultKernelBinaryPath) + let remoteTar = ContainerSystemConfig().kernel.url + let defaultBinaryPath = ContainerSystemConfig().kernel.binaryPath deinit { try? resetDefaultBinary() @@ -100,7 +97,7 @@ class TestCLIKernelSet: CLITest { let symlinkBinaryPath: String = URL(filePath: defaultBinaryPath).deletingLastPathComponent().appending(path: "vmlinux.container").relativePath let extraArgs: [String] = [ "--tar", - defaultKernelTar, + remoteTar.absoluteString, "--binary", symlinkBinaryPath, ] diff --git a/Tests/CLITests/TestCLINoParallelCases.swift b/Tests/CLITests/TestCLINoParallelCases.swift index be5c274a..cec512be 100644 --- a/Tests/CLITests/TestCLINoParallelCases.swift +++ b/Tests/CLITests/TestCLINoParallelCases.swift @@ -15,9 +15,7 @@ //===----------------------------------------------------------------------===// import ContainerAPIClient -import ContainerPersistence import ContainerizationExtras -import ContainerizationOCI import Foundation import Testing @@ -303,74 +301,4 @@ class TestCLINoParallelCases: CLITest { #expect(statusFinal == 0) #expect(!listFinal.contains(networkName), "network should be pruned after container is deleted") } - - // MARK: - Parser.resources (DefaultsStore-dependent) - - @Test func testResourcesCustomDefaults() throws { - let result = try Parser.resources( - cpus: nil, memory: nil, - cpuPropertyKey: .defaultBuildCPUs, memoryPropertyKey: .defaultBuildMemory, - defaultCPUs: 2, defaultMemoryInBytes: 2048.mib() - ) - #expect(result.cpus == 2) - #expect(result.memoryInBytes == 2048.mib()) - } - - @Test func testResourcesBuildPropertyLookup() throws { - DefaultsStore.set(value: "8", key: .defaultBuildCPUs) - DefaultsStore.set(value: "4g", key: .defaultBuildMemory) - defer { - DefaultsStore.unset(key: .defaultBuildCPUs) - DefaultsStore.unset(key: .defaultBuildMemory) - } - let result = try Parser.resources( - cpus: nil, memory: nil, - cpuPropertyKey: .defaultBuildCPUs, memoryPropertyKey: .defaultBuildMemory, - defaultCPUs: 2, defaultMemoryInBytes: 2048.mib() - ) - #expect(result.cpus == 8) - #expect(result.memoryInBytes == 4096.mib()) - } - - @Test func testResourcesCPUsFromProperty() throws { - DefaultsStore.set(value: "8", key: .defaultContainerCPUs) - defer { DefaultsStore.unset(key: .defaultContainerCPUs) } - let result = try Parser.resources(cpus: nil, memory: nil) - #expect(result.cpus == 8) - } - - @Test func testResourcesMemoryFromProperty() throws { - DefaultsStore.set(value: "2g", key: .defaultContainerMemory) - defer { DefaultsStore.unset(key: .defaultContainerMemory) } - let result = try Parser.resources(cpus: nil, memory: nil) - #expect(result.memoryInBytes == 2048.mib()) - } - - @Test func testResourcesFlagOverridesProperty() throws { - DefaultsStore.set(value: "8", key: .defaultContainerCPUs) - DefaultsStore.set(value: "2g", key: .defaultContainerMemory) - defer { - DefaultsStore.unset(key: .defaultContainerCPUs) - DefaultsStore.unset(key: .defaultContainerMemory) - } - let result = try Parser.resources(cpus: 1, memory: "256m") - #expect(result.cpus == 1) - #expect(result.memoryInBytes == 256.mib()) - } - - @Test func testResourcesPropertyKeysAreIsolated() throws { - DefaultsStore.set(value: "16", key: .defaultContainerCPUs) - DefaultsStore.set(value: "8g", key: .defaultContainerMemory) - defer { - DefaultsStore.unset(key: .defaultContainerCPUs) - DefaultsStore.unset(key: .defaultContainerMemory) - } - let result = try Parser.resources( - cpus: nil, memory: nil, - cpuPropertyKey: .defaultBuildCPUs, memoryPropertyKey: .defaultBuildMemory, - defaultCPUs: 2, defaultMemoryInBytes: 2048.mib() - ) - #expect(result.cpus == 2) - #expect(result.memoryInBytes == 2048.mib()) - } } diff --git a/Tests/CLITests/Utilities/CLITest.swift b/Tests/CLITests/Utilities/CLITest.swift index 28d0bef8..4fb43939 100644 --- a/Tests/CLITests/Utilities/CLITest.swift +++ b/Tests/CLITests/Utilities/CLITest.swift @@ -16,6 +16,7 @@ import AsyncHTTPClient import ContainerLog +import ContainerPersistence import ContainerResource import Containerization import ContainerizationOS @@ -23,6 +24,7 @@ import Foundation import Logging import Synchronization import SystemPackage +import TOML import Testing class CLITest { @@ -506,31 +508,12 @@ class CLITest { return try decoder.decode([ImageInspectOutput].self, from: jsonData) } - func doDefaultRegistrySet(domain: String) throws { - let args = [ - "system", - "property", - "set", - "registry.domain", - domain, - ] - let (_, _, error, status) = try run(arguments: args) - if status != 0 { - throw CLIError.executionFailed("command failed: \(error)") - } - } - - func doDefaultRegistryUnset() throws { - let args = [ - "system", - "property", - "clear", - "registry.domain", - ] - let (_, _, error, status) = try run(arguments: args) - if status != 0 { - throw CLIError.executionFailed("command failed: \(error)") + func getSystemConfig() throws -> ContainerSystemConfig { + let (_, output, err, status) = try run(arguments: ["system", "property", "list", "--format", "toml"]) + guard status == 0 else { + throw CLIError.executionFailed("system property list failed (\(status)): \(err)") } + return try TOMLDecoder().decode(ContainerSystemConfig.self, from: Data(output.utf8)) } func doRemove(name: String, force: Bool = false) throws { diff --git a/Tests/ContainerAPIClientTests/MemorySizeTests.swift b/Tests/ContainerAPIClientTests/MemorySizeTests.swift new file mode 100644 index 00000000..1ae4e13a --- /dev/null +++ b/Tests/ContainerAPIClientTests/MemorySizeTests.swift @@ -0,0 +1,75 @@ +//===----------------------------------------------------------------------===// +// 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 Testing + +struct MemorySizeTests { + @Test(arguments: [ + ("1gb", "1gb"), + ("2048MB", "2048mb"), + ("512kb", "512kb"), + ("1024b", "1024b"), + ("4tb", "4tb"), + ]) + func testFormattedOutput(input: String, expected: String) throws { + let size = try MemorySize(input) + #expect(size.formatted == expected) + } + + @Test func testMeasurementValue() throws { + let size = try MemorySize("2048mb") + #expect(size.measurement.value == 2048) + #expect(size.measurement.unit == .mebibytes) + + let sizeGB = try MemorySize("4gb") + #expect(sizeGB.measurement.value == 4) + #expect(sizeGB.measurement.unit == .gibibytes) + } + + @Test func testDescription() throws { + let size = try MemorySize("1gb") + #expect(size.description == "1gb") + } + + @Test func testEquality() throws { + let a = try MemorySize("1gb") + let b = try MemorySize("1gb") + #expect(a == b) + } + + @Test func testRoundTripEncoding() throws { + let original = try MemorySize("2048mb") + let encoder = JSONEncoder() + let data = try encoder.encode(original) + let decoder = JSONDecoder() + let decoded = try decoder.decode(MemorySize.self, from: data) + #expect(original == decoded) + } + + @Test func testDecodingFromString() throws { + let json = Data("\"512kb\"".utf8) + let decoded = try JSONDecoder().decode(MemorySize.self, from: json) + #expect(decoded.formatted == "512kb") + } + + @Test func testInvalidInputThrows() throws { + #expect(throws: (any Error).self) { + _ = try MemorySize("notasize") + } + } +} diff --git a/Tests/ContainerAPIClientTests/ParserTest.swift b/Tests/ContainerAPIClientTests/ParserTest.swift index 6445fc0f..bc5997f4 100644 --- a/Tests/ContainerAPIClientTests/ParserTest.swift +++ b/Tests/ContainerAPIClientTests/ParserTest.swift @@ -1183,4 +1183,116 @@ struct ParserTest { } } + // MARK: - Parser.resources + + @Test func testResourcesCustomDefaults() throws { + let result = try Parser.resources( + cpus: nil, memory: nil, + defaultCPUs: 2, defaultMemory: try MemorySize("2048MB") + ) + #expect(result.cpus == 2) + #expect(result.memoryInBytes == 2048.mib()) + } + + @Test func testResourcesFlagOverridesDefaults() throws { + let result = try Parser.resources(cpus: 1, memory: "256m", defaultCPUs: 8, defaultMemory: MemorySize("2g")) + #expect(result.cpus == 1) + #expect(result.memoryInBytes == 256.mib()) + } + + @Test func testResourcesBuildPropertyLookup() throws { + let content = """ + [build] + cpus = 8 + memory = "4g" + """ + let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("test-build-lookup.toml") + 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 result = try Parser.resources( + cpus: nil, memory: nil, + defaultCPUs: config.build.cpus, + defaultMemory: config.build.memory + ) + #expect(result.cpus == 8) + #expect(result.memoryInBytes == 4096.mib()) + } + + @Test func testResourcesCPUsFromProperty() throws { + let content = """ + [container] + cpus = 8 + """ + let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("test-cpus-property.toml") + 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 result = try Parser.resources( + cpus: nil, memory: nil, + defaultCPUs: config.container.cpus, + defaultMemory: config.container.memory + ) + #expect(result.cpus == 8) + } + + @Test func testResourcesMemoryFromProperty() throws { + let content = """ + [container] + memory = "2g" + """ + let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("test-memory-property.toml") + 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 result = try Parser.resources( + cpus: nil, memory: nil, + defaultCPUs: config.container.cpus, + defaultMemory: config.container.memory + ) + #expect(result.memoryInBytes == 2048.mib()) + } + + @Test func testResourcesFlagOverridesProperty() throws { + let content = """ + [container] + cpus = 8 + memory = "2g" + """ + let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("test-flag-overrides.toml") + 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 result = try Parser.resources( + cpus: 1, memory: "256m", + defaultCPUs: config.container.cpus, + defaultMemory: config.container.memory + ) + #expect(result.cpus == 1) + #expect(result.memoryInBytes == 256.mib()) + } + + @Test func testResourcesPropertyKeysAreIsolated() throws { + let content = """ + [container] + cpus = 16 + memory = "8g" + """ + let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("test-keys-isolated.toml") + 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 result = try Parser.resources( + cpus: nil, memory: nil, + defaultCPUs: config.build.cpus, + defaultMemory: config.build.memory + ) + #expect(result.cpus == 2) + #expect(result.memoryInBytes == 2048.mib()) + } } diff --git a/Tests/ContainerAPIClientTests/RequestSchemeTests.swift b/Tests/ContainerAPIClientTests/RequestSchemeTests.swift index 7ff0aa27..69ef1ce8 100644 --- a/Tests/ContainerAPIClientTests/RequestSchemeTests.swift +++ b/Tests/ContainerAPIClientTests/RequestSchemeTests.swift @@ -14,14 +14,14 @@ // limitations under the License. //===----------------------------------------------------------------------===// -import ContainerPersistence import ContainerizationError -import Foundation import Testing @testable import ContainerAPIClient struct RequestSchemeTests { + static let defaultDnsDomain = "test" + internal struct TestArg { let scheme: String let host: String @@ -58,19 +58,18 @@ struct RequestSchemeTests { func testIsConnectionSecure(arg: TestArg) throws { let requestScheme = RequestScheme(rawValue: arg.scheme)! - #expect(try requestScheme.schemeFor(host: arg.host) == arg.expected) + #expect(try requestScheme.schemeFor(host: arg.host, internalDnsDomain: Self.defaultDnsDomain) == arg.expected) } @Test func testEmptyHostThrowsError() throws { #expect(throws: (any Error).self) { let requestScheme = RequestScheme(rawValue: "https")! - _ = try requestScheme.schemeFor(host: "") + _ = try requestScheme.schemeFor(host: "", internalDnsDomain: Self.defaultDnsDomain) } } @Test func testIsInternalHostWithDefaultDNSDomain() throws { - let defaultDnsDomain = DefaultsStore.get(key: .defaultDNSDomain) - let hostName = "some-dns-name.io.\(defaultDnsDomain)" - #expect(RequestScheme.isInternalHost(host: hostName, dnsDomain: defaultDnsDomain)) + let hostName = "some-dns-name.io.\(Self.defaultDnsDomain)" + #expect(RequestScheme.isInternalHost(host: hostName, internalDnsDomain: Self.defaultDnsDomain)) } } diff --git a/Tests/ContainerAPIClientTests/SystemRuntimeOptionsTests.swift b/Tests/ContainerAPIClientTests/SystemRuntimeOptionsTests.swift new file mode 100644 index 00000000..6e7f2c4d --- /dev/null +++ b/Tests/ContainerAPIClientTests/SystemRuntimeOptionsTests.swift @@ -0,0 +1,197 @@ +//===----------------------------------------------------------------------===// +// 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") + } + +} diff --git a/Tests/ContainerCommandsTests/ListFormattingTests.swift b/Tests/ContainerCommandsTests/ListFormattingTests.swift index ccb9916a..501f5af2 100644 --- a/Tests/ContainerCommandsTests/ListFormattingTests.swift +++ b/Tests/ContainerCommandsTests/ListFormattingTests.swift @@ -251,31 +251,10 @@ struct NetworkResourceDisplayTests { struct ListFormatTests { @Test func hasAllOutputFormatCases() { - #expect(ListFormat.allCases.count == 3) + #expect(ListFormat.allCases.count == 4) #expect(ListFormat.json.rawValue == "json") #expect(ListFormat.table.rawValue == "table") #expect(ListFormat.yaml.rawValue == "yaml") - } -} - -// MARK: - String.elided tests - -struct StringElidedTests { - @Test - func shortStringUnchanged() { - #expect("hello".elided(to: 10) == "hello") - #expect("hello".elided(to: 5) == "hello") - } - - @Test - func longStringTruncatedWithEllipsis() { - #expect("hello world".elided(to: 8) == "hello...") - } - - @Test - func maxCountShorterThanEllipsis() { - #expect("hello".elided(to: 2) == "..") - #expect("hello".elided(to: 1) == ".") - #expect("hello".elided(to: 0) == "") + #expect(ListFormat.toml.rawValue == "toml") } } diff --git a/docs/command-reference.md b/docs/command-reference.md index 9254e35d..de231de9 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -85,7 +85,7 @@ container run [] [ ...] - `10.*.*.*` - `192.168.*.*` - `172.16.*.*` through `172.31.*.*` - - The host ends with the machine's default container DNS domain (as defined in `DefaultsStore.Keys.defaultDNSDomain`, located [here](../Sources/ContainerPersistence/DefaultsStore.swift)) + - The host ends with the machine's default container DNS domain (as defined in `DNSConfig.defaultDomain`, located [here](../Sources/ContainerPersistence/ContainerSystemConfig.swift)) For internal/local registries, the client uses **HTTP**. Otherwise, it uses **HTTPS**. @@ -1213,118 +1213,25 @@ container system kernel set [--arch ] [--binary ] [--force] [--rec ### `container system property list (ls)` -Lists all available system properties with their current values, types, and descriptions. Output can be formatted as a table or JSON. +Lists all system properties with their current values. Output can be formatted as JSON or TOML. **Usage** ```bash -container system property list [--format ] [--quiet] [--debug] +container system property list [--format ] [--debug] ``` **Options** -* `--format `: Format of the output (values: json, table; default: table) -* `-q, --quiet`: Only output the property ID +* `--format `: Format of the output (values: json, toml; default: toml) **Examples** ```bash -# list all properties in table format +# list all properties in TOML format (default) container system property list -# get only property IDs -container system property list --quiet - # output as JSON for scripting container system property list --format json ``` -### `container system property get` - -Retrieves the current value of a specific system property by its ID. - -**Usage** - -```bash -container system property get [--debug] -``` - -**Arguments** - -* ``: The property ID - -**Options** - -No options. - -**Examples** - -```bash -# get the default registry domain -container system property get registry.domain - -# get the current DNS domain setting -container system property get dns.domain -``` - -### `container system property set` - -Sets the value of a system property. The command validates the value based on the property type (boolean, domain name, image reference, URL, or CIDR address). - -**Usage** - -```bash -container system property set [--debug] -``` - -**Arguments** - -* ``: The property ID -* ``: The property value - -**Options** - -No options. - -**Examples** - -```bash -# enable Rosetta for AMD64 builds on ARM64 -container system property set build.rosetta true - -# set a custom DNS domain -container system property set dns.domain mycompany.local - -# configure a custom registry -container system property set registry.domain registry.example.com - -# set a custom builder image -container system property set image.builder myregistry.com/custom-builder:latest -``` - -### `container system property clear` - -Clears (unsets) a system property, reverting it to its default value. - -**Usage** - -```bash -container system property clear [--debug] -``` - -**Arguments** - -* ``: The property ID - -**Options** - -No options. - -**Examples** - -```bash -# clear custom DNS domain (revert to default) -container system property clear dns.domain - -# clear custom registry setting -container system property clear registry.domain diff --git a/docs/how-to.md b/docs/how-to.md index 0c2419de..75d816e9 100644 --- a/docs/how-to.md +++ b/docs/how-to.md @@ -360,18 +360,12 @@ Networks support both IPv4 and IPv6. When creating a network without explicit su ## Configure default network subnets -You can customize the default IPv4 and IPv6 subnets used for new networks using system properties. +You can customize the default IPv4 and IPv6 subnets used for new networks by editing your runtime configuration file at `~/.config/container/runtime-config.toml`: -### Set default IPv4 subnet - -```bash -container system property set network.subnet 192.168.100.1/24 -``` - -### Set default IPv6 prefix - -```bash -container system property set network.subnetv6 fd00:abcd::/64 +```toml +[network] +subnet = "192.168.100.1/24" +subnetv6 = "fd00:abcd::/64" ``` These settings apply to networks created without explicit `--subnet` or `--subnet-v6` options. @@ -642,27 +636,43 @@ Check the VM boot logs to confirm your custom init code executed: The `container system property` subcommand manages the configuration settings for the `container` CLI and services. You can customize various aspects of container behavior, including build settings, default images, and network configuration. -Use `container system property list` to show information for all available properties: +Use `container system property list` to show all properties that have set defaults: ```console % bin/container system property ls -ID TYPE VALUE DESCRIPTION -build.rosetta Bool true Build amd64 images on arm64 using Rosetta, instead of QEMU. -dns.domain String *undefined* If defined, the local DNS domain to use for containers with unqualified names. -image.builder String ghcr.io/apple/container-builder-shim/... The image reference for the utility container that `container build` uses. -image.init String ghcr.io/apple/containerization/vminit... The image reference for the default initial filesystem image. -kernel.binaryPath String opt/kata/share/kata-containers/vmlinu... If the kernel URL is for an archive, the archive member pathname for the kernel file. -kernel.url String https://github.com/kata-containers/ka... The URL for the kernel file to install, or the URL for an archive containing the kernel file. -network.subnet String *undefined* Default subnet for IPv4 allocation. -network.subnetv6 String *undefined* Default IPv6 network prefix. +[build] +cpus = 2 +memory = "2048mb" +rosetta = true +image = "ghcr.io/apple/container-builder-shim/builder:0.11.0" + +[container] +cpus = 4 +memory = "1gb" + +[dns] +domain = "test" + +[kernel] +binaryPath = "opt/kata/share/kata-containers/vmlinux-6.18.5-177" +url = "https://github.com/kata-containers/kata-containers/releases/download/3.26.0/kata-static-3.26.0-arm64.tar.zst" + +[network] + +[registry] +domain = "docker.io" + +[vminit] +image = "ghcr.io/apple/containerization/vminit:0.30.1" ``` ### Example: Disable Rosetta for builds -If you want to prevent the use of Rosetta translation during container builds on Apple Silicon Macs: +If you want to prevent the use of Rosetta translation during container builds on Apple Silicon Macs, set the following in `~/.config/container/runtime-config.toml`: -```bash -container system property set build.rosetta false +```toml +[build] +rosetta = false ``` This is useful when you want to ensure builds only produce native arm64 images and avoid any x86_64 emulation. diff --git a/docs/tutorial.md b/docs/tutorial.md index d6ccf153..45d76da0 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -109,12 +109,11 @@ Use the `--help` flag to see which abbreviations exist. ```bash sudo container system dns create test -container system property set dns.domain test ``` Enter your administrator password when prompted. The first command requires administrator privileges to create a file containing the domain configuration under the `/etc/resolver` directory, and to tell the macOS DNS resolver to reload its configuration files. -The second command makes `test` the default domain to use when running a container with an unqualified name. For example, if the default domain is `test` and you use `--name my-web-server` to start a container, queries to `my-web-server.test` will respond with that container's IP address. +With the domain set to `test`, if you use `--name my-web-server` to start a container, queries to `my-web-server.test` will respond with that container's IP address. You can customize the domain in `~/.config/container/runtime-config.toml`. ## Build an image @@ -306,7 +305,11 @@ container image push some-registry.example.com/fido/web-test:latest > [!NOTE] > By default `container` is configured to use Docker Hub. -> You can change the default registry to another value by running `container system property set registry.domain some-registry.example.com`. +> You can change the default registry by setting `domain` under `[registry]` in `~/.config/container/runtime-config.toml`: +> ```toml +> [registry] +> domain = "some-registry.example.com" +> ``` > See the other sub commands under `container registry` for more options. ### Pull and run your image