feat: Moving bundle creation from ContainerService to SandboxService (#1076)

## Type of Change
- [ ] Bug fix
- [x] New feature  
- [ ] Breaking change
- [ ] Documentation update

## Motivation and Context
Closes #1046 -- Right now we're creating container bundles in
ContainersService. Move this to the SandboxService to make it easier to
support different container bundle types.

## Testing
- [x] Tested locally
- [x] Added/updated tests
- [ ] Added/updated docs
This commit is contained in:
AJ Emory
2026-02-13 19:27:32 -08:00
committed by GitHub
parent 4c800db3fd
commit 7476743cc2
7 changed files with 296 additions and 35 deletions
+9
View File
@@ -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: [
@@ -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
}
}
@@ -69,6 +69,7 @@ extension RuntimeLinuxHelper {
}
nonisolated(unsafe) let anonymousConnection = xpc_connection_create(nil, nil)
let server = SandboxService(
root: .init(fileURLWithPath: root),
interfaceStrategy: interfaceStrategy,
@@ -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 {
@@ -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)
}
}
@@ -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<ExitStatus, Never>) {
@@ -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
}
}
}
@@ -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")
}
}