From cf9b335aa1236f06c7bb30901c44024cababc2cc Mon Sep 17 00:00:00 2001 From: Manu Schiller <56154253+manuschillerdev@users.noreply.github.com> Date: Tue, 10 Feb 2026 03:11:27 +0100 Subject: [PATCH] feat: add --init-image flag for specifying custom init filesystem images per VM (#937) - Users can now specify an alternate image to use with the `container run ---init-image` flag. --- Makefile | 1 + .../Container/ContainerCreate.swift | 2 +- .../Container/ContainerRun.swift | 3 +- .../Client/ContainerClient.swift | 7 +- .../ContainerAPIService/Client/Flags.swift | 6 + .../ContainerAPIService/Client/Utility.swift | 7 +- .../ContainerAPIService/Client/XPC+.swift | 3 + .../Server/Containers/ContainersHarness.swift | 4 +- .../Server/Containers/ContainersService.swift | 14 +- .../Subcommands/Run/TestCLIRunInitImage.swift | 122 ++++++++++++++++++ docs/command-reference.md | 7 + docs/how-to.md | 76 +++++++++++ 12 files changed, 240 insertions(+), 12 deletions(-) create mode 100644 Tests/CLITests/Subcommands/Run/TestCLIRunInitImage.swift diff --git a/Makefile b/Makefile index 31816886..66c8425f 100644 --- a/Makefile +++ b/Makefile @@ -191,6 +191,7 @@ integration: init-block $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIStatsCommand || exit_code=1 ; \ $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIImagesCommand || exit_code=1 ; \ $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIRunBase || exit_code=1 ; \ + $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIRunInitImage || exit_code=1 ; \ $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIBuildBase || exit_code=1 ; \ $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIVolumes || exit_code=1 ; \ $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIKernelSet || exit_code=1 ; \ diff --git a/Sources/ContainerCommands/Container/ContainerCreate.swift b/Sources/ContainerCommands/Container/ContainerCreate.swift index d70170c6..ac26d205 100644 --- a/Sources/ContainerCommands/Container/ContainerCreate.swift +++ b/Sources/ContainerCommands/Container/ContainerCreate.swift @@ -84,7 +84,7 @@ extension Application { let options = ContainerCreateOptions(autoRemove: managementFlags.remove) let client = ContainerClient() - try await client.create(configuration: ck.0, options: options, kernel: ck.1) + try await client.create(configuration: ck.0, options: options, kernel: ck.1, initImage: ck.2) 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 1053b05c..c83fbf79 100644 --- a/Sources/ContainerCommands/Container/ContainerRun.swift +++ b/Sources/ContainerCommands/Container/ContainerRun.swift @@ -113,7 +113,8 @@ extension Application { try await client.create( configuration: ck.0, options: options, - kernel: ck.1 + kernel: ck.1, + initImage: ck.2 ) let detach = self.managementFlags.detach diff --git a/Sources/Services/ContainerAPIService/Client/ContainerClient.swift b/Sources/Services/ContainerAPIService/Client/ContainerClient.swift index 82105649..68d49be2 100644 --- a/Sources/Services/ContainerAPIService/Client/ContainerClient.swift +++ b/Sources/Services/ContainerAPIService/Client/ContainerClient.swift @@ -48,7 +48,8 @@ public struct ContainerClient: Sendable { public func create( configuration: ContainerConfiguration, options: ContainerCreateOptions = .default, - kernel: Kernel + kernel: Kernel, + initImage: String? = nil ) async throws { do { let request = XPCMessage(route: .containerCreate) @@ -60,6 +61,10 @@ public struct ContainerClient: Sendable { request.set(key: .kernel, value: kdata) request.set(key: .containerOptions, value: odata) + if let initImage { + request.set(key: .initImage, value: initImage) + } + try await xpcSend(message: request) } catch { throw ContainerizationError( diff --git a/Sources/Services/ContainerAPIService/Client/Flags.swift b/Sources/Services/ContainerAPIService/Client/Flags.swift index 86616a03..7d8f3062 100644 --- a/Sources/Services/ContainerAPIService/Client/Flags.swift +++ b/Sources/Services/ContainerAPIService/Client/Flags.swift @@ -158,6 +158,12 @@ public struct Flags { ) public var kernel: String? + @Option( + name: .long, + help: .init("Use a custom init image instead of the default", valueName: "image") + ) + public var initImage: String? + @Option(name: [.short, .customLong("label")], help: "Add a key=value label to the container") public var labels: [String] = [] diff --git a/Sources/Services/ContainerAPIService/Client/Utility.swift b/Sources/Services/ContainerAPIService/Client/Utility.swift index a57383aa..7ea19106 100644 --- a/Sources/Services/ContainerAPIService/Client/Utility.swift +++ b/Sources/Services/ContainerAPIService/Client/Utility.swift @@ -84,7 +84,7 @@ public struct Utility { imageFetch: Flags.ImageFetch, progressUpdate: @escaping ProgressUpdateHandler, log: Logger - ) async throws -> (ContainerConfiguration, Kernel) { + ) async throws -> (ContainerConfiguration, Kernel, String?) { var requestedPlatform = Parser.platform(os: management.os, arch: management.arch) // Prefer --platform if let platform = management.platform { @@ -129,8 +129,9 @@ public struct Utility { .setItemsName("blobs"), ]) let fetchInitTask = await taskManager.startTask() + let initImageRef = management.initImage ?? ClientImage.initImageRef let initImage = try await ClientImage.fetch( - reference: ClientImage.initImageRef, platform: .current, scheme: scheme, + reference: initImageRef, platform: .current, scheme: scheme, progressUpdate: ProgressTaskCoordinator.handler(for: fetchInitTask, from: progressUpdate), maxConcurrentDownloads: imageFetch.maxConcurrentDownloads) @@ -252,7 +253,7 @@ public struct Utility { config.runtimeHandler = runtime } - return (config, kernel) + return (config, kernel, management.initImage) } static func getAttachmentConfigurations( diff --git a/Sources/Services/ContainerAPIService/Client/XPC+.swift b/Sources/Services/ContainerAPIService/Client/XPC+.swift index ca838aad..49edbadf 100644 --- a/Sources/Services/ContainerAPIService/Client/XPC+.swift +++ b/Sources/Services/ContainerAPIService/Client/XPC+.swift @@ -106,6 +106,9 @@ 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 b96d86de..e5483872 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift @@ -187,7 +187,9 @@ public struct ContainersHarness: Sendable { let config = try JSONDecoder().decode(ContainerConfiguration.self, from: data) let kernel = try JSONDecoder().decode(Kernel.self, from: kdata) - try await service.create(configuration: config, kernel: kernel, options: options) + let initImage = message.string(key: .initImage) + + try await service.create(configuration: config, kernel: kernel, options: options, initImage: initImage) return message.reply() } diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift index c76e7d11..17eb2ba8 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift @@ -192,7 +192,7 @@ public actor ContainersService { } /// Create a new container from the provided id and configuration. - public func create(configuration: ContainerConfiguration, kernel: Kernel, options: ContainerCreateOptions) async throws { + public func create(configuration: ContainerConfiguration, kernel: Kernel, options: ContainerCreateOptions, initImage: String? = nil) async throws { self.log.debug("\(#function)") try await self.lock.withLock { context in @@ -233,11 +233,14 @@ public actor ContainersService { let path = self.containerRoot.appendingPathComponent(configuration.id) let systemPlatform = kernel.platform - let initFs = try await self.getInitBlock(for: systemPlatform.ociPlatform()) + + // Fetch init image (custom or default) + 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: initFs, + initialFilesystem: initFilesystem, kernel: kernel, containerConfiguration: configuration ) @@ -622,8 +625,9 @@ public actor ContainersService { return options } - private func getInitBlock(for platform: Platform) async throws -> Filesystem { - let initImage = try await ClientImage.fetch(reference: ClientImage.initImageRef, platform: platform) + private func getInitBlock(for platform: Platform, imageRef: String? = nil) async throws -> Filesystem { + let ref = imageRef ?? ClientImage.initImageRef + let initImage = try await ClientImage.fetch(reference: ref, platform: platform) var fs = try await initImage.getCreateSnapshot(platform: platform) fs.options = ["ro"] return fs diff --git a/Tests/CLITests/Subcommands/Run/TestCLIRunInitImage.swift b/Tests/CLITests/Subcommands/Run/TestCLIRunInitImage.swift new file mode 100644 index 00000000..4a234366 --- /dev/null +++ b/Tests/CLITests/Subcommands/Run/TestCLIRunInitImage.swift @@ -0,0 +1,122 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Testing + +/// Tests for the `--init-image` flag which allows specifying a custom init filesystem +/// image for microvms. This enables customizing boot-time behavior before the OCI +/// container starts. +/// +/// See: https://github.com/apple/container/discussions/838 +/// +/// Note: A full integration test that verifies custom init behavior would require +/// a pre-built test init image that writes a marker to /dev/kmsg. This can be added +/// once a test init image is published to the registry. +class TestCLIRunInitImage: CLITest { + private func getTestName() -> String { + Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased() + } + + /// Test that specifying a non-existent init-image fails with an appropriate error. + @Test func testRunWithNonExistentInitImage() throws { + let name = getTestName() + let nonExistentImage = "nonexistent.invalid/init-image:does-not-exist" + + #expect(throws: CLIError.self, "expected container run with non-existent init-image to fail") { + let (_, _, error, status) = try run(arguments: [ + "run", + "--rm", + "--name", name, + "-d", + "--init-image", nonExistentImage, + alpine, + "sleep", "infinity", + ]) + defer { try? doRemove(name: name, force: true) } + if status != 0 { + throw CLIError.executionFailed("command failed: \(error)") + } + } + } + + /// Test that the `--init-image` flag is recognized and documented in CLI help. + @Test func testInitImageFlagInHelp() throws { + let (_, output, _, status) = try run(arguments: ["run", "--help"]) + #expect(status == 0, "expected help command to succeed") + #expect( + output.contains("--init-image"), + "expected help output to contain --init-image flag" + ) + #expect( + output.contains("custom init image"), + "expected help output to describe the init-image flag" + ) + } + + /// Test that the `--init-image` flag works with `container create` command. + @Test func testCreateWithNonExistentInitImage() throws { + let name = getTestName() + let nonExistentImage = "nonexistent.invalid/init-image:does-not-exist" + + #expect(throws: CLIError.self, "expected container create with non-existent init-image to fail") { + let (_, _, error, status) = try run(arguments: [ + "create", + "--rm", + "--name", name, + "--init-image", nonExistentImage, + alpine, + "echo", "hello", + ]) + defer { try? doRemove(name: name, force: true) } + if status != 0 { + throw CLIError.executionFailed("command failed: \(error)") + } + } + } + + /// Test that explicitly specifying the default init image works the same as + /// not specifying any init image. + @Test func testRunWithExplicitDefaultInitImage() throws { + let name = getTestName() + + // Get the default init image reference + let (_, defaultInitImage, _, propStatus) = try run(arguments: [ + "system", "property", "get", "image.init", + ]) + + guard propStatus == 0 else { + print("Skipping testRunWithExplicitDefaultInitImage: could not get default init image") + return + } + + let initImage = defaultInitImage.trimmingCharacters(in: .whitespacesAndNewlines) + + // Run container with explicit default init image + try doLongRun(name: name, args: ["--init-image", initImage]) + defer { + try? doStop(name: name) + } + + // Verify container is running and functional + try waitForContainerRunning(name) + let output = try doExec(name: name, cmd: ["echo", "hello"]) + #expect( + output.trimmingCharacters(in: .whitespacesAndNewlines) == "hello", + "expected 'hello' output from exec, got '\(output)'" + ) + } +} diff --git a/docs/command-reference.md b/docs/command-reference.md index 507971a3..ab8b5f61 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -50,6 +50,7 @@ container run [] [ ...] * `--dns-option