diff --git a/Package.swift b/Package.swift index cd0cfa93..2c798095 100644 --- a/Package.swift +++ b/Package.swift @@ -338,6 +338,7 @@ let package = Package( dependencies: [ .product(name: "Containerization", package: "containerization"), .product(name: "ContainerizationExtras", package: "containerization"), + "ContainerAPIService", "ContainerResource", ] ), @@ -370,6 +371,14 @@ let package = Package( "ContainerPlugin" ] ), + .testTarget( + name: "ContainerSandboxServiceTests", + dependencies: [ + .product(name: "Containerization", package: "containerization"), + "ContainerResource", + "ContainerSandboxServiceClient", + ] + ), .target( name: "ContainerXPC", dependencies: [ diff --git a/Sources/ContainerResource/Container/Bundle.swift b/Sources/ContainerResource/Container/Bundle.swift index 999b13da..93c8066d 100644 --- a/Sources/ContainerResource/Container/Bundle.swift +++ b/Sources/ContainerResource/Container/Bundle.swift @@ -82,7 +82,9 @@ extension Bundle { path: URL, initialFilesystem: Filesystem, kernel: Kernel, - containerConfiguration: ContainerConfiguration? = nil + containerConfiguration: ContainerConfiguration? = nil, + containerRootFilesystem: Filesystem? = nil, + options: ContainerCreateOptions? = nil ) throws -> Bundle { try FileManager.default.createDirectory(at: path, withIntermediateDirectories: true) let kbin = path.appendingPathComponent(Self.kernelBinaryFilename) @@ -107,6 +109,15 @@ extension Bundle { if let containerConfiguration { try bundle.write(filename: Self.containerConfigFilename, value: containerConfiguration) } + + if let containerRootFilesystem { + let readonly = containerConfiguration?.readOnly ?? false + try bundle.setContainerRootFs(cloning: containerRootFilesystem, readonly: readonly) + } + + if let options { + try bundle.write(filename: "options.json", value: options) + } return bundle } } diff --git a/Sources/Helpers/RuntimeLinux/RuntimeLinuxHelper+Start.swift b/Sources/Helpers/RuntimeLinux/RuntimeLinuxHelper+Start.swift index 55ac5420..6c4a2676 100644 --- a/Sources/Helpers/RuntimeLinux/RuntimeLinuxHelper+Start.swift +++ b/Sources/Helpers/RuntimeLinux/RuntimeLinuxHelper+Start.swift @@ -69,6 +69,7 @@ extension RuntimeLinuxHelper { } nonisolated(unsafe) let anonymousConnection = xpc_connection_create(nil, nil) + let server = SandboxService( root: .init(fileURLWithPath: root), interfaceStrategy: interfaceStrategy, diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift index 8a2009f3..f19288b2 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift @@ -81,8 +81,8 @@ public actor ContainersService { var results = [String: ContainerState]() for dir in directories { do { - let bundle = ContainerResource.Bundle(path: dir) - let config = try bundle.configuration + let config = try Self.getContainerConfiguration(at: dir) + let state = ContainerState( snapshot: .init( configuration: config, @@ -100,7 +100,7 @@ public actor ContainersService { } } catch { try? FileManager.default.removeItem(at: dir) - log.warning("failed to load container bundle at \(dir.path)") + log.warning("failed to load container at \(dir.path): \(error)") } } return results @@ -261,17 +261,20 @@ public actor ContainersService { self.log.info("Using init image: \(initImage ?? ClientImage.initImageRef)") let initFilesystem = try await self.getInitBlock(for: systemPlatform.ociPlatform(), imageRef: initImage) - let bundle = try ContainerResource.Bundle.create( - path: path, - initialFilesystem: initFilesystem, - kernel: kernel, - containerConfiguration: configuration - ) do { let containerImage = ClientImage(description: configuration.image) let imageFs = try await containerImage.getCreateSnapshot(platform: configuration.platform) - try bundle.setContainerRootFs(cloning: imageFs, readonly: configuration.readOnly) - try bundle.write(filename: "options.json", value: options) + + let runtimeConfig = RuntimeConfiguration( + path: path, + initialFilesystem: initFilesystem, + kernel: kernel, + containerConfiguration: configuration, + containerRootFilesystem: imageFs, + options: options + ) + + try runtimeConfig.writeRuntimeConfiguration() let snapshot = ContainerSnapshot( configuration: configuration, @@ -281,11 +284,6 @@ public actor ContainersService { ) await self.setContainerState(configuration.id, ContainerState(snapshot: snapshot), context: context) } catch { - do { - try bundle.delete() - } catch { - self.log.error("failed to delete bundle for container \(configuration.id): \(error)") - } throw error } } @@ -305,8 +303,7 @@ public actor ContainersService { } let path = self.containerRoot.appendingPathComponent(id) - let bundle = ContainerResource.Bundle(path: path) - let config = try bundle.configuration + let config = try Self.getContainerConfiguration(at: path) do { try Self.registerService( @@ -321,6 +318,7 @@ public actor ContainersService { id: id, runtime: runtime ) + try await sandboxClient.bootstrap(stdio: stdio) try await self.exitMonitor.registerProcess( @@ -625,15 +623,35 @@ public actor ContainersService { // the OCI runtime. await self.exitMonitor.stopTracking(id: id) let path = self.containerRoot.appendingPathComponent(id) - let bundle = ContainerResource.Bundle(path: path) - let config = try bundle.configuration - let label = Self.fullLaunchdServiceLabel( - runtimeName: config.runtimeHandler, - instanceId: id - ) - try ServiceManager.deregister(fullServiceLabel: label) - try bundle.delete() + // Try to get config for service deregistration + // Don't fail if bundle is incomplete + var config: ContainerConfiguration? + let bundle = ContainerResource.Bundle(path: path) + do { + config = try bundle.configuration + } catch { + self.log.warning("Unable to read bundle configuration during cleanup for container \(id): \(error)") + } + + // Only try to deregister service if we have a valid config + // TODO: Change this so we don't have to reread the config + // possibly store the container ID to service label mapping + if let config = config { + let label = Self.fullLaunchdServiceLabel( + runtimeName: config.runtimeHandler, + instanceId: id + ) + try? ServiceManager.deregister(fullServiceLabel: label) + } + + // Always try to delete the bundle directory, even if it's incomplete + do { + try bundle.delete() + } catch { + self.log.warning("Failed to delete bundle for container \(id): \(error)") + } + self.containers.removeValue(forKey: id) } @@ -698,6 +716,22 @@ public actor ContainersService { private static func isInitProcess(id: String, processID: String) -> Bool { id == processID } + + /// Get container configuration, either from existing bundle or from RuntimeConfiguration + private static func getContainerConfiguration(at path: URL) throws -> ContainerConfiguration { + let bundle = ContainerResource.Bundle(path: path) + do { + return try bundle.configuration + } catch { + // Bundle doesn't exist or incomplete, try runtime configuration + // This handles containers that were created but not started yet + let runtimeConfig = try RuntimeConfiguration.readRuntimeConfiguration(from: path) + guard let config = runtimeConfig.containerConfiguration else { + throw ContainerizationError(.internalError, message: "runtime configuration missing container configuration") + } + return config + } + } } extension XPCMessage { diff --git a/Sources/Services/ContainerSandboxService/Client/SandboxRuntimeConfiguration.swift b/Sources/Services/ContainerSandboxService/Client/SandboxRuntimeConfiguration.swift new file mode 100644 index 00000000..66a04d86 --- /dev/null +++ b/Sources/Services/ContainerSandboxService/Client/SandboxRuntimeConfiguration.swift @@ -0,0 +1,73 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ContainerResource +import Containerization +import ContainerizationError +import Foundation + +public struct RuntimeConfiguration: Codable, Sendable { + static let runtimeConfigurationFilename = "runtime-configuration.json" + + public let path: URL + public let initialFilesystem: Filesystem + public let kernel: Kernel + public let containerConfiguration: ContainerConfiguration? + public let containerRootFilesystem: Filesystem? + public let options: ContainerCreateOptions? + + public init( + path: URL, + initialFilesystem: Filesystem, + kernel: Kernel, + containerConfiguration: ContainerConfiguration? = nil, + containerRootFilesystem: Filesystem? = nil, + options: ContainerCreateOptions? = nil + ) { + self.path = path + self.initialFilesystem = initialFilesystem + self.kernel = kernel + self.containerConfiguration = containerConfiguration + self.containerRootFilesystem = containerRootFilesystem + self.options = options + } + + public var runtimeConfigurationPath: URL { + self.path.appendingPathComponent(Self.runtimeConfigurationFilename) + } + + public func writeRuntimeConfiguration() throws { + // Ensure the parent directory exists + let directory = self.runtimeConfigurationPath.deletingLastPathComponent() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + + let data = try JSONEncoder().encode(self) + try data.write(to: self.runtimeConfigurationPath) + } + + public static func readRuntimeConfiguration(from runtimeConfigurationPath: URL) throws -> RuntimeConfiguration { + let configurationPath = runtimeConfigurationPath.appendingPathComponent(RuntimeConfiguration.runtimeConfigurationFilename) + guard FileManager.default.fileExists(atPath: configurationPath.path) else { + throw ContainerizationError( + .notFound, + message: "runtime configuration file not found at path: \(configurationPath.path)" + ) + } + + let data = try Data(contentsOf: configurationPath) + return try JSONDecoder().decode(RuntimeConfiguration.self, from: data) + } +} diff --git a/Sources/Services/ContainerSandboxService/Server/SandboxService.swift b/Sources/Services/ContainerSandboxService/Server/SandboxService.swift index 3cc64569..70ef364a 100644 --- a/Sources/Services/ContainerSandboxService/Server/SandboxService.swift +++ b/Sources/Services/ContainerSandboxService/Server/SandboxService.swift @@ -60,13 +60,6 @@ public actor SandboxService { return nil } - /// Create an instance with a bundle that describes the container. - /// - /// - Parameters: - /// - root: The file URL for the bundle root. - /// - interfaceStrategy: The strategy for producing network interface - /// objects for each network to which the container attaches. - /// - log: The destination for log messages. public init( root: URL, interfaceStrategy: InterfaceStrategy, @@ -108,6 +101,12 @@ public actor SandboxService { @Sendable public func bootstrap(_ message: XPCMessage) async throws -> XPCMessage { self.log.info("`bootstrap` xpc handler") + + // Create the bundle if it doesn't exist yet + if !self.bundleExists(at: self.root) { + try self.createBundle() + } + return try await self.lock.withLock { _ in guard await self.state == .created else { throw ContainerizationError( @@ -1225,7 +1224,7 @@ extension FileHandle: @retroactive ReaderStream, @retroactive Writer { } } -// MARK: State handler helpers +// MARK: State handler and bundle creation helpers extension SandboxService { private func addWaiter(id: String, cont: CheckedContinuation) { @@ -1300,4 +1299,38 @@ extension SandboxService { func setState(_ new: State) { self.state = new } + + /// Check if a bundle exists at the given path + private func bundleExists(at path: URL) -> Bool { + guard FileManager.default.fileExists(atPath: path.path) else { + return false + } + + let bundle = ContainerResource.Bundle(path: path) + do { + _ = try bundle.configuration + return true + } catch { + return false + } + } + + /// Create bundle from RuntimeConfiguration + private func createBundle() throws { + do { + let runtimeConfig = try RuntimeConfiguration.readRuntimeConfiguration(from: self.root) + _ = try ContainerResource.Bundle.create( + path: runtimeConfig.path, + initialFilesystem: runtimeConfig.initialFilesystem, + kernel: runtimeConfig.kernel, + containerConfiguration: runtimeConfig.containerConfiguration, + containerRootFilesystem: runtimeConfig.containerRootFilesystem, + options: runtimeConfig.options + ) + self.log.info("Created bundle from runtime configuration at \(runtimeConfig.path)") + } catch { + self.log.error("Failed to create bundle \(error)") + throw error + } + } } diff --git a/Tests/ContainerSandboxServiceTests/RuntimeConfigurationTests.swift b/Tests/ContainerSandboxServiceTests/RuntimeConfigurationTests.swift new file mode 100644 index 00000000..c54c8a79 --- /dev/null +++ b/Tests/ContainerSandboxServiceTests/RuntimeConfigurationTests.swift @@ -0,0 +1,100 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +// import ContainerAPIService +import ContainerResource +import ContainerSandboxServiceClient +import Containerization +// import ContainerizationOCI +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. +struct RuntimeConfigurationTests { + + /// Test that reading non-existent runtime configuration file throws + /// appropriate error + @Test + func testReadNonExistentRuntimeConfiguration() throws { + let tempDir = FileManager.default.temporaryDirectory + let nonExistentPath = tempDir.appendingPathComponent("non-existent-\(UUID()).json") + + #expect(throws: Error.self) { + _ = try RuntimeConfiguration.readRuntimeConfiguration(from: nonExistentPath) + } + } + + /// Test that runtime configuration reads and writes as expected + @Test + func testRuntimeConfigurationReadWrite() throws { + let tempDir = FileManager.default.temporaryDirectory + let bundlePath = tempDir.appendingPathComponent("test-bundle-\(UUID())") + + defer { + 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 + ) + + try runtimeConfig.writeRuntimeConfiguration() + + defer { + try? FileManager.default.removeItem(at: runtimeConfig.runtimeConfigurationPath) + } + + 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") + } +}