diff --git a/Makefile b/Makefile index ff96d854..2dce736c 100644 --- a/Makefile +++ b/Makefile @@ -185,6 +185,7 @@ integration: init-block $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIExecCommand || exit_code=1 ; \ $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLICreateCommand || exit_code=1 ; \ $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIRunCommand || exit_code=1 ; \ + $(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIRunInitImage || exit_code=1 ; \ $(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 ; \ diff --git a/Sources/ContainerCommands/Container/ContainerCreate.swift b/Sources/ContainerCommands/Container/ContainerCreate.swift index e084e875..1cc15893 100644 --- a/Sources/ContainerCommands/Container/ContainerCreate.swift +++ b/Sources/ContainerCommands/Container/ContainerCreate.swift @@ -82,7 +82,7 @@ extension Application { ) let options = ContainerCreateOptions(autoRemove: managementFlags.remove) - let container = try await ClientContainer.create(configuration: ck.0, options: options, kernel: ck.1) + let container = try await ClientContainer.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 31de0b24..91896e40 100644 --- a/Sources/ContainerCommands/Container/ContainerRun.swift +++ b/Sources/ContainerCommands/Container/ContainerRun.swift @@ -111,7 +111,8 @@ extension Application { let container = try await ClientContainer.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/ClientContainer.swift b/Sources/Services/ContainerAPIService/Client/ClientContainer.swift index 0eb3dc6e..8447dabf 100644 --- a/Sources/Services/ContainerAPIService/Client/ClientContainer.swift +++ b/Sources/Services/ContainerAPIService/Client/ClientContainer.swift @@ -78,7 +78,8 @@ extension ClientContainer { public static func create( configuration: ContainerConfiguration, options: ContainerCreateOptions = .default, - kernel: Kernel + kernel: Kernel, + initImage: String? = nil ) async throws -> ClientContainer { do { let client = Self.newXPCClient() @@ -91,6 +92,10 @@ extension ClientContainer { request.set(key: .kernel, value: kdata) request.set(key: .containerOptions, value: odata) + if let initImage { + request.set(key: .initImage, value: initImage) + } + try await xpcSend(client: client, message: request) return ClientContainer(configuration: configuration) } catch { diff --git a/Sources/Services/ContainerAPIService/Client/Flags.swift b/Sources/Services/ContainerAPIService/Client/Flags.swift index eb9721a3..f958b28c 100644 --- a/Sources/Services/ContainerAPIService/Client/Flags.swift +++ b/Sources/Services/ContainerAPIService/Client/Flags.swift @@ -142,6 +142,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 5cb85cab..8f997d4e 100644 --- a/Sources/Services/ContainerAPIService/Client/Utility.swift +++ b/Sources/Services/ContainerAPIService/Client/Utility.swift @@ -82,7 +82,7 @@ public struct Utility { registry: Flags.Registry, imageFetch: Flags.ImageFetch, progressUpdate: @escaping ProgressUpdateHandler - ) 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 { @@ -241,7 +241,7 @@ public struct Utility { config.ssh = management.ssh config.readOnly = management.readOnly - return (config, kernel) + return (config, kernel, management.initImage) } static func getAttachmentConfigurations(containerId: String, networks: [Parser.ParsedNetwork]) throws -> [AttachmentConfiguration] { diff --git a/Sources/Services/ContainerAPIService/Client/XPC+.swift b/Sources/Services/ContainerAPIService/Client/XPC+.swift index 6a196ffc..26486738 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 7aa5f96d..187d6eb6 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 033166d9..0a6b4953 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 ) @@ -602,8 +605,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..56661f0d --- /dev/null +++ b/Tests/CLITests/Subcommands/Run/TestCLIRunInitImage.swift @@ -0,0 +1,122 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025-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 2b105bf7..f2ee6229 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -50,6 +50,7 @@ container run [] [ ...] * `--dns-option