Skip to content

Commit 6e1d1fa

Browse files
feat: add init-image flag to allow specifying custom init filesystem images per VM
1 parent 113a6ec commit 6e1d1fa

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
@@ -82,7 +82,7 @@ extension Application {
8282
)
8383

8484
let options = ContainerCreateOptions(autoRemove: managementFlags.remove)
85-
let container = try await ClientContainer.create(configuration: ck.0, options: options, kernel: ck.1)
85+
let container = try await ClientContainer.create(configuration: ck.0, options: options, kernel: ck.1, initImage: ck.2)
8686

8787
if !self.managementFlags.cidfile.isEmpty {
8888
let path = self.managementFlags.cidfile

Sources/ContainerCommands/Container/ContainerRun.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,8 @@ extension Application {
111111
let container = try await ClientContainer.create(
112112
configuration: ck.0,
113113
options: options,
114-
kernel: ck.1
114+
kernel: ck.1,
115+
initImage: ck.2
115116
)
116117

117118
let detach = self.managementFlags.detach

Sources/Services/ContainerAPIService/Client/ClientContainer.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,8 @@ extension ClientContainer {
7878
public static func create(
7979
configuration: ContainerConfiguration,
8080
options: ContainerCreateOptions = .default,
81-
kernel: Kernel
81+
kernel: Kernel,
82+
initImage: String? = nil
8283
) async throws -> ClientContainer {
8384
do {
8485
let client = Self.newXPCClient()
@@ -91,6 +92,10 @@ extension ClientContainer {
9192
request.set(key: .kernel, value: kdata)
9293
request.set(key: .containerOptions, value: odata)
9394

95+
if let initImage {
96+
request.set(key: .initImage, value: initImage)
97+
}
98+
9499
try await xpcSend(client: client, message: request)
95100
return ClientContainer(configuration: configuration)
96101
} catch {

Sources/Services/ContainerAPIService/Client/Flags.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,12 @@ public struct Flags {
149149
)
150150
public var kernel: String?
151151

152+
@Option(
153+
name: .long,
154+
help: .init("Use a custom init image instead of the default", valueName: "image")
155+
)
156+
public var initImage: String?
157+
152158
@Option(name: [.short, .customLong("label")], help: "Add a key=value label to the container")
153159
public var labels: [String] = []
154160

Sources/Services/ContainerAPIService/Client/Utility.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ public struct Utility {
8282
registry: Flags.Registry,
8383
imageFetch: Flags.ImageFetch,
8484
progressUpdate: @escaping ProgressUpdateHandler
85-
) async throws -> (ContainerConfiguration, Kernel) {
85+
) async throws -> (ContainerConfiguration, Kernel, String?) {
8686
var requestedPlatform = Parser.platform(os: management.os, arch: management.arch)
8787
// Prefer --platform
8888
if let platform = management.platform {
@@ -241,7 +241,7 @@ public struct Utility {
241241
config.ssh = management.ssh
242242
config.readOnly = management.readOnly
243243

244-
return (config, kernel)
244+
return (config, kernel, management.initImage)
245245
}
246246

247247
static func getAttachmentConfigurations(containerId: String, networks: [Parser.ParsedNetwork]) throws -> [AttachmentConfiguration] {

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
)
@@ -619,8 +622,9 @@ public actor ContainersService {
619622
return options
620623
}
621624

622-
private func getInitBlock(for platform: Platform) async throws -> Filesystem {
623-
let initImage = try await ClientImage.fetch(reference: ClientImage.initImageRef, platform: platform)
625+
private func getInitBlock(for platform: Platform, imageRef: String? = nil) async throws -> Filesystem {
626+
let ref = imageRef ?? ClientImage.initImageRef
627+
let initImage = try await ClientImage.fetch(reference: ref, platform: platform)
624628
var fs = try await initImage.getCreateSnapshot(platform: platform)
625629
fs.options = ["ro"]
626630
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)