From 899081d094fd874036608aceff9af93d2fb74e82 Mon Sep 17 00:00:00 2001 From: Danny Canter Date: Mon, 23 Feb 2026 16:02:38 -0800 Subject: [PATCH] cli: Add --init to run/create (#1244) Closes #1225 Add a flag to signify that we'd like to run a minimal init process that can reap zombie processes. The actual support for this is in the Containerization library so the plumbing here is very simple. --- Package.resolved | 6 +- Package.swift | 2 +- .../Container/ContainerConfiguration.swift | 4 ++ .../ContainerAPIService/Client/Flags.swift | 33 +++++---- .../ContainerAPIService/Client/Utility.swift | 1 + .../Server/SandboxService.swift | 1 + .../Subcommands/Run/TestCLIRunCommand.swift | 68 +++++++++++++++++++ docs/command-reference.md | 11 ++- docs/how-to.md | 15 ++++ 9 files changed, 121 insertions(+), 20 deletions(-) diff --git a/Package.resolved b/Package.resolved index 97075898..9c7d7197 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "a0d9f0581740922266b0739fae8ec0998c7d5c7d98ff76cccb68a878f27e88ab", + "originHash" : "052cce484c36a6e3389f4630d1fbaf7a73c8902737f282ec0697eedc84c833e9", "pins" : [ { "identity" : "async-http-client", @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/containerization.git", "state" : { - "revision" : "185b04af7414db37577e003bad052cc8fc75a5c9", - "version" : "0.26.2" + "revision" : "185bc1ecd7b6d9ef1938da1846e620ab73db8950", + "version" : "0.26.3" } }, { diff --git a/Package.swift b/Package.swift index 88ca771f..c4e511a7 100644 --- a/Package.swift +++ b/Package.swift @@ -23,7 +23,7 @@ import PackageDescription let releaseVersion = ProcessInfo.processInfo.environment["RELEASE_VERSION"] ?? "0.0.0" let gitCommit = ProcessInfo.processInfo.environment["GIT_COMMIT"] ?? "unspecified" let builderShimVersion = "0.8.0" -let scVersion = "0.26.2" +let scVersion = "0.26.3" let package = Package( name: "container", diff --git a/Sources/ContainerResource/Container/ContainerConfiguration.swift b/Sources/ContainerResource/Container/ContainerConfiguration.swift index e81d09db..c4c3da6b 100644 --- a/Sources/ContainerResource/Container/ContainerConfiguration.swift +++ b/Sources/ContainerResource/Container/ContainerConfiguration.swift @@ -51,6 +51,8 @@ public struct ContainerConfiguration: Sendable, Codable { public var ssh: Bool = false /// Whether to mount the rootfs as read-only. public var readOnly: Bool = false + /// Whether to use a minimal init process inside the container. + public var useInit: Bool = false enum CodingKeys: String, CodingKey { case id @@ -70,6 +72,7 @@ public struct ContainerConfiguration: Sendable, Codable { case virtualization case ssh case readOnly + case useInit } /// Create a configuration from the supplied Decoder, initializing missing @@ -100,6 +103,7 @@ public struct ContainerConfiguration: Sendable, Codable { virtualization = try container.decodeIfPresent(Bool.self, forKey: .virtualization) ?? false ssh = try container.decodeIfPresent(Bool.self, forKey: .ssh) ?? false readOnly = try container.decodeIfPresent(Bool.self, forKey: .readOnly) ?? false + useInit = try container.decodeIfPresent(Bool.self, forKey: .useInit) ?? false } public struct DNSConfiguration: Sendable, Codable { diff --git a/Sources/Services/ContainerAPIService/Client/Flags.swift b/Sources/Services/ContainerAPIService/Client/Flags.swift index bb6f11a8..b83d7820 100644 --- a/Sources/Services/ContainerAPIService/Client/Flags.swift +++ b/Sources/Services/ContainerAPIService/Client/Flags.swift @@ -188,6 +188,7 @@ public struct Flags { runtime: String?, ssh: Bool, tmpFs: [String], + useInit: Bool, virtualization: Bool, volumes: [String] ) { @@ -213,6 +214,7 @@ public struct Flags { self.runtime = runtime self.ssh = ssh self.tmpFs = tmpFs + self.useInit = useInit self.virtualization = virtualization self.volumes = volumes } @@ -238,6 +240,15 @@ public struct Flags { ) public var entrypoint: String? + @Flag(name: .customLong("init"), help: "Run an init process inside the container that forwards signals and reaps processes") + public var useInit = false + + @Option( + name: .long, + help: .init("Use a custom init image instead of the default", valueName: "image") + ) + public var initImage: String? + @Option( name: .shortAndLong, help: .init("Set a custom kernel path", valueName: "path"), @@ -248,12 +259,6 @@ 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] = [] @@ -293,21 +298,24 @@ public struct Flags { ) public var publishSockets: [String] = [] + @Flag(name: .long, help: "Mount the container's root filesystem as read-only") + public var readOnly = false + @Flag(name: [.customLong("rm"), .long], help: "Remove the container after it stops") public var remove = false @Flag(name: .long, help: "Enable Rosetta in the container") public var rosetta = false + @Option(name: .long, help: "Set the runtime handler for the container (default: container-runtime-linux)") + public var runtime: String? + @Flag(name: .long, help: "Forward SSH agent socket to container") public var ssh = false @Option(name: .customLong("tmpfs"), help: "Add a tmpfs mount to the container at the given path") public var tmpFs: [String] = [] - @Option(name: [.customLong("volume"), .short], help: "Bind mount a volume into the container") - public var volumes: [String] = [] - @Flag( name: .long, help: @@ -315,11 +323,8 @@ public struct Flags { ) public var virtualization: Bool = false - @Flag(name: .long, help: "Mount the container's root filesystem as read-only") - public var readOnly = false - - @Option(name: .long, help: "Set the runtime handler for the container (default: container-runtime-linux)") - public var runtime: String? + @Option(name: [.customLong("volume"), .short], help: "Bind mount a volume into the container") + public var volumes: [String] = [] } public struct Progress: ParsableArguments { diff --git a/Sources/Services/ContainerAPIService/Client/Utility.swift b/Sources/Services/ContainerAPIService/Client/Utility.swift index 7ea19106..0e68d5e8 100644 --- a/Sources/Services/ContainerAPIService/Client/Utility.swift +++ b/Sources/Services/ContainerAPIService/Client/Utility.swift @@ -248,6 +248,7 @@ public struct Utility { config.ssh = management.ssh config.readOnly = management.readOnly + config.useInit = management.useInit if let runtime = management.runtime { config.runtimeHandler = runtime diff --git a/Sources/Services/ContainerSandboxService/Server/SandboxService.swift b/Sources/Services/ContainerSandboxService/Server/SandboxService.swift index 0abb9346..d7e0e0b4 100644 --- a/Sources/Services/ContainerSandboxService/Server/SandboxService.swift +++ b/Sources/Services/ContainerSandboxService/Server/SandboxService.swift @@ -842,6 +842,7 @@ public actor SandboxService { } // If the host doesn't support this, we'll throw on container creation. czConfig.virtualization = config.virtualization + czConfig.useInit = config.useInit for mount in config.mounts { if try mount.isSocket() { diff --git a/Tests/CLITests/Subcommands/Run/TestCLIRunCommand.swift b/Tests/CLITests/Subcommands/Run/TestCLIRunCommand.swift index 0ee889d0..dd69ed2f 100644 --- a/Tests/CLITests/Subcommands/Run/TestCLIRunCommand.swift +++ b/Tests/CLITests/Subcommands/Run/TestCLIRunCommand.swift @@ -487,6 +487,74 @@ class TestCLIRunCommand2: CLITest { return } } + + @Test func testRunCommandInit() throws { + do { + let name = getTestName() + try doLongRun(name: name, args: ["--init"]) + defer { + try? doStop(name: name) + } + let inspectResp = try inspectContainer(name) + #expect(inspectResp.configuration.useInit == true, "expected useInit to be true in container configuration") + + // With --init, PID 1 should be the init process, not "sleep". + var output = try doExec(name: name, cmd: ["cat", "/proc/1/cmdline"]) + output = output.trimmingCharacters(in: .whitespacesAndNewlines) + #expect( + !output.hasPrefix("sleep"), + "expected PID 1 to be init process, not 'sleep', got '\(output)'" + ) + try doStop(name: name) + } catch { + Issue.record("failed to run container with --init: \(error)") + return + } + } + + @Test func testRunCommandInitReapsZombies() throws { + do { + let name = getTestName() + try doLongRun(name: name, args: ["--init"]) + defer { + try? doStop(name: name) + } + + _ = try doExec( + name: name, + cmd: [ + "sh", "-c", + "sh -c 'sh -c \"exit 0\" &' && sleep 1", + ]) + + let psOutput = try doExec(name: name, cmd: ["sh", "-c", "ps aux | grep -c '\\[sh\\]' || true"]) + let zombieCount = Int(psOutput.trimmingCharacters(in: .whitespacesAndNewlines)) ?? -1 + #expect( + zombieCount == 0, + "expected no zombie processes with --init, found \(zombieCount)" + ) + try doStop(name: name) + } catch { + Issue.record("failed to verify zombie reaping with --init: \(error)") + return + } + } + + @Test func testRunCommandWithoutInitDefault() throws { + do { + let name = getTestName() + try doLongRun(name: name, args: []) + defer { + try? doStop(name: name) + } + let inspectResp = try inspectContainer(name) + #expect(inspectResp.configuration.useInit == false, "expected useInit to be false by default") + try doStop(name: name) + } catch { + Issue.record("failed to run container without --init: \(error)") + return + } + } } class TestCLIRunCommand3: CLITest { diff --git a/docs/command-reference.md b/docs/command-reference.md index 43f18977..3f9b6fcf 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -50,6 +50,7 @@ container run [] [ ...] * `--dns-option