Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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 ; \
Expand Down
2 changes: 1 addition & 1 deletion Sources/ContainerCommands/Container/ContainerCreate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion Sources/ContainerCommands/Container/ContainerRun.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions Sources/Services/ContainerAPIService/Client/Flags.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []

Expand Down
4 changes: 2 additions & 2 deletions Sources/Services/ContainerAPIService/Client/Utility.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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] {
Expand Down
3 changes: 3 additions & 0 deletions Sources/Services/ContainerAPIService/Client/XPC+.swift
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ public enum XPCKeys: String {
case systemPlatform
case kernelForce

/// Init image reference
case initImage

/// Volume
case volume
case volumes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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
Expand Down
122 changes: 122 additions & 0 deletions Tests/CLITests/Subcommands/Run/TestCLIRunInitImage.swift
Original file line number Diff line number Diff line change
@@ -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)'"
)
}
}
7 changes: 7 additions & 0 deletions docs/command-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ container run [<options>] <image> [<arguments> ...]
* `--dns-option <option>`: DNS options
* `--dns-search <domain>`: DNS search domains
* `--entrypoint <cmd>`: Override the entrypoint of the image
* `--init-image <image>`: Use a custom init image instead of the default. This allows customizing boot-time behavior before the OCI container starts, such as running VM-level daemons, configuring eBPF filters, or debugging the init process.
* `-k, --kernel <path>`: Set a custom kernel path
* `-l, --label <label>`: Add a key=value label to the container
* `--mount <mount>`: Add a mount to the container (format: type=<>,source=<>,target=<>,readonly)
Expand All @@ -61,6 +62,7 @@ container run [<options>] <image> [<arguments> ...]
* `--platform <platform>`: Platform for the image if it's multi-platform. This takes precedence over --os and --arch
* `--publish-socket <spec>`: Publish a socket from container to host (format: host_path:container_path)
* `--rm, --remove`: Remove the container after it stops
* `--rosetta`: Enable Rosetta in the container
* `--ssh`: Forward SSH agent socket to container
* `--tmpfs <tmpfs>`: Add a tmpfs mount to the container at the given path
* `-v, --volume <volume>`: Bind mount a volume into the container
Expand Down Expand Up @@ -100,6 +102,9 @@ container run -e NODE_ENV=production --cpus 2 --memory 1G node:18

# run a container with a specific MAC address
container run --network default,mac=02:42:ac:11:00:02 ubuntu:latest

# run a container with a custom init image for boot customization
container run --init-image local/custom-init:latest ubuntu:latest
```

### `container build`
Expand Down Expand Up @@ -198,6 +203,7 @@ container create [<options>] <image> [<arguments> ...]
* `--dns-option <option>`: DNS options
* `--dns-search <domain>`: DNS search domains
* `--entrypoint <cmd>`: Override the entrypoint of the image
* `--init-image <image>`: Use a custom init image instead of the default. This allows customizing boot-time behavior before the OCI container starts, such as running VM-level daemons, configuring eBPF filters, or debugging the init process.
* `-k, --kernel <path>`: Set a custom kernel path
* `-l, --label <label>`: Add a key=value label to the container
* `--mount <mount>`: Add a mount to the container (format: type=<>,source=<>,target=<>,readonly)
Expand All @@ -209,6 +215,7 @@ container create [<options>] <image> [<arguments> ...]
* `--platform <platform>`: Platform for the image if it's multi-platform. This takes precedence over --os and --arch
* `--publish-socket <spec>`: Publish a socket from container to host (format: host_path:container_path)
* `--rm, --remove`: Remove the container after it stops
* `--rosetta`: Enable Rosetta in the container
* `--ssh`: Forward SSH agent socket to container
* `--tmpfs <tmpfs>`: Add a tmpfs mount to the container at the given path
* `-v, --volume <volume>`: Bind mount a volume into the container
Expand Down