Skip to content

Commit 6b5e2f3

Browse files
feat: add init-image flag to allow specifying custom init filesystem images per VM
1 parent 474906d commit 6b5e2f3

12 files changed

Lines changed: 238 additions & 11 deletions

File tree

Makefile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,7 @@ integration: init-block
191191
$(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIStatsCommand || exit_code=1 ; \
192192
$(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIImagesCommand || exit_code=1 ; \
193193
$(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIRunBase || exit_code=1 ; \
194+
$(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIRunInitImage || exit_code=1 ; \
194195
$(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIBuildBase || exit_code=1 ; \
195196
$(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIVolumes || exit_code=1 ; \
196197
$(SWIFT) test -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter TestCLIKernelSet || exit_code=1 ; \

Sources/ContainerCommands/Container/ContainerCreate.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ extension Application {
8484

8585
let options = ContainerCreateOptions(autoRemove: managementFlags.remove)
8686
let client = ContainerClient()
87-
try await client.create(configuration: ck.0, options: options, kernel: ck.1)
87+
try await client.create(configuration: ck.0, options: options, kernel: ck.1, initImage: ck.2)
8888

8989
if !self.managementFlags.cidfile.isEmpty {
9090
let path = self.managementFlags.cidfile

Sources/ContainerCommands/Container/ContainerRun.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,8 @@ extension Application {
113113
try await client.create(
114114
configuration: ck.0,
115115
options: options,
116-
kernel: ck.1
116+
kernel: ck.1,
117+
initImage: ck.2
117118
)
118119

119120
let detach = self.managementFlags.detach

Sources/Services/ContainerAPIService/Client/ContainerClient.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ public struct ContainerClient: Sendable {
4848
public func create(
4949
configuration: ContainerConfiguration,
5050
options: ContainerCreateOptions = .default,
51-
kernel: Kernel
51+
kernel: Kernel,
52+
initImage: String? = nil
5253
) async throws {
5354
do {
5455
let request = XPCMessage(route: .containerCreate)
@@ -60,6 +61,10 @@ public struct ContainerClient: Sendable {
6061
request.set(key: .kernel, value: kdata)
6162
request.set(key: .containerOptions, value: odata)
6263

64+
if let initImage {
65+
request.set(key: .initImage, value: initImage)
66+
}
67+
6368
try await xpcSend(message: request)
6469
} catch {
6570
throw ContainerizationError(

Sources/Services/ContainerAPIService/Client/Flags.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,12 @@ public struct Flags {
158158
)
159159
public var kernel: String?
160160

161+
@Option(
162+
name: .long,
163+
help: .init("Use a custom init image instead of the default", valueName: "image")
164+
)
165+
public var initImage: String?
166+
161167
@Option(name: [.short, .customLong("label")], help: "Add a key=value label to the container")
162168
public var labels: [String] = []
163169

Sources/Services/ContainerAPIService/Client/Utility.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ public struct Utility {
8484
imageFetch: Flags.ImageFetch,
8585
progressUpdate: @escaping ProgressUpdateHandler,
8686
log: Logger
87-
) async throws -> (ContainerConfiguration, Kernel) {
87+
) async throws -> (ContainerConfiguration, Kernel, String?) {
8888
var requestedPlatform = Parser.platform(os: management.os, arch: management.arch)
8989
// Prefer --platform
9090
if let platform = management.platform {
@@ -252,7 +252,7 @@ public struct Utility {
252252
config.runtimeHandler = runtime
253253
}
254254

255-
return (config, kernel)
255+
return (config, kernel, management.initImage)
256256
}
257257

258258
static func getAttachmentConfigurations(

Sources/Services/ContainerAPIService/Client/XPC+.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,9 @@ public enum XPCKeys: String {
106106
case systemPlatform
107107
case kernelForce
108108

109+
/// Init image reference
110+
case initImage
111+
109112
/// Volume
110113
case volume
111114
case volumes

Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,9 @@ public struct ContainersHarness: Sendable {
187187
let config = try JSONDecoder().decode(ContainerConfiguration.self, from: data)
188188
let kernel = try JSONDecoder().decode(Kernel.self, from: kdata)
189189

190-
try await service.create(configuration: config, kernel: kernel, options: options)
190+
let initImage = message.string(key: .initImage)
191+
192+
try await service.create(configuration: config, kernel: kernel, options: options, initImage: initImage)
191193
return message.reply()
192194
}
193195

Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ public actor ContainersService {
192192
}
193193

194194
/// Create a new container from the provided id and configuration.
195-
public func create(configuration: ContainerConfiguration, kernel: Kernel, options: ContainerCreateOptions) async throws {
195+
public func create(configuration: ContainerConfiguration, kernel: Kernel, options: ContainerCreateOptions, initImage: String? = nil) async throws {
196196
self.log.debug("\(#function)")
197197

198198
try await self.lock.withLock { context in
@@ -233,11 +233,14 @@ public actor ContainersService {
233233

234234
let path = self.containerRoot.appendingPathComponent(configuration.id)
235235
let systemPlatform = kernel.platform
236-
let initFs = try await self.getInitBlock(for: systemPlatform.ociPlatform())
236+
237+
// Fetch init image (custom or default)
238+
self.log.info("Using init image: \(initImage ?? ClientImage.initImageRef)")
239+
let initFilesystem = try await self.getInitBlock(for: systemPlatform.ociPlatform(), imageRef: initImage)
237240

238241
let bundle = try ContainerResource.Bundle.create(
239242
path: path,
240-
initialFilesystem: initFs,
243+
initialFilesystem: initFilesystem,
241244
kernel: kernel,
242245
containerConfiguration: configuration
243246
)
@@ -622,8 +625,9 @@ public actor ContainersService {
622625
return options
623626
}
624627

625-
private func getInitBlock(for platform: Platform) async throws -> Filesystem {
626-
let initImage = try await ClientImage.fetch(reference: ClientImage.initImageRef, platform: platform)
628+
private func getInitBlock(for platform: Platform, imageRef: String? = nil) async throws -> Filesystem {
629+
let ref = imageRef ?? ClientImage.initImageRef
630+
let initImage = try await ClientImage.fetch(reference: ref, platform: platform)
627631
var fs = try await initImage.getCreateSnapshot(platform: platform)
628632
fs.options = ["ro"]
629633
return fs
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
//===----------------------------------------------------------------------===//
2+
// Copyright © 2026 Apple Inc. and the container project authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// https://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
//===----------------------------------------------------------------------===//
16+
17+
import Foundation
18+
import Testing
19+
20+
/// Tests for the `--init-image` flag which allows specifying a custom init filesystem
21+
/// image for microvms. This enables customizing boot-time behavior before the OCI
22+
/// container starts.
23+
///
24+
/// See: https://github.com/apple/container/discussions/838
25+
///
26+
/// Note: A full integration test that verifies custom init behavior would require
27+
/// a pre-built test init image that writes a marker to /dev/kmsg. This can be added
28+
/// once a test init image is published to the registry.
29+
class TestCLIRunInitImage: CLITest {
30+
private func getTestName() -> String {
31+
Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased()
32+
}
33+
34+
/// Test that specifying a non-existent init-image fails with an appropriate error.
35+
@Test func testRunWithNonExistentInitImage() throws {
36+
let name = getTestName()
37+
let nonExistentImage = "nonexistent.invalid/init-image:does-not-exist"
38+
39+
#expect(throws: CLIError.self, "expected container run with non-existent init-image to fail") {
40+
let (_, _, error, status) = try run(arguments: [
41+
"run",
42+
"--rm",
43+
"--name", name,
44+
"-d",
45+
"--init-image", nonExistentImage,
46+
alpine,
47+
"sleep", "infinity",
48+
])
49+
defer { try? doRemove(name: name, force: true) }
50+
if status != 0 {
51+
throw CLIError.executionFailed("command failed: \(error)")
52+
}
53+
}
54+
}
55+
56+
/// Test that the `--init-image` flag is recognized and documented in CLI help.
57+
@Test func testInitImageFlagInHelp() throws {
58+
let (_, output, _, status) = try run(arguments: ["run", "--help"])
59+
#expect(status == 0, "expected help command to succeed")
60+
#expect(
61+
output.contains("--init-image"),
62+
"expected help output to contain --init-image flag"
63+
)
64+
#expect(
65+
output.contains("custom init image"),
66+
"expected help output to describe the init-image flag"
67+
)
68+
}
69+
70+
/// Test that the `--init-image` flag works with `container create` command.
71+
@Test func testCreateWithNonExistentInitImage() throws {
72+
let name = getTestName()
73+
let nonExistentImage = "nonexistent.invalid/init-image:does-not-exist"
74+
75+
#expect(throws: CLIError.self, "expected container create with non-existent init-image to fail") {
76+
let (_, _, error, status) = try run(arguments: [
77+
"create",
78+
"--rm",
79+
"--name", name,
80+
"--init-image", nonExistentImage,
81+
alpine,
82+
"echo", "hello",
83+
])
84+
defer { try? doRemove(name: name, force: true) }
85+
if status != 0 {
86+
throw CLIError.executionFailed("command failed: \(error)")
87+
}
88+
}
89+
}
90+
91+
/// Test that explicitly specifying the default init image works the same as
92+
/// not specifying any init image.
93+
@Test func testRunWithExplicitDefaultInitImage() throws {
94+
let name = getTestName()
95+
96+
// Get the default init image reference
97+
let (_, defaultInitImage, _, propStatus) = try run(arguments: [
98+
"system", "property", "get", "image.init",
99+
])
100+
101+
guard propStatus == 0 else {
102+
print("Skipping testRunWithExplicitDefaultInitImage: could not get default init image")
103+
return
104+
}
105+
106+
let initImage = defaultInitImage.trimmingCharacters(in: .whitespacesAndNewlines)
107+
108+
// Run container with explicit default init image
109+
try doLongRun(name: name, args: ["--init-image", initImage])
110+
defer {
111+
try? doStop(name: name)
112+
}
113+
114+
// Verify container is running and functional
115+
try waitForContainerRunning(name)
116+
let output = try doExec(name: name, cmd: ["echo", "hello"])
117+
#expect(
118+
output.trimmingCharacters(in: .whitespacesAndNewlines) == "hello",
119+
"expected 'hello' output from exec, got '\(output)'"
120+
)
121+
}
122+
}

0 commit comments

Comments
 (0)