mirror of
https://github.com/apple/container.git
synced 2026-08-24 10:05:43 -05:00
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:
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user