From e59c608cb21a09d8d3c31b5fe9dccbe432acdc2c Mon Sep 17 00:00:00 2001 From: ajemory Date: Fri, 29 May 2026 09:42:02 -0700 Subject: [PATCH 1/2] Runtime abstraction CLI -> runtime plumbing --- .../Builder/BuilderStart.swift | 23 +-- .../Container/ContainerCreate.swift | 18 +- .../Container/ContainerRun.swift | 23 ++- .../RuntimeLinux/RuntimeLinuxHelper.swift | 2 +- .../Client/ContainerClient.swift | 16 +- .../ContainerAPIService/Client/Utility.swift | 94 +++++----- .../ContainerAPIService/Client/XPC+.swift | 3 - .../Server/Containers/ContainersHarness.swift | 18 +- .../Server/Containers/ContainersService.swift | 74 ++------ .../RuntimeClient/RuntimeConfiguration.swift | 25 +-- .../Client/LinuxRuntimeData.swift | 39 +++- .../RuntimeLinux/Server/RuntimeService.swift | 44 ++++- .../RuntimeConfigurationTests.swift | 167 ++++++++++++++---- 13 files changed, 341 insertions(+), 205 deletions(-) diff --git a/Sources/ContainerCommands/Builder/BuilderStart.swift b/Sources/ContainerCommands/Builder/BuilderStart.swift index cf1d4e1c0..4989e810e 100644 --- a/Sources/ContainerCommands/Builder/BuilderStart.swift +++ b/Sources/ContainerCommands/Builder/BuilderStart.swift @@ -20,6 +20,7 @@ import ContainerBuild import ContainerPersistence import ContainerPlugin import ContainerResource +import ContainerRuntimeLinuxClient import Containerization import ContainerizationError import ContainerizationExtras @@ -276,24 +277,26 @@ extension Application { options: dnsOptions ) - let kernel = try await { - await progressUpdate([ - .setDescription("Fetching kernel"), - .setItemsName("binary"), - ]) - - let kernel = try await ClientKernel.getDefaultKernel(for: .current) - return kernel - }() + await progressUpdate([ + .setDescription("Fetching kernel"), + .setItemsName("binary"), + ]) + let kernel = try await ClientKernel.getDefaultKernel(for: .current) await progressUpdate([ .setDescription("Starting BuildKit container") ]) + let runtimeData = try LinuxRuntimeData.encodeData( + kernelPath: kernel.path.path, + initImageRef: containerSystemConfig.vminit.image, + imageRef: builderImage + ) + try await client.create( configuration: config, options: .default, - kernel: kernel + runtimeData: runtimeData ) try await startBuildKit(client: client, id: Builder.builderContainerId, progressUpdate, taskManager) diff --git a/Sources/ContainerCommands/Container/ContainerCreate.swift b/Sources/ContainerCommands/Container/ContainerCreate.swift index 92f0c02c1..7990c415b 100644 --- a/Sources/ContainerCommands/Container/ContainerCreate.swift +++ b/Sources/ContainerCommands/Container/ContainerCreate.swift @@ -19,6 +19,7 @@ import ContainerAPIClient import ContainerPersistence import ContainerPlugin import ContainerResource +import ContainerRuntimeLinuxClient import ContainerizationError import Foundation import TerminalProgress @@ -72,7 +73,7 @@ extension Application { let id = Utility.createContainerID(name: self.managementFlags.name) try Utility.validEntityName(id) - let ck = try await Utility.containerConfigFromFlags( + let (config, kernel, initImageRef) = try await Utility.containerConfigFromFlags( id: id, image: image, arguments: arguments, @@ -86,9 +87,22 @@ extension Application { log: log ) + guard let kernel else { + throw ContainerizationError(.internalError, message: "failed to resolve kernel") + } + guard let initImageRef else { + throw ContainerizationError(.internalError, message: "failed to resolve init image") + } + + let runtimeData = try LinuxRuntimeData.encodeData( + kernelPath: kernel.path.path, + initImageRef: initImageRef, + imageRef: config.image.reference + ) + let options = ContainerCreateOptions(autoRemove: managementFlags.remove) let client = ContainerClient() - try await client.create(configuration: ck.0, options: options, kernel: ck.1, initImage: ck.2) + try await client.create(configuration: config, options: options, runtimeData: runtimeData) if !self.managementFlags.cidfile.isEmpty { let path = self.managementFlags.cidfile diff --git a/Sources/ContainerCommands/Container/ContainerRun.swift b/Sources/ContainerCommands/Container/ContainerRun.swift index 431279ac5..569bdd05b 100644 --- a/Sources/ContainerCommands/Container/ContainerRun.swift +++ b/Sources/ContainerCommands/Container/ContainerRun.swift @@ -19,6 +19,7 @@ import ContainerAPIClient import ContainerPersistence import ContainerPlugin import ContainerResource +import ContainerRuntimeLinuxClient import Containerization import ContainerizationError import ContainerizationExtras @@ -92,7 +93,7 @@ extension Application { ) } - let ck = try await Utility.containerConfigFromFlags( + let (config, kernel, initImageRef) = try await Utility.containerConfigFromFlags( id: id, image: image, arguments: arguments, @@ -106,15 +107,23 @@ extension Application { log: log ) + guard let kernel else { + throw ContainerizationError(.internalError, message: "failed to resolve kernel") + } + guard let initImageRef else { + throw ContainerizationError(.internalError, message: "failed to resolve init image") + } + + let runtimeData = try LinuxRuntimeData.encodeData( + kernelPath: kernel.path.path, + initImageRef: initImageRef, + imageRef: config.image.reference + ) + progress.set(description: "Starting container") let options = ContainerCreateOptions(autoRemove: managementFlags.remove) - try await client.create( - configuration: ck.0, - options: options, - kernel: ck.1, - initImage: ck.2 - ) + try await client.create(configuration: config, options: options, runtimeData: runtimeData) let detach = self.managementFlags.detach do { diff --git a/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper.swift b/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper.swift index bd322cdf0..0c7472157 100644 --- a/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper.swift +++ b/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper.swift @@ -24,7 +24,7 @@ struct RuntimeLinuxHelper: AsyncParsableCommand { abstract: "XPC Service for managing a Linux sandbox", version: ReleaseVersion.singleLine(appName: "container-runtime-linux"), subcommands: [ - Start.self + Start.self, ] ) } diff --git a/Sources/Services/ContainerAPIService/Client/ContainerClient.swift b/Sources/Services/ContainerAPIService/Client/ContainerClient.swift index b1b64a66e..2f5005ef8 100644 --- a/Sources/Services/ContainerAPIService/Client/ContainerClient.swift +++ b/Sources/Services/ContainerAPIService/Client/ContainerClient.swift @@ -16,7 +16,6 @@ import ContainerResource import ContainerXPC -import Containerization import ContainerizationError import ContainerizationOCI import Foundation @@ -48,27 +47,16 @@ public struct ContainerClient: Sendable { public func create( configuration: ContainerConfiguration, options: ContainerCreateOptions = .default, - kernel: Kernel, - initImage: String? = nil, - runtimeData: Data? = nil + runtimeData: Data ) async throws { do { let request = XPCMessage(route: .containerCreate) let data = try JSONEncoder().encode(configuration) - let kdata = try JSONEncoder().encode(kernel) let odata = try JSONEncoder().encode(options) request.set(key: .containerConfig, value: data) - request.set(key: .kernel, value: kdata) request.set(key: .containerOptions, value: odata) - - if let initImage { - request.set(key: .initImage, value: initImage) - } - - if let runtimeData { - request.set(key: .runtimeData, value: runtimeData) - } + request.set(key: .runtimeData, value: runtimeData) try await xpcSend(message: request) } catch { diff --git a/Sources/Services/ContainerAPIService/Client/Utility.swift b/Sources/Services/ContainerAPIService/Client/Utility.swift index bfea2bbc0..0826eb15b 100644 --- a/Sources/Services/ContainerAPIService/Client/Utility.swift +++ b/Sources/Services/ContainerAPIService/Client/Utility.swift @@ -67,6 +67,7 @@ public struct Utility { } } + // TODO: refactor to remove kernel + initimage fetch from APIService public static func containerConfigFromFlags( id: String, image: String, @@ -77,9 +78,11 @@ public struct Utility { registry: Flags.Registry, imageFetch: Flags.ImageFetch, containerSystemConfig: ContainerSystemConfig, + fetchKernel: Bool = true, + fetchInitImage: Bool = true, progressUpdate: @escaping ProgressUpdateHandler, log: Logger - ) async throws -> (ContainerConfiguration, Kernel, String?) { + ) async throws -> (ContainerConfiguration, Kernel?, String?) { let requestedPlatform = try DefaultPlatform.resolveWithDefaults( platform: management.platform, os: management.os, @@ -113,34 +116,39 @@ public struct Utility { platform: requestedPlatform, progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: progressUpdate)) - await progressUpdate([ - .setDescription("Fetching kernel"), - .setItemsName("binary"), - ]) - - let kernel = try await self.getKernel(management: management) - - // Pull and unpack the initial filesystem - await progressUpdate([ - .setDescription("Fetching init image"), - .setItemsName("blobs"), - ]) - let fetchInitTask = await taskManager.startTask() - 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) + var kernel: Kernel? = nil + if fetchKernel { + await progressUpdate([ + .setDescription("Fetching kernel"), + .setItemsName("binary"), + ]) + kernel = try await self.getKernel(management: management) + } - await progressUpdate([ - .setDescription("Unpacking init image"), - .setItemsName("entries"), - ]) - let unpackInitTask = await taskManager.startTask() - _ = try await initImage.getCreateSnapshot( - platform: .current, - progressUpdate: ProgressTaskCoordinator.handler(for: unpackInitTask, from: progressUpdate)) + var initImageRef: String? = nil + if fetchInitImage { + await progressUpdate([ + .setDescription("Fetching init image"), + .setItemsName("blobs"), + ]) + let fetchInitTask = await taskManager.startTask() + let initImageReference = management.initImage ?? containerSystemConfig.vminit.image + let initImage = try await ClientImage.fetch( + reference: initImageReference, platform: .current, scheme: scheme, + containerSystemConfig: containerSystemConfig, + progressUpdate: ProgressTaskCoordinator.handler(for: fetchInitTask, from: progressUpdate), + maxConcurrentDownloads: imageFetch.maxConcurrentDownloads) + + await progressUpdate([ + .setDescription("Unpacking init image"), + .setItemsName("entries"), + ]) + let unpackInitTask = await taskManager.startTask() + _ = try await initImage.getCreateSnapshot( + platform: .current, + progressUpdate: ProgressTaskCoordinator.handler(for: unpackInitTask, from: progressUpdate)) + initImageRef = initImage.reference + } await taskManager.finish() @@ -264,7 +272,21 @@ public struct Utility { config.runtimeHandler = runtime } - return (config, kernel, management.initImage) + return (config, kernel, initImageRef) + } + + private static func getKernel(management: Flags.Management) async throws -> Kernel { + // For the image itself we'll take the user input and try with it as we can do userspace + // emulation for x86, but for the kernel we need it to match the hosts architecture. + let s: SystemPlatform = .current + if let userKernel = management.kernel { + guard FileManager.default.fileExists(atPath: userKernel) else { + throw ContainerizationError(.notFound, message: "kernel file not found at path \(userKernel)") + } + let p = URL(filePath: userKernel) + return .init(path: p, platform: s) + } + return try await ClientKernel.getDefaultKernel(for: s) } static func getAttachmentConfigurations( @@ -329,20 +351,6 @@ public struct Utility { return [AttachmentConfiguration(network: builtinNetworkId, options: AttachmentOptions(hostname: fqdn ?? containerId, macAddress: nil, mtu: 1280))] } - private static func getKernel(management: Flags.Management) async throws -> Kernel { - // For the image itself we'll take the user input and try with it as we can do userspace - // emulation for x86, but for the kernel we need it to match the hosts architecture. - let s: SystemPlatform = .current - if let userKernel = management.kernel { - guard FileManager.default.fileExists(atPath: userKernel) else { - throw ContainerizationError(.notFound, message: "kernel file not found at path \(userKernel)") - } - let p = URL(filePath: userKernel) - return .init(path: p, platform: s) - } - return try await ClientKernel.getDefaultKernel(for: s) - } - /// Parses key-value pairs from command line arguments. /// /// Supports formats like "key=value" and standalone keys (treated as "key="). diff --git a/Sources/Services/ContainerAPIService/Client/XPC+.swift b/Sources/Services/ContainerAPIService/Client/XPC+.swift index 499b82b84..d346667b1 100644 --- a/Sources/Services/ContainerAPIService/Client/XPC+.swift +++ b/Sources/Services/ContainerAPIService/Client/XPC+.swift @@ -113,9 +113,6 @@ public enum XPCKeys: String { case systemPlatform case kernelForce - /// Init image reference - case initImage - /// Volume case volume case volumes diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift index d7da46e3d..f4b598d80 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift @@ -180,25 +180,21 @@ public struct ContainersHarness: Sendable { message: "container configuration cannot be empty" ) } - let kdata = message.dataNoCopy(key: .kernel) - guard let kdata else { - throw ContainerizationError( - .invalidArgument, - message: "kernel cannot be empty" - ) - } let odata = message.dataNoCopy(key: .containerOptions) var options: ContainerCreateOptions = .default if let odata { options = try JSONDecoder().decode(ContainerCreateOptions.self, from: odata) } let config = try JSONDecoder().decode(ContainerConfiguration.self, from: data) - let kernel = try JSONDecoder().decode(Kernel.self, from: kdata) - let initImage = message.string(key: .initImage) - let runtimeData = message.dataNoCopy(key: .runtimeData) + guard let runtimeData = message.dataNoCopy(key: .runtimeData) else { + throw ContainerizationError( + .invalidArgument, + message: "runtime data cannot be empty" + ) + } - try await service.create(configuration: config, kernel: kernel, options: options, initImage: initImage, runtimeData: runtimeData) + try await service.create(configuration: config, options: options, runtimeData: runtimeData) return message.reply() } diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift index a37ad6912..22e49b238 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift @@ -264,7 +264,7 @@ public actor ContainersService { } /// Create a new container from the provided id and configuration. - public func create(configuration: ContainerConfiguration, kernel: Kernel, options: ContainerCreateOptions, initImage: String? = nil, runtimeData: Data? = nil) async throws { + public func create(configuration: ContainerConfiguration, options: ContainerCreateOptions, runtimeData: Data) async throws { log.debug( "ContainersService: enter", metadata: [ @@ -311,7 +311,7 @@ public actor ContainersService { ) } - guard self.runtimePlugins.first(where: { $0.name == configuration.runtimeHandler }) != nil else { + guard self.runtimePlugins.contains(where: { $0.name == configuration.runtimeHandler }) else { throw ContainerizationError( .notFound, message: "unable to locate runtime plugin \(configuration.runtimeHandler)" @@ -334,56 +334,21 @@ public actor ContainersService { } let path = self.containerRoot.appendingPathComponent(configuration.id) - let systemPlatform = kernel.platform - - // Fetch init image (custom or default) - self.log.debug( - "ContainersService: get init block", - metadata: [ - "id": "\(configuration.id)" - ] + let runtimeConfig = RuntimeConfiguration( + path: path, + containerConfiguration: configuration, + options: options, + runtimeData: runtimeData ) - let initFilesystem = try await self.getInitBlock(for: systemPlatform.ociPlatform(), imageRef: initImage) + try runtimeConfig.writeRuntimeConfiguration() - do { - self.log.debug( - "create snapshot", - metadata: [ - "id": "\(configuration.id)", - "ref": "\(configuration.image.reference)", - ]) - let containerImage = ClientImage(description: configuration.image) - let imageFs = try await options.rootFsOverride == nil ? containerImage.getCreateSnapshot(platform: configuration.platform) : nil - - self.log.debug( - "configure runtime", - metadata: [ - "id": "\(configuration.id)", - "kernel": "\(kernel.path)", - "initfs": "\(initImage ?? self.containerSystemConfig.vminit.image)", - ]) - let runtimeConfig = RuntimeConfiguration( - path: path, - initialFilesystem: initFilesystem, - kernel: kernel, - containerConfiguration: configuration, - containerRootFilesystem: imageFs, - options: options, - runtimeData: runtimeData - ) - - try runtimeConfig.writeRuntimeConfiguration() - - let snapshot = ContainerSnapshot( - configuration: configuration, - status: .stopped, - networks: [], - startedDate: nil - ) - await self.setContainerState(configuration.id, ContainerState(snapshot: snapshot), context: context) - } catch { - throw error - } + let snapshot = ContainerSnapshot( + configuration: configuration, + status: .stopped, + networks: [], + startedDate: nil + ) + await self.setContainerState(configuration.id, ContainerState(snapshot: snapshot), context: context) } } @@ -1078,14 +1043,6 @@ public actor ContainersService { return options } - private func getInitBlock(for platform: Platform, imageRef: String? = nil) async throws -> Filesystem { - 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 - } - private static func registerService( plugin: Plugin, loader: PluginLoader, @@ -1130,6 +1087,7 @@ public actor ContainersService { id == processID } + /// Get container configuration, either from existing bundle or from RuntimeConfiguration private static func getContainerConfiguration(at path: URL) throws -> (ContainerConfiguration, ContainerCreateOptions?) { let bundle = ContainerResource.Bundle(path: path) diff --git a/Sources/Services/Runtime/RuntimeClient/RuntimeConfiguration.swift b/Sources/Services/Runtime/RuntimeClient/RuntimeConfiguration.swift index 29507bb70..ed080268d 100644 --- a/Sources/Services/Runtime/RuntimeClient/RuntimeConfiguration.swift +++ b/Sources/Services/Runtime/RuntimeClient/RuntimeConfiguration.swift @@ -23,31 +23,32 @@ public struct RuntimeConfiguration: Codable, Sendable { static let runtimeConfigurationFilename = "runtime-configuration.json" public let path: URL - // TODO: Remove runtime-specific fields (initialFilesystem, kernel, containerRootFilesystem). - // These should be encoded into the opaque `runtimeData` field by the CLI. - public let initialFilesystem: Filesystem - public let kernel: Kernel public let containerConfiguration: ContainerConfiguration? - public let containerRootFilesystem: Filesystem? public let options: ContainerCreateOptions? public let runtimeData: Data? + // TEMPORARY: Retained for decoding old-format files. Plugin migrate handles conversion. + // TODO: Remove after migration period. + public let initialFilesystem: Filesystem? + public let kernel: Kernel? + public let containerRootFilesystem: Filesystem? + public init( path: URL, - initialFilesystem: Filesystem, - kernel: Kernel, containerConfiguration: ContainerConfiguration? = nil, - containerRootFilesystem: Filesystem? = nil, options: ContainerCreateOptions? = nil, - runtimeData: Data? = nil + runtimeData: Data? = nil, + initialFilesystem: Filesystem? = nil, + kernel: Kernel? = nil, + containerRootFilesystem: Filesystem? = nil ) { self.path = path - self.initialFilesystem = initialFilesystem - self.kernel = kernel self.containerConfiguration = containerConfiguration - self.containerRootFilesystem = containerRootFilesystem self.options = options self.runtimeData = runtimeData + self.initialFilesystem = initialFilesystem + self.kernel = kernel + self.containerRootFilesystem = containerRootFilesystem } public var runtimeConfigurationPath: URL { diff --git a/Sources/Services/RuntimeLinux/Client/LinuxRuntimeData.swift b/Sources/Services/RuntimeLinux/Client/LinuxRuntimeData.swift index d30185a11..ba12ab37e 100644 --- a/Sources/Services/RuntimeLinux/Client/LinuxRuntimeData.swift +++ b/Sources/Services/RuntimeLinux/Client/LinuxRuntimeData.swift @@ -19,9 +19,46 @@ import Foundation /// Linux-specific runtime data passed through the opaque runtimeData field /// in RuntimeConfiguration. Encoded by the CLI, decoded by the Linux runtime. public struct LinuxRuntimeData: Codable, Sendable { + /// Runtime variant identifier. public let variant: String? + /// Path to the kernel binary on the host. + public let kernelPath: String + /// Reference to the init image (resolved from local cache at bootstrap). + public let initImageRef: String + /// Reference to the container root image (resolved from local cache at bootstrap). + /// Nil when using rootFsOverride. + public let imageRef: String? - public init(variant: String? = nil) { + public init( + variant: String? = nil, + kernelPath: String, + initImageRef: String, + imageRef: String? + ) { self.variant = variant + self.kernelPath = kernelPath + self.initImageRef = initImageRef + self.imageRef = imageRef + } + + /// Encode Linux-specific runtime data into an opaque blob to pass through the API server. + public static func encodeData( + kernelPath: String, + initImageRef: String, + imageRef: String?, + variant: String? = nil + ) throws -> Data { + let data = LinuxRuntimeData( + variant: variant, + kernelPath: kernelPath, + initImageRef: initImageRef, + imageRef: imageRef + ) + return try JSONEncoder().encode(data) + } + + /// Decode Linux-specific runtime data from the opaque blob. + public static func decodeData(_ data: Data) throws -> LinuxRuntimeData { + try JSONDecoder().decode(LinuxRuntimeData.self, from: data) } } diff --git a/Sources/Services/RuntimeLinux/Server/RuntimeService.swift b/Sources/Services/RuntimeLinux/Server/RuntimeService.swift index e51ad14b0..06e9376c3 100644 --- a/Sources/Services/RuntimeLinux/Server/RuntimeService.swift +++ b/Sources/Services/RuntimeLinux/Server/RuntimeService.swift @@ -14,11 +14,13 @@ // limitations under the License. //===----------------------------------------------------------------------===// +import ContainerAPIClient import ContainerNetworkClient import ContainerOS import ContainerPersistence import ContainerResource import ContainerRuntimeClient +import ContainerRuntimeLinuxClient import ContainerXPC import Containerization import ContainerizationError @@ -141,7 +143,7 @@ public actor RuntimeService { // Create the bundle if it doesn't exist yet if !self.bundleExists(at: self.root) { - try self.createBundle() + try await self.createBundle() } return try await self.lock.withLock { _ in @@ -1551,15 +1553,47 @@ extension RuntimeService { } /// Create bundle from RuntimeConfiguration - private func createBundle() throws { + private func createBundle() async throws { do { let runtimeConfig = try RuntimeConfiguration.readRuntimeConfiguration(from: self.root) + + guard let runtimeDataBlob = runtimeConfig.runtimeData else { + throw ContainerizationError(.invalidState, message: "runtime configuration missing runtimeData") + } + + let linuxData = try LinuxRuntimeData.decodeData(runtimeDataBlob) + let initPlatform = SystemPlatform.current.ociPlatform() + let containerPlatform = runtimeConfig.containerConfiguration?.platform ?? initPlatform + + // Resolve image refs from local cache + let allImages = try await ClientImage.list() + + // Resolve init image ref to filesystem (uses kernel/host platform) + guard let initImage = allImages.first(where: { $0.reference == linuxData.initImageRef }) else { + throw ContainerizationError(.notFound, message: "init image not found: \(linuxData.initImageRef)") + } + var initFs = try await initImage.getCreateSnapshot(platform: initPlatform) + initFs.options = ["ro"] + + // Resolve container image ref to filesystem (uses requested container platform) + let containerFs: Filesystem? + if let imageRef = linuxData.imageRef { + guard let containerImage = allImages.first(where: { $0.reference == imageRef }) else { + throw ContainerizationError(.notFound, message: "container image not found: \(imageRef)") + } + containerFs = try await containerImage.getCreateSnapshot(platform: containerPlatform) + } else { + containerFs = runtimeConfig.options?.rootFsOverride + } + + let kernel = Kernel(path: URL(fileURLWithPath: linuxData.kernelPath), platform: .current) + _ = try ContainerResource.Bundle.create( path: runtimeConfig.path, - initialFilesystem: runtimeConfig.initialFilesystem, - kernel: runtimeConfig.kernel, + initialFilesystem: initFs, + kernel: kernel, containerConfiguration: runtimeConfig.containerConfiguration, - containerRootFilesystem: runtimeConfig.containerRootFilesystem, + containerRootFilesystem: containerFs, options: runtimeConfig.options ) self.log.info("created bundle", metadata: ["configPath": "\(runtimeConfig.path)"]) diff --git a/Tests/ContainerAPIServiceTests/RuntimeConfigurationTests.swift b/Tests/ContainerAPIServiceTests/RuntimeConfigurationTests.swift index a1529d311..3668826e7 100644 --- a/Tests/ContainerAPIServiceTests/RuntimeConfigurationTests.swift +++ b/Tests/ContainerAPIServiceTests/RuntimeConfigurationTests.swift @@ -49,48 +49,27 @@ struct RuntimeConfigurationTests { try? FileManager.default.removeItem(at: bundlePath) } - let initFs = Filesystem.virtiofs( - source: "/path/to/initfs", - destination: "/", - options: ["ro"] - ) - - let kernel = Kernel( - path: URL(fileURLWithPath: "/path/to/kernel"), - platform: .linuxArm + let linuxData = LinuxRuntimeData( + kernelPath: "/path/to/kernel", + initImageRef: "init:latest", + imageRef: "alpine:latest" ) + let encodedData = try JSONEncoder().encode(linuxData) let runtimeConfig = RuntimeConfiguration( path: bundlePath, - initialFilesystem: initFs, - kernel: kernel, - containerConfiguration: nil, - containerRootFilesystem: nil, - options: nil + runtimeData: encodedData ) try runtimeConfig.writeRuntimeConfiguration() - - let readRuntimeConfig = try RuntimeConfiguration.readRuntimeConfiguration(from: bundlePath) - - #expect( - readRuntimeConfig.path == bundlePath, - "Path should match") - #expect( - readRuntimeConfig.kernel.path == kernel.path, - "Kernel path should match") - #expect( - readRuntimeConfig.initialFilesystem.source == initFs.source, - "Initial filesystem source should match") - #expect( - readRuntimeConfig.containerConfiguration == nil, - "Container configuration should be nil") - #expect( - readRuntimeConfig.containerRootFilesystem == nil, - "Root filesystem should be nil") - #expect( - readRuntimeConfig.options == nil, - "Options should be nil") + let readConfig = try RuntimeConfiguration.readRuntimeConfiguration(from: bundlePath) + + #expect(readConfig.path == bundlePath) + #expect(readConfig.runtimeData != nil) + #expect(readConfig.containerConfiguration == nil) + #expect(readConfig.options == nil) + #expect(readConfig.kernel == nil) + #expect(readConfig.initialFilesystem == nil) } @Test @@ -113,14 +92,19 @@ struct RuntimeConfigurationTests { platform: .linuxArm ) - let linuxData = LinuxRuntimeData(variant: "test-variant") + let linuxData = LinuxRuntimeData( + variant: "test-variant", + kernelPath: "/path/to/kernel", + initImageRef: "init:latest", + imageRef: "ubuntu:22.04" + ) let encodedData = try JSONEncoder().encode(linuxData) let runtimeConfig = RuntimeConfiguration( path: bundlePath, + runtimeData: encodedData, initialFilesystem: initFs, - kernel: kernel, - runtimeData: encodedData + kernel: kernel ) try runtimeConfig.writeRuntimeConfiguration() @@ -132,4 +116,111 @@ struct RuntimeConfigurationTests { let decodedData = try JSONDecoder().decode(LinuxRuntimeData.self, from: readRuntimeConfig.runtimeData!) #expect(decodedData.variant == "test-variant", "Variant should round-trip through RuntimeConfiguration") } + + /// Test that LinuxRuntimeData encodes and decodes correctly with all fields populated. + @Test + func testLinuxRuntimeDataFullEncoding() throws { + let original = LinuxRuntimeData( + variant: "rosetta", + kernelPath: "/usr/local/share/container/kernel", + initImageRef: "ghcr.io/apple/container-init:latest", + imageRef: "ubuntu:22.04" + ) + + let encoded = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(LinuxRuntimeData.self, from: encoded) + + #expect(decoded.variant == original.variant, "variant should round-trip") + #expect(decoded.kernelPath == original.kernelPath, "kernelPath should round-trip") + #expect(decoded.initImageRef == original.initImageRef, "initImageRef should round-trip") + #expect(decoded.imageRef == original.imageRef, "imageRef should round-trip") + } + + /// Test that LinuxRuntimeData encodes and decodes correctly when optional fields are nil. + @Test + func testLinuxRuntimeDataNilContainerImageRef() throws { + let original = LinuxRuntimeData( + variant: nil, + kernelPath: "/usr/local/share/container/kernel", + initImageRef: "ghcr.io/apple/container-init:latest", + imageRef: nil + ) + + let encoded = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(LinuxRuntimeData.self, from: encoded) + + #expect(decoded.variant == nil, "variant should be nil") + #expect(decoded.kernelPath == original.kernelPath, "kernelPath should round-trip") + #expect(decoded.initImageRef == original.initImageRef, "initImageRef should round-trip") + #expect(decoded.imageRef == nil, "imageRef should be nil") + } + + /// Test that a new-format RuntimeConfiguration (runtimeData only, no legacy fields) round-trips correctly. + @Test + func testNewFormatRoundTrip() throws { + let tempDir = FileManager.default.temporaryDirectory + let bundlePath = tempDir.appendingPathComponent("test-new-format-\(UUID())") + + defer { + try? FileManager.default.removeItem(at: bundlePath) + } + + let linuxData = LinuxRuntimeData( + variant: "default", + kernelPath: "/path/to/kernel", + initImageRef: "init:latest", + imageRef: "alpine:3.20" + ) + let runtimeData = try JSONEncoder().encode(linuxData) + + let config = RuntimeConfiguration( + path: bundlePath, + containerConfiguration: nil, + options: nil, + runtimeData: runtimeData + ) + + try config.writeRuntimeConfiguration() + let read = try RuntimeConfiguration.readRuntimeConfiguration(from: bundlePath) + + #expect(read.runtimeData != nil) + #expect(read.kernel == nil, "New format should not have legacy kernel field") + #expect(read.initialFilesystem == nil, "New format should not have legacy filesystem field") + + let decoded = try JSONDecoder().decode(LinuxRuntimeData.self, from: read.runtimeData!) + #expect(decoded.kernelPath == "/path/to/kernel") + #expect(decoded.initImageRef == "init:latest") + #expect(decoded.imageRef == "alpine:3.20") + #expect(decoded.variant == "default") + } + + /// Test the rootFsOverride case where imageRef is nil. + @Test + func testNewFormatWithNilContainerImageRef() throws { + let tempDir = FileManager.default.temporaryDirectory + let bundlePath = tempDir.appendingPathComponent("test-nil-ref-\(UUID())") + + defer { + try? FileManager.default.removeItem(at: bundlePath) + } + + let linuxData = LinuxRuntimeData( + kernelPath: "/path/to/kernel", + initImageRef: "init:latest", + imageRef: nil + ) + let runtimeData = try JSONEncoder().encode(linuxData) + + let config = RuntimeConfiguration( + path: bundlePath, + runtimeData: runtimeData + ) + + try config.writeRuntimeConfiguration() + let read = try RuntimeConfiguration.readRuntimeConfiguration(from: bundlePath) + + let decoded = try JSONDecoder().decode(LinuxRuntimeData.self, from: read.runtimeData!) + #expect(decoded.imageRef == nil) + #expect(decoded.initImageRef == "init:latest") + } } From 3755ac95642caaa6c5c83ca4bb3b856ae68a51e0 Mon Sep 17 00:00:00 2001 From: ajemory Date: Thu, 11 Jun 2026 13:12:03 -0700 Subject: [PATCH 2/2] runtime abstraction plumbing --- Package.swift | 1 + Sources/APIServer/APIServer+Start.swift | 3 - .../Builder/BuilderStart.swift | 17 +- .../Container/ContainerCreate.swift | 3 +- .../Container/ContainerRun.swift | 3 +- .../MachineAPIServer+Start.swift | 9 +- .../RuntimeLinux/RuntimeLinuxHelper.swift | 2 +- .../ContainerAPIService/Client/Utility.swift | 28 +-- .../Server/Containers/ContainersService.swift | 5 - .../Server/MachinesService.swift | 18 +- .../RuntimeClient/RuntimeConfiguration.swift | 17 +- .../Client/LinuxRuntimeData.swift | 13 +- .../RuntimeLinux/Server/RuntimeService.swift | 58 +++--- .../RuntimeConfigurationTests.swift | 172 +++++++----------- 14 files changed, 165 insertions(+), 184 deletions(-) diff --git a/Package.swift b/Package.swift index e9d8579d0..ba5f95493 100644 --- a/Package.swift +++ b/Package.swift @@ -591,6 +591,7 @@ let package = Package( "ContainerAPIClient", "ContainerResource", "ContainerRuntimeClient", + "ContainerRuntimeLinuxClient", "ContainerXPC", "MachineAPIClient", ], diff --git a/Sources/APIServer/APIServer+Start.swift b/Sources/APIServer/APIServer+Start.swift index b6038b550..838d66489 100644 --- a/Sources/APIServer/APIServer+Start.swift +++ b/Sources/APIServer/APIServer+Start.swift @@ -66,7 +66,6 @@ extension APIServer { try await initializePlugins(pluginLoader: pluginLoader, log: log, routes: &routes, debug: debug) let containersService = try initializeContainersService( pluginLoader: pluginLoader, - containerSystemConfig: containerSystemConfig, log: log, routes: &routes ) @@ -273,7 +272,6 @@ extension APIServer { private func initializeContainersService( pluginLoader: PluginLoader, - containerSystemConfig: ContainerSystemConfig, log: Logger, routes: inout [XPCRoute: XPCServer.RouteHandler] ) throws -> ContainersService { @@ -284,7 +282,6 @@ extension APIServer { let service = try ContainersService( appRoot: appRootURL, pluginLoader: pluginLoader, - containerSystemConfig: containerSystemConfig, log: log, debugHelpers: debug ) diff --git a/Sources/ContainerCommands/Builder/BuilderStart.swift b/Sources/ContainerCommands/Builder/BuilderStart.swift index 4989e810e..00d908b8c 100644 --- a/Sources/ContainerCommands/Builder/BuilderStart.swift +++ b/Sources/ContainerCommands/Builder/BuilderStart.swift @@ -283,14 +283,27 @@ extension Application { ]) let kernel = try await ClientKernel.getDefaultKernel(for: .current) + // Ensure the init image is present locally and resolve it to a canonical + // reference for the runtime to load at bootstrap. + await progressUpdate([ + .setDescription("Fetching init image"), + .setItemsName("blobs"), + ]) + let initFetchTask = await taskManager.startTask() + let initImage = try await ClientImage.fetch( + reference: containerSystemConfig.vminit.image, + platform: .current, + containerSystemConfig: containerSystemConfig, + progressUpdate: ProgressTaskCoordinator.handler(for: initFetchTask, from: progressUpdate) + ) + await progressUpdate([ .setDescription("Starting BuildKit container") ]) let runtimeData = try LinuxRuntimeData.encodeData( kernelPath: kernel.path.path, - initImageRef: containerSystemConfig.vminit.image, - imageRef: builderImage + initImageRef: initImage.reference ) try await client.create( diff --git a/Sources/ContainerCommands/Container/ContainerCreate.swift b/Sources/ContainerCommands/Container/ContainerCreate.swift index 7990c415b..535a0fe27 100644 --- a/Sources/ContainerCommands/Container/ContainerCreate.swift +++ b/Sources/ContainerCommands/Container/ContainerCreate.swift @@ -96,8 +96,7 @@ extension Application { let runtimeData = try LinuxRuntimeData.encodeData( kernelPath: kernel.path.path, - initImageRef: initImageRef, - imageRef: config.image.reference + initImageRef: initImageRef ) let options = ContainerCreateOptions(autoRemove: managementFlags.remove) diff --git a/Sources/ContainerCommands/Container/ContainerRun.swift b/Sources/ContainerCommands/Container/ContainerRun.swift index 569bdd05b..f8632d959 100644 --- a/Sources/ContainerCommands/Container/ContainerRun.swift +++ b/Sources/ContainerCommands/Container/ContainerRun.swift @@ -116,8 +116,7 @@ extension Application { let runtimeData = try LinuxRuntimeData.encodeData( kernelPath: kernel.path.path, - initImageRef: initImageRef, - imageRef: config.image.reference + initImageRef: initImageRef ) progress.set(description: "Starting container") diff --git a/Sources/Plugins/MachineAPIServer/MachineAPIServer+Start.swift b/Sources/Plugins/MachineAPIServer/MachineAPIServer+Start.swift index 6e3b89009..3c4a0fade 100644 --- a/Sources/Plugins/MachineAPIServer/MachineAPIServer+Start.swift +++ b/Sources/Plugins/MachineAPIServer/MachineAPIServer+Start.swift @@ -16,6 +16,7 @@ import ArgumentParser import ContainerLog +import ContainerPersistence import ContainerPlugin import ContainerXPC import Foundation @@ -60,7 +61,13 @@ extension MachineAPIServer { log.info("configuring XPC server") let resourceRoot = FilePath(resources) - let service = try MachinesService(appRoot: pluginStateRoot, resourceRoot: resourceRoot, log: log) + let containerSystemConfig: ContainerSystemConfig = try await ConfigurationLoader.load() + let service = try MachinesService( + appRoot: pluginStateRoot, + resourceRoot: resourceRoot, + containerSystemConfig: containerSystemConfig, + log: log + ) let harness = MachinesHarness(service: service) let server = XPCServer( diff --git a/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper.swift b/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper.swift index 0c7472157..bd322cdf0 100644 --- a/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper.swift +++ b/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper.swift @@ -24,7 +24,7 @@ struct RuntimeLinuxHelper: AsyncParsableCommand { abstract: "XPC Service for managing a Linux sandbox", version: ReleaseVersion.singleLine(appName: "container-runtime-linux"), subcommands: [ - Start.self, + Start.self ] ) } diff --git a/Sources/Services/ContainerAPIService/Client/Utility.swift b/Sources/Services/ContainerAPIService/Client/Utility.swift index 0826eb15b..6e72272a2 100644 --- a/Sources/Services/ContainerAPIService/Client/Utility.swift +++ b/Sources/Services/ContainerAPIService/Client/Utility.swift @@ -275,20 +275,6 @@ public struct Utility { return (config, kernel, initImageRef) } - private static func getKernel(management: Flags.Management) async throws -> Kernel { - // For the image itself we'll take the user input and try with it as we can do userspace - // emulation for x86, but for the kernel we need it to match the hosts architecture. - let s: SystemPlatform = .current - if let userKernel = management.kernel { - guard FileManager.default.fileExists(atPath: userKernel) else { - throw ContainerizationError(.notFound, message: "kernel file not found at path \(userKernel)") - } - let p = URL(filePath: userKernel) - return .init(path: p, platform: s) - } - return try await ClientKernel.getDefaultKernel(for: s) - } - static func getAttachmentConfigurations( containerId: String, builtinNetworkId: String?, @@ -351,6 +337,20 @@ public struct Utility { return [AttachmentConfiguration(network: builtinNetworkId, options: AttachmentOptions(hostname: fqdn ?? containerId, macAddress: nil, mtu: 1280))] } + private static func getKernel(management: Flags.Management) async throws -> Kernel { + // For the image itself we'll take the user input and try with it as we can do userspace + // emulation for x86, but for the kernel we need it to match the hosts architecture. + let s: SystemPlatform = .current + if let userKernel = management.kernel { + guard FileManager.default.fileExists(atPath: userKernel) else { + throw ContainerizationError(.notFound, message: "kernel file not found at path \(userKernel)") + } + let p = URL(filePath: userKernel) + return .init(path: p, platform: s) + } + return try await ClientKernel.getDefaultKernel(for: s) + } + /// Parses key-value pairs from command line arguments. /// /// Supports formats like "key=value" and standalone keys (treated as "key="). diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift index 22e49b238..40d140a03 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift @@ -16,7 +16,6 @@ import CVersion import ContainerAPIClient -import ContainerPersistence import ContainerPlugin import ContainerResource import ContainerRuntimeClient @@ -57,7 +56,6 @@ 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] @@ -68,7 +66,6 @@ public actor ContainersService { public init( appRoot: URL, pluginLoader: PluginLoader, - containerSystemConfig: ContainerSystemConfig, log: Logger, debugHelpers: Bool = false ) throws { @@ -78,7 +75,6 @@ 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) } @@ -1087,7 +1083,6 @@ public actor ContainersService { id == processID } - /// Get container configuration, either from existing bundle or from RuntimeConfiguration private static func getContainerConfiguration(at path: URL) throws -> (ContainerConfiguration, ContainerCreateOptions?) { let bundle = ContainerResource.Bundle(path: path) diff --git a/Sources/Services/MachineAPIService/Server/MachinesService.swift b/Sources/Services/MachineAPIService/Server/MachinesService.swift index a80cc7698..859e7c778 100644 --- a/Sources/Services/MachineAPIService/Server/MachinesService.swift +++ b/Sources/Services/MachineAPIService/Server/MachinesService.swift @@ -18,6 +18,7 @@ import ContainerAPIClient import ContainerPersistence import ContainerResource import ContainerRuntimeClient +import ContainerRuntimeLinuxClient import Containerization import ContainerizationEXT4 import ContainerizationError @@ -49,6 +50,7 @@ public actor MachinesService { private let resourceRoot: FilePath private let machineRoot: FilePath + private let containerSystemConfig: ContainerSystemConfig private let lock = AsyncLock() private var machines: [String: MachineState] private let exitMonitor: ExitMonitor @@ -63,8 +65,9 @@ public actor MachinesService { return self.machines[id] } - public init(appRoot: FilePath, resourceRoot: FilePath, log: Logger) throws { + public init(appRoot: FilePath, resourceRoot: FilePath, containerSystemConfig: ContainerSystemConfig, log: Logger) throws { self.resourceRoot = resourceRoot + self.containerSystemConfig = containerSystemConfig let machineRoot = appRoot.appending(Self.machinesDir) try FileManager.default.createDirectory(atPath: machineRoot.string, withIntermediateDirectories: true) @@ -370,13 +373,24 @@ public actor MachinesService { config.resources.memoryInBytes = bootConfig.memory.toUInt64(unit: .bytes) let kernel = try await ClientKernel.getDefaultKernel(for: .current) + // Ensure the init image is present locally and resolve it to a canonical + // reference for the runtime to load at bootstrap. + let initImage = try await ClientImage.fetch( + reference: self.containerSystemConfig.vminit.image, + platform: .current, + containerSystemConfig: self.containerSystemConfig + ) + let runtimeData = try LinuxRuntimeData.encodeData( + kernelPath: kernel.path.path, + initImageRef: initImage.reference + ) var fhs: [FileHandle] = [] do { try await self.client.create( configuration: config, options: ContainerCreateOptions(autoRemove: true, rootFsOverride: rootfs), - kernel: kernel + runtimeData: runtimeData ) let process = try await self.client.bootstrap( diff --git a/Sources/Services/Runtime/RuntimeClient/RuntimeConfiguration.swift b/Sources/Services/Runtime/RuntimeClient/RuntimeConfiguration.swift index ed080268d..7a4cf264f 100644 --- a/Sources/Services/Runtime/RuntimeClient/RuntimeConfiguration.swift +++ b/Sources/Services/Runtime/RuntimeClient/RuntimeConfiguration.swift @@ -27,8 +27,9 @@ public struct RuntimeConfiguration: Codable, Sendable { public let options: ContainerCreateOptions? public let runtimeData: Data? - // TEMPORARY: Retained for decoding old-format files. Plugin migrate handles conversion. - // TODO: Remove after migration period. + // Legacy fields retained for decoding old-format files. + // Only used by the runtime plugin during migration at bootstrap. + // TODO: remove after migration period public let initialFilesystem: Filesystem? public let kernel: Kernel? public let containerRootFilesystem: Filesystem? @@ -37,18 +38,15 @@ public struct RuntimeConfiguration: Codable, Sendable { path: URL, containerConfiguration: ContainerConfiguration? = nil, options: ContainerCreateOptions? = nil, - runtimeData: Data? = nil, - initialFilesystem: Filesystem? = nil, - kernel: Kernel? = nil, - containerRootFilesystem: Filesystem? = nil + runtimeData: Data? = nil ) { self.path = path self.containerConfiguration = containerConfiguration self.options = options self.runtimeData = runtimeData - self.initialFilesystem = initialFilesystem - self.kernel = kernel - self.containerRootFilesystem = containerRootFilesystem + self.initialFilesystem = nil + self.kernel = nil + self.containerRootFilesystem = nil } public var runtimeConfigurationPath: URL { @@ -56,7 +54,6 @@ public struct RuntimeConfiguration: Codable, Sendable { } public func writeRuntimeConfiguration() throws { - // Ensure the parent directory exists let directory = self.runtimeConfigurationPath.deletingLastPathComponent() try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) diff --git a/Sources/Services/RuntimeLinux/Client/LinuxRuntimeData.swift b/Sources/Services/RuntimeLinux/Client/LinuxRuntimeData.swift index ba12ab37e..e276b15ae 100644 --- a/Sources/Services/RuntimeLinux/Client/LinuxRuntimeData.swift +++ b/Sources/Services/RuntimeLinux/Client/LinuxRuntimeData.swift @@ -23,36 +23,29 @@ public struct LinuxRuntimeData: Codable, Sendable { public let variant: String? /// Path to the kernel binary on the host. public let kernelPath: String - /// Reference to the init image (resolved from local cache at bootstrap). + /// Reference to the init image, resolved from the local image store at bootstrap. public let initImageRef: String - /// Reference to the container root image (resolved from local cache at bootstrap). - /// Nil when using rootFsOverride. - public let imageRef: String? public init( variant: String? = nil, kernelPath: String, - initImageRef: String, - imageRef: String? + initImageRef: String ) { self.variant = variant self.kernelPath = kernelPath self.initImageRef = initImageRef - self.imageRef = imageRef } /// Encode Linux-specific runtime data into an opaque blob to pass through the API server. public static func encodeData( kernelPath: String, initImageRef: String, - imageRef: String?, variant: String? = nil ) throws -> Data { let data = LinuxRuntimeData( variant: variant, kernelPath: kernelPath, - initImageRef: initImageRef, - imageRef: imageRef + initImageRef: initImageRef ) return try JSONEncoder().encode(data) } diff --git a/Sources/Services/RuntimeLinux/Server/RuntimeService.swift b/Sources/Services/RuntimeLinux/Server/RuntimeService.swift index 06e9376c3..78746b457 100644 --- a/Sources/Services/RuntimeLinux/Server/RuntimeService.swift +++ b/Sources/Services/RuntimeLinux/Server/RuntimeService.swift @@ -1556,38 +1556,48 @@ extension RuntimeService { private func createBundle() async throws { do { let runtimeConfig = try RuntimeConfiguration.readRuntimeConfiguration(from: self.root) - - guard let runtimeDataBlob = runtimeConfig.runtimeData else { - throw ContainerizationError(.invalidState, message: "runtime configuration missing runtimeData") - } - - let linuxData = try LinuxRuntimeData.decodeData(runtimeDataBlob) let initPlatform = SystemPlatform.current.ociPlatform() let containerPlatform = runtimeConfig.containerConfiguration?.platform ?? initPlatform - // Resolve image refs from local cache - let allImages = try await ClientImage.list() - - // Resolve init image ref to filesystem (uses kernel/host platform) - guard let initImage = allImages.first(where: { $0.reference == linuxData.initImageRef }) else { - throw ContainerizationError(.notFound, message: "init image not found: \(linuxData.initImageRef)") - } - var initFs = try await initImage.getCreateSnapshot(platform: initPlatform) - initFs.options = ["ro"] - - // Resolve container image ref to filesystem (uses requested container platform) + let initFs: Filesystem let containerFs: Filesystem? - if let imageRef = linuxData.imageRef { - guard let containerImage = allImages.first(where: { $0.reference == imageRef }) else { - throw ContainerizationError(.notFound, message: "container image not found: \(imageRef)") + let kernel: Kernel + + if let runtimeDataBlob = runtimeConfig.runtimeData { + // New format — decode LinuxRuntimeData and resolve refs + let linuxData = try LinuxRuntimeData.decodeData(runtimeDataBlob) + + // Resolve the init image reference to a filesystem + let systemConfig = try await ConfigurationLoader.load() + let initImage = try await ClientImage.get(reference: linuxData.initImageRef, containerSystemConfig: systemConfig) + var initFsResolved = try await initImage.getCreateSnapshot(platform: initPlatform) + initFsResolved.options = ["ro"] + initFs = initFsResolved + + // An explicit root filesystem override takes precedence over resolving + // the container image (e.g. a prebuilt persistent root supplied directly). + if let override = runtimeConfig.options?.rootFsOverride { + containerFs = override + } else if let imageDescription = runtimeConfig.containerConfiguration?.image { + let containerImage = ClientImage(description: imageDescription) + containerFs = try await containerImage.getCreateSnapshot(platform: containerPlatform) + } else { + containerFs = nil } - containerFs = try await containerImage.getCreateSnapshot(platform: containerPlatform) + + kernel = Kernel(path: URL(fileURLWithPath: linuxData.kernelPath), platform: .current) + } else if let legacyKernel = runtimeConfig.kernel, + let legacyInitFs = runtimeConfig.initialFilesystem + { + // Old-format config — boot directly from the legacy fields. + // TODO: remove after migration period + initFs = legacyInitFs + containerFs = runtimeConfig.containerRootFilesystem + kernel = legacyKernel } else { - containerFs = runtimeConfig.options?.rootFsOverride + throw ContainerizationError(.invalidState, message: "runtime configuration missing both runtimeData and legacy fields") } - let kernel = Kernel(path: URL(fileURLWithPath: linuxData.kernelPath), platform: .current) - _ = try ContainerResource.Bundle.create( path: runtimeConfig.path, initialFilesystem: initFs, diff --git a/Tests/ContainerAPIServiceTests/RuntimeConfigurationTests.swift b/Tests/ContainerAPIServiceTests/RuntimeConfigurationTests.swift index 3668826e7..2fc512866 100644 --- a/Tests/ContainerAPIServiceTests/RuntimeConfigurationTests.swift +++ b/Tests/ContainerAPIServiceTests/RuntimeConfigurationTests.swift @@ -21,14 +21,12 @@ import Containerization import Foundation import Testing -/// Unit tests for RuntimeConfiguration functionality. -/// -/// These tests verify the runtime configuration serialization and deserialization, -/// ensuring that configuration can be properly written, read, and used to create bundles. +/// Unit tests for RuntimeConfiguration and LinuxRuntimeData serialization, +/// covering the round-trip of the configuration format and the decoding of +/// legacy-format configurations the runtime still boots from directly. struct RuntimeConfigurationTests { - /// Test that reading non-existent runtime configuration file throws - /// appropriate error + /// Reading a non-existent runtime configuration file throws. @Test func testReadNonExistentRuntimeConfiguration() throws { let tempDir = FileManager.default.temporaryDirectory @@ -39,7 +37,7 @@ struct RuntimeConfigurationTests { } } - /// Test that runtime configuration reads and writes as expected + /// A RuntimeConfiguration reads, writes, and round-trips through disk. @Test func testRuntimeConfigurationReadWrite() throws { let tempDir = FileManager.default.temporaryDirectory @@ -49,29 +47,25 @@ struct RuntimeConfigurationTests { try? FileManager.default.removeItem(at: bundlePath) } - let linuxData = LinuxRuntimeData( + let runtimeData = try LinuxRuntimeData.encodeData( kernelPath: "/path/to/kernel", - initImageRef: "init:latest", - imageRef: "alpine:latest" - ) - let encodedData = try JSONEncoder().encode(linuxData) - - let runtimeConfig = RuntimeConfiguration( - path: bundlePath, - runtimeData: encodedData + initImageRef: "init:latest" ) + let runtimeConfig = RuntimeConfiguration(path: bundlePath, runtimeData: runtimeData) try runtimeConfig.writeRuntimeConfiguration() - let readConfig = try RuntimeConfiguration.readRuntimeConfiguration(from: bundlePath) + let readConfig = try RuntimeConfiguration.readRuntimeConfiguration(from: bundlePath) #expect(readConfig.path == bundlePath) #expect(readConfig.runtimeData != nil) #expect(readConfig.containerConfiguration == nil) #expect(readConfig.options == nil) + // A freshly-written configuration carries no legacy fields. #expect(readConfig.kernel == nil) #expect(readConfig.initialFilesystem == nil) } + /// The variant round-trips through RuntimeConfiguration's runtimeData blob. @Test func testRuntimeConfigurationWithVariant() throws { let tempDir = FileManager.default.temporaryDirectory @@ -81,146 +75,108 @@ struct RuntimeConfigurationTests { try? FileManager.default.removeItem(at: bundlePath) } - let initFs = Filesystem.virtiofs( - source: "/path/to/initfs", - destination: "/", - options: ["ro"] - ) - - let kernel = Kernel( - path: URL(fileURLWithPath: "/path/to/kernel"), - platform: .linuxArm - ) - - let linuxData = LinuxRuntimeData( - variant: "test-variant", + let runtimeData = try LinuxRuntimeData.encodeData( kernelPath: "/path/to/kernel", initImageRef: "init:latest", - imageRef: "ubuntu:22.04" - ) - let encodedData = try JSONEncoder().encode(linuxData) - - let runtimeConfig = RuntimeConfiguration( - path: bundlePath, - runtimeData: encodedData, - initialFilesystem: initFs, - kernel: kernel + variant: "test-variant" ) + let runtimeConfig = RuntimeConfiguration(path: bundlePath, runtimeData: runtimeData) try runtimeConfig.writeRuntimeConfiguration() - let readRuntimeConfig = try RuntimeConfiguration.readRuntimeConfiguration(from: bundlePath) - - #expect(readRuntimeConfig.runtimeData != nil, "runtimeData should be persisted") + let readConfig = try RuntimeConfiguration.readRuntimeConfiguration(from: bundlePath) + #expect(readConfig.runtimeData != nil, "runtimeData should be persisted") - let decodedData = try JSONDecoder().decode(LinuxRuntimeData.self, from: readRuntimeConfig.runtimeData!) - #expect(decodedData.variant == "test-variant", "Variant should round-trip through RuntimeConfiguration") + let decoded = try LinuxRuntimeData.decodeData(readConfig.runtimeData!) + #expect(decoded.variant == "test-variant", "variant should round-trip") } - /// Test that LinuxRuntimeData encodes and decodes correctly with all fields populated. + /// LinuxRuntimeData round-trips through Codable. @Test - func testLinuxRuntimeDataFullEncoding() throws { + func testLinuxRuntimeDataReferenceRoundTrip() throws { let original = LinuxRuntimeData( variant: "rosetta", kernelPath: "/usr/local/share/container/kernel", - initImageRef: "ghcr.io/apple/container-init:latest", - imageRef: "ubuntu:22.04" + initImageRef: "ghcr.io/apple/container-init:latest" ) let encoded = try JSONEncoder().encode(original) let decoded = try JSONDecoder().decode(LinuxRuntimeData.self, from: encoded) - #expect(decoded.variant == original.variant, "variant should round-trip") - #expect(decoded.kernelPath == original.kernelPath, "kernelPath should round-trip") - #expect(decoded.initImageRef == original.initImageRef, "initImageRef should round-trip") - #expect(decoded.imageRef == original.imageRef, "imageRef should round-trip") + #expect(decoded.variant == "rosetta") + #expect(decoded.kernelPath == "/usr/local/share/container/kernel") + #expect(decoded.initImageRef == "ghcr.io/apple/container-init:latest") } - /// Test that LinuxRuntimeData encodes and decodes correctly when optional fields are nil. + /// A RuntimeConfiguration with runtimeData round-trips end to end through disk. @Test - func testLinuxRuntimeDataNilContainerImageRef() throws { - let original = LinuxRuntimeData( - variant: nil, - kernelPath: "/usr/local/share/container/kernel", - initImageRef: "ghcr.io/apple/container-init:latest", - imageRef: nil - ) - - let encoded = try JSONEncoder().encode(original) - let decoded = try JSONDecoder().decode(LinuxRuntimeData.self, from: encoded) - - #expect(decoded.variant == nil, "variant should be nil") - #expect(decoded.kernelPath == original.kernelPath, "kernelPath should round-trip") - #expect(decoded.initImageRef == original.initImageRef, "initImageRef should round-trip") - #expect(decoded.imageRef == nil, "imageRef should be nil") - } - - /// Test that a new-format RuntimeConfiguration (runtimeData only, no legacy fields) round-trips correctly. - @Test - func testNewFormatRoundTrip() throws { + func testRuntimeConfigurationRoundTrip() throws { let tempDir = FileManager.default.temporaryDirectory - let bundlePath = tempDir.appendingPathComponent("test-new-format-\(UUID())") + let bundlePath = tempDir.appendingPathComponent("test-roundtrip-\(UUID())") defer { try? FileManager.default.removeItem(at: bundlePath) } - let linuxData = LinuxRuntimeData( - variant: "default", + let runtimeData = try LinuxRuntimeData.encodeData( kernelPath: "/path/to/kernel", initImageRef: "init:latest", - imageRef: "alpine:3.20" - ) - let runtimeData = try JSONEncoder().encode(linuxData) - - let config = RuntimeConfiguration( - path: bundlePath, - containerConfiguration: nil, - options: nil, - runtimeData: runtimeData + variant: "default" ) + let config = RuntimeConfiguration(path: bundlePath, runtimeData: runtimeData) try config.writeRuntimeConfiguration() - let read = try RuntimeConfiguration.readRuntimeConfiguration(from: bundlePath) + let read = try RuntimeConfiguration.readRuntimeConfiguration(from: bundlePath) #expect(read.runtimeData != nil) - #expect(read.kernel == nil, "New format should not have legacy kernel field") - #expect(read.initialFilesystem == nil, "New format should not have legacy filesystem field") + #expect(read.kernel == nil) + #expect(read.initialFilesystem == nil) - let decoded = try JSONDecoder().decode(LinuxRuntimeData.self, from: read.runtimeData!) + let decoded = try LinuxRuntimeData.decodeData(read.runtimeData!) #expect(decoded.kernelPath == "/path/to/kernel") - #expect(decoded.initImageRef == "init:latest") - #expect(decoded.imageRef == "alpine:3.20") #expect(decoded.variant == "default") + #expect(decoded.initImageRef == "init:latest") } - /// Test the rootFsOverride case where imageRef is nil. + /// A legacy-format configuration (legacy kernel/filesystem fields, no runtimeData) is + /// decodable, with legacy fields populated. The runtime boots directly from these fields. + /// + /// TODO: remove after migration period @Test - func testNewFormatWithNilContainerImageRef() throws { + func testLegacyConfigurationDecodes() throws { let tempDir = FileManager.default.temporaryDirectory - let bundlePath = tempDir.appendingPathComponent("test-nil-ref-\(UUID())") + let bundlePath = tempDir.appendingPathComponent("test-legacy-\(UUID())") defer { try? FileManager.default.removeItem(at: bundlePath) } - let linuxData = LinuxRuntimeData( - kernelPath: "/path/to/kernel", - initImageRef: "init:latest", - imageRef: nil - ) - let runtimeData = try JSONEncoder().encode(linuxData) + try FileManager.default.createDirectory(at: bundlePath, withIntermediateDirectories: true) - let config = RuntimeConfiguration( + // Produce accurate legacy-format JSON from real Kernel/Filesystem values, matching + // what a pre-conversion version wrote to disk. + let kernel = Kernel(path: URL(fileURLWithPath: "/usr/local/lib/container/kernel"), platform: .linuxArm) + let initFs = Filesystem.virtiofs(source: "/var/lib/container/snapshots/init123/snapshot", destination: "/", options: ["ro"]) + let legacy = LegacyRuntimeConfiguration( path: bundlePath, - runtimeData: runtimeData + kernel: kernel, + initialFilesystem: initFs ) + let configPath = bundlePath.appendingPathComponent("runtime-configuration.json") + try JSONEncoder().encode(legacy).write(to: configPath) - try config.writeRuntimeConfiguration() - let read = try RuntimeConfiguration.readRuntimeConfiguration(from: bundlePath) - - let decoded = try JSONDecoder().decode(LinuxRuntimeData.self, from: read.runtimeData!) - #expect(decoded.imageRef == nil) - #expect(decoded.initImageRef == "init:latest") + let config = try RuntimeConfiguration.readRuntimeConfiguration(from: bundlePath) + #expect(config.runtimeData == nil) + #expect(config.kernel?.path.path == "/usr/local/lib/container/kernel") + #expect(config.initialFilesystem?.source == "/var/lib/container/snapshots/init123/snapshot") } } + +/// Mirror of the legacy RuntimeConfiguration on-disk format, used by tests to produce +/// accurate legacy-format JSON via the real Kernel/Filesystem Codable conformances. +/// TODO: remove after migration period +private struct LegacyRuntimeConfiguration: Codable { + let path: URL + let kernel: Kernel + let initialFilesystem: Filesystem +}