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 cf1d4e1c0..00d908b8c 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,39 @@ extension Application { options: dnsOptions ) - let kernel = try await { - await progressUpdate([ - .setDescription("Fetching kernel"), - .setItemsName("binary"), - ]) + await progressUpdate([ + .setDescription("Fetching kernel"), + .setItemsName("binary"), + ]) + let kernel = try await ClientKernel.getDefaultKernel(for: .current) - let kernel = try await ClientKernel.getDefaultKernel(for: .current) - return kernel - }() + // 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: initImage.reference + ) + 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..535a0fe27 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,21 @@ 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 + ) + 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..f8632d959 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,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 + ) + 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/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/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..6e72272a2 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,7 @@ public struct Utility { config.runtimeHandler = runtime } - return (config, kernel, management.initImage) + return (config, kernel, initImageRef) } static func getAttachmentConfigurations( 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..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) } @@ -264,7 +260,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 +307,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 +330,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) - - 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() + 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 +1039,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, 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 29507bb70..7a4cf264f 100644 --- a/Sources/Services/Runtime/RuntimeClient/RuntimeConfiguration.swift +++ b/Sources/Services/Runtime/RuntimeClient/RuntimeConfiguration.swift @@ -23,31 +23,30 @@ 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? + // 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? + public init( path: URL, - initialFilesystem: Filesystem, - kernel: Kernel, containerConfiguration: ContainerConfiguration? = nil, - containerRootFilesystem: Filesystem? = nil, options: ContainerCreateOptions? = nil, runtimeData: Data? = nil ) { self.path = path - self.initialFilesystem = initialFilesystem - self.kernel = kernel self.containerConfiguration = containerConfiguration - self.containerRootFilesystem = containerRootFilesystem self.options = options self.runtimeData = runtimeData + self.initialFilesystem = nil + self.kernel = nil + self.containerRootFilesystem = nil } public var runtimeConfigurationPath: URL { @@ -55,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 d30185a11..e276b15ae 100644 --- a/Sources/Services/RuntimeLinux/Client/LinuxRuntimeData.swift +++ b/Sources/Services/RuntimeLinux/Client/LinuxRuntimeData.swift @@ -19,9 +19,39 @@ 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 the local image store at bootstrap. + public let initImageRef: String - public init(variant: String? = nil) { + public init( + variant: String? = nil, + kernelPath: String, + initImageRef: String + ) { self.variant = variant + self.kernelPath = kernelPath + self.initImageRef = initImageRef + } + + /// Encode Linux-specific runtime data into an opaque blob to pass through the API server. + public static func encodeData( + kernelPath: String, + initImageRef: String, + variant: String? = nil + ) throws -> Data { + let data = LinuxRuntimeData( + variant: variant, + kernelPath: kernelPath, + initImageRef: initImageRef + ) + 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..78746b457 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,57 @@ extension RuntimeService { } /// Create bundle from RuntimeConfiguration - private func createBundle() throws { + private func createBundle() async throws { do { let runtimeConfig = try RuntimeConfiguration.readRuntimeConfiguration(from: self.root) + let initPlatform = SystemPlatform.current.ociPlatform() + let containerPlatform = runtimeConfig.containerConfiguration?.platform ?? initPlatform + + let initFs: Filesystem + let containerFs: Filesystem? + 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 + } + + 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 { + throw ContainerizationError(.invalidState, message: "runtime configuration missing both runtimeData and legacy fields") + } + _ = 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..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,50 +47,25 @@ 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 runtimeConfig = RuntimeConfiguration( - path: bundlePath, - initialFilesystem: initFs, - kernel: kernel, - containerConfiguration: nil, - containerRootFilesystem: nil, - options: nil + let runtimeData = try LinuxRuntimeData.encodeData( + kernelPath: "/path/to/kernel", + initImageRef: "init:latest" ) + let runtimeConfig = RuntimeConfiguration(path: bundlePath, runtimeData: runtimeData) 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) + // 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 @@ -102,34 +75,108 @@ struct RuntimeConfigurationTests { try? FileManager.default.removeItem(at: bundlePath) } - let initFs = Filesystem.virtiofs( - source: "/path/to/initfs", - destination: "/", - options: ["ro"] + let runtimeData = try LinuxRuntimeData.encodeData( + kernelPath: "/path/to/kernel", + initImageRef: "init:latest", + variant: "test-variant" ) - let kernel = Kernel( - path: URL(fileURLWithPath: "/path/to/kernel"), - platform: .linuxArm + let runtimeConfig = RuntimeConfiguration(path: bundlePath, runtimeData: runtimeData) + try runtimeConfig.writeRuntimeConfiguration() + + let readConfig = try RuntimeConfiguration.readRuntimeConfiguration(from: bundlePath) + #expect(readConfig.runtimeData != nil, "runtimeData should be persisted") + + let decoded = try LinuxRuntimeData.decodeData(readConfig.runtimeData!) + #expect(decoded.variant == "test-variant", "variant should round-trip") + } + + /// LinuxRuntimeData round-trips through Codable. + @Test + func testLinuxRuntimeDataReferenceRoundTrip() throws { + let original = LinuxRuntimeData( + variant: "rosetta", + kernelPath: "/usr/local/share/container/kernel", + initImageRef: "ghcr.io/apple/container-init:latest" ) - let linuxData = LinuxRuntimeData(variant: "test-variant") - let encodedData = try JSONEncoder().encode(linuxData) + let encoded = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(LinuxRuntimeData.self, from: encoded) - let runtimeConfig = RuntimeConfiguration( - path: bundlePath, - initialFilesystem: initFs, - kernel: kernel, - runtimeData: encodedData + #expect(decoded.variant == "rosetta") + #expect(decoded.kernelPath == "/usr/local/share/container/kernel") + #expect(decoded.initImageRef == "ghcr.io/apple/container-init:latest") + } + + /// A RuntimeConfiguration with runtimeData round-trips end to end through disk. + @Test + func testRuntimeConfigurationRoundTrip() throws { + let tempDir = FileManager.default.temporaryDirectory + let bundlePath = tempDir.appendingPathComponent("test-roundtrip-\(UUID())") + + defer { + try? FileManager.default.removeItem(at: bundlePath) + } + + let runtimeData = try LinuxRuntimeData.encodeData( + kernelPath: "/path/to/kernel", + initImageRef: "init:latest", + variant: "default" ) - try runtimeConfig.writeRuntimeConfiguration() + let config = RuntimeConfiguration(path: bundlePath, runtimeData: runtimeData) + try config.writeRuntimeConfiguration() + + let read = try RuntimeConfiguration.readRuntimeConfiguration(from: bundlePath) + #expect(read.runtimeData != nil) + #expect(read.kernel == nil) + #expect(read.initialFilesystem == nil) - let readRuntimeConfig = try RuntimeConfiguration.readRuntimeConfiguration(from: bundlePath) + let decoded = try LinuxRuntimeData.decodeData(read.runtimeData!) + #expect(decoded.kernelPath == "/path/to/kernel") + #expect(decoded.variant == "default") + #expect(decoded.initImageRef == "init:latest") + } - #expect(readRuntimeConfig.runtimeData != nil, "runtimeData should be persisted") + /// 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 testLegacyConfigurationDecodes() throws { + let tempDir = FileManager.default.temporaryDirectory + let bundlePath = tempDir.appendingPathComponent("test-legacy-\(UUID())") - let decodedData = try JSONDecoder().decode(LinuxRuntimeData.self, from: readRuntimeConfig.runtimeData!) - #expect(decodedData.variant == "test-variant", "Variant should round-trip through RuntimeConfiguration") + defer { + try? FileManager.default.removeItem(at: bundlePath) + } + + try FileManager.default.createDirectory(at: bundlePath, withIntermediateDirectories: true) + + // 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, + kernel: kernel, + initialFilesystem: initFs + ) + let configPath = bundlePath.appendingPathComponent("runtime-configuration.json") + try JSONEncoder().encode(legacy).write(to: configPath) + + 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 +}