From 2d27ef6debfd39ea636e667fa3350b440cac0e8f Mon Sep 17 00:00:00 2001 From: Danny Canter Date: Fri, 5 Dec 2025 07:42:50 -0800 Subject: [PATCH] LinuxPod: Wire up pid namespace sharing (#434) In a prior change I'd added a way for vminitd to double as a simple pause container. This change wires this up by adding a new bool to the pod config to ask for pid ns sharing. --- Sources/Containerization/LinuxPod.swift | 90 ++++++++++++++++++++++- Sources/Integration/PodTests.swift | 44 +++++++++++ Sources/Integration/Suite.swift | 1 + vminitd/Sources/vmexec/RunCommand.swift | 57 ++++++++++++-- vminitd/Sources/vminitd/Server+GRPC.swift | 1 - 5 files changed, 186 insertions(+), 7 deletions(-) diff --git a/Sources/Containerization/LinuxPod.swift b/Sources/Containerization/LinuxPod.swift index f3213e83..1bbf1f2f 100644 --- a/Sources/Containerization/LinuxPod.swift +++ b/Sources/Containerization/LinuxPod.swift @@ -50,6 +50,9 @@ public final class LinuxPod: Sendable { public var virtualization: Bool = false /// Optional file path to store serial boot logs. public var bootLog: BootLog? + /// Whether containers in the pod should share a PID namespace. + /// When enabled, all containers can see each other's processes. + public var shareProcessNamespace: Bool = false public init() {} } @@ -103,6 +106,7 @@ public final class LinuxPod: Sendable { private struct State: Sendable { var phase: Phase var containers: [String: PodContainer] + var pauseProcess: LinuxProcess? } private enum Phase: Sendable { @@ -173,7 +177,7 @@ public final class LinuxPod: Sendable { try configuration(&config) self.config = config - self.state = AsyncMutex(State(phase: .initialized, containers: [:])) + self.state = AsyncMutex(State(phase: .initialized, containers: [:], pauseProcess: nil)) } private static func createDefaultRuntimeSpec(_ containerID: String, podID: String) -> Spec { @@ -303,9 +307,64 @@ extension LinuxPod { do { let containers = state.containers + let shareProcessNamespace = self.config.shareProcessNamespace + let pauseProcessHolder = Mutex(nil) + try await vm.withAgent { agent in try await agent.standardSetup() + // Create pause container if PID namespace sharing is enabled + if shareProcessNamespace { + let pauseID = "pause-\(self.id)" + let pauseRootfsPath = "/run/container/\(pauseID)/rootfs" + + // Bind mount /sbin into the pause container rootfs. + // This is where the guest agent lives. + try await agent.mount( + ContainerizationOCI.Mount( + type: "", + source: "/sbin", + destination: "\(pauseRootfsPath)/sbin", + options: ["bind"] + )) + + var pauseSpec = Self.createDefaultRuntimeSpec(pauseID, podID: self.id) + pauseSpec.process?.args = ["/sbin/vminitd", "pause"] + pauseSpec.hostname = "" + pauseSpec.mounts = LinuxContainer.defaultMounts().map { + ContainerizationOCI.Mount( + type: $0.type, + source: $0.source, + destination: $0.destination, + options: $0.options + ) + } + pauseSpec.linux?.namespaces = [ + LinuxNamespace(type: .cgroup), + LinuxNamespace(type: .ipc), + LinuxNamespace(type: .mount), + LinuxNamespace(type: .pid), + LinuxNamespace(type: .uts), + ] + + // Create LinuxProcess for pause container + let process = LinuxProcess( + pauseID, + containerID: pauseID, + spec: pauseSpec, + io: LinuxProcess.Stdio(stdin: nil, stdout: nil, stderr: nil), + ociRuntimePath: nil, + agent: agent, + vm: vm, + logger: self.logger + ) + + try await process.start() + pauseProcessHolder.withLock { $0 = process } + + self.logger?.debug("Pause container started", metadata: ["pid": "\(process.pid)"]) + } + // Mount all container rootfs for (_, container) in containers { guard let attachments = vm.mounts[container.id], let rootfsAttachment = attachments.first else { @@ -353,6 +412,8 @@ extension LinuxPod { } } + state.pauseProcess = pauseProcessHolder.withLock { $0 } + // Transition all containers to created state for id in state.containers.keys { state.containers[id]?.state = .created @@ -394,6 +455,33 @@ extension LinuxPod { let containerMounts = createdState.vm.mounts[containerID] ?? [] spec.mounts = containerMounts.dropFirst().map { $0.to } + // Configure namespaces for the container + var namespaces: [LinuxNamespace] = [ + LinuxNamespace(type: .cgroup), + LinuxNamespace(type: .ipc), + LinuxNamespace(type: .mount), + LinuxNamespace(type: .uts), + ] + + // Either join pause container's pid ns or create a new one + if self.config.shareProcessNamespace, let pausePID = state.pauseProcess?.pid { + let nsPath = "/proc/\(pausePID)/ns/pid" + + self.logger?.debug( + "Container joining pause PID namespace", + metadata: [ + "container": "\(containerID)", + "pausePID": "\(pausePID)", + "nsPath": "\(nsPath)", + ]) + + namespaces.append(LinuxNamespace(type: .pid, path: nsPath)) + } else { + namespaces.append(LinuxNamespace(type: .pid)) + } + + spec.linux?.namespaces = namespaces + let stdio = IOUtil.setup( portAllocator: self.hostVsockPorts, stdin: container.config.process.stdin, diff --git a/Sources/Integration/PodTests.swift b/Sources/Integration/PodTests.swift index 15d120a0..79708bd7 100644 --- a/Sources/Integration/PodTests.swift +++ b/Sources/Integration/PodTests.swift @@ -702,4 +702,48 @@ extension IntegrationSuite { throw error } } + + func testPodSharedPIDNamespace() async throws { + let id = "test-pod-shared-pid-namespace" + + let bs = try await bootstrap(id) + let pod = try LinuxPod(id, vmm: bs.vmm) { config in + config.cpus = 4 + config.memoryInBytes = 1024.mib() + config.bootLog = bs.bootLog + config.shareProcessNamespace = true + } + + // First container runs a long-running process + try await pod.addContainer("container1", rootfs: try cloneRootfs(bs.rootfs, testID: id, containerID: "container1")) { config in + config.process.arguments = ["/bin/sleep", "300"] + } + + // Second container checks if it can see container1's sleep process + let psBuffer = BufferWriter() + try await pod.addContainer("container2", rootfs: try cloneRootfs(bs.rootfs, testID: id, containerID: "container2")) { config in + config.process.arguments = ["/bin/sh", "-c", "ps aux | grep 'sleep 300' | grep -v grep"] + config.process.stdout = psBuffer + } + + try await pod.create() + try await pod.startContainer("container1") + try await Task.sleep(for: .milliseconds(100)) + + try await pod.startContainer("container2") + let status = try await pod.waitContainer("container2") + + try await pod.killContainer("container1", signal: SIGKILL) + _ = try await pod.waitContainer("container1") + try await pod.stop() + + guard status.exitCode == 0 else { + throw IntegrationError.assert(msg: "container2 should have found the sleep process (status: \(status))") + } + + let output = String(data: psBuffer.data, encoding: .utf8) ?? "" + guard output.contains("sleep 300") else { + throw IntegrationError.assert(msg: "ps output should contain 'sleep 300', got: '\(output)'") + } + } } diff --git a/Sources/Integration/Suite.swift b/Sources/Integration/Suite.swift index 72e226ba..0d191fe7 100644 --- a/Sources/Integration/Suite.swift +++ b/Sources/Integration/Suite.swift @@ -315,6 +315,7 @@ struct IntegrationSuite: AsyncParsableCommand { Test("pod container filesystem isolation", testPodContainerFilesystemIsolation), Test("pod container PID namespace isolation", testPodContainerPIDNamespaceIsolation), Test("pod container independent resource limits", testPodContainerIndependentResourceLimits), + Test("pod shared PID namespace", testPodSharedPIDNamespace), ] let passed: Atomic = Atomic(0) diff --git a/vminitd/Sources/vmexec/RunCommand.swift b/vminitd/Sources/vmexec/RunCommand.swift index 615e8d58..47ac234f 100644 --- a/vminitd/Sources/vmexec/RunCommand.swift +++ b/vminitd/Sources/vmexec/RunCommand.swift @@ -36,9 +36,14 @@ struct RunCommand: ParsableCommand { LoggingSystem.bootstrap(App.standardError) let log = Logger(label: "vmexec") - let bundle = try ContainerizationOCI.Bundle.load(path: URL(filePath: bundlePath)) - let ociSpec = try bundle.loadConfig() - try execInNamespace(spec: ociSpec, log: log) + let spec: ContainerizationOCI.Spec + do { + let bundle = try ContainerizationOCI.Bundle.load(path: URL(filePath: bundlePath)) + spec = try bundle.loadConfig() + } catch { + throw App.Failure(message: "failed to load OCI bundle at \(bundlePath): \(error)") + } + try execInNamespace(spec: spec, log: log) } catch { App.writeError(error) throw error @@ -146,12 +151,54 @@ struct RunCommand: ParsableCommand { try App.exec(process: process, currentEnv: process.env) } + private func setupNamespaces(namespaces: [ContainerizationOCI.LinuxNamespace]?) throws -> Int32 { + var unshareFlags: Int32 = 0 + + // Map namespace types to their corresponding CLONE flags + let nsTypeToFlag: [ContainerizationOCI.LinuxNamespaceType: Int32] = [ + .pid: CLONE_NEWPID, + .mount: CLONE_NEWNS, + .uts: CLONE_NEWUTS, + .ipc: CLONE_NEWIPC, + .user: CLONE_NEWUSER, + .cgroup: CLONE_NEWCGROUP, + ] + + guard let namespaces = namespaces else { + return CLONE_NEWPID | CLONE_NEWNS | CLONE_NEWUTS + } + + for ns in namespaces { + guard let flag = nsTypeToFlag[ns.type] else { + continue + } + + if ns.path.isEmpty { + unshareFlags |= flag + } else { + let fd = open(ns.path, O_RDONLY | O_CLOEXEC) + guard fd >= 0 else { + throw App.Errno(stage: "open(\(ns.path))") + } + defer { close(fd) } + + guard setns(fd, flag) == 0 else { + throw App.Errno(stage: "setns(\(ns.path))") + } + } + } + + return unshareFlags + } + private func execInNamespace(spec: ContainerizationOCI.Spec, log: Logger) throws { let syncPipe = FileHandle(fileDescriptor: 3) let ackPipe = FileHandle(fileDescriptor: 4) - guard unshare(CLONE_NEWPID | CLONE_NEWNS | CLONE_NEWUTS) == 0 else { - throw App.Errno(stage: "unshare(pid|mnt|uts)") + let unshareFlags = try setupNamespaces(namespaces: spec.linux?.namespaces) + + guard unshare(unshareFlags) == 0 else { + throw App.Errno(stage: "unshare(\(unshareFlags))") } let processID = fork() diff --git a/vminitd/Sources/vminitd/Server+GRPC.swift b/vminitd/Sources/vminitd/Server+GRPC.swift index ba1f609b..f84f44bd 100644 --- a/vminitd/Sources/vminitd/Server+GRPC.swift +++ b/vminitd/Sources/vminitd/Server+GRPC.swift @@ -428,7 +428,6 @@ extension Initd: Com_Apple_Containerization_Sandbox_V3_SandboxContextAsyncProvid "stdin": "Port: \(request.stdin)", "stdout": "Port: \(request.stdout)", "stderr": "Port: \(request.stderr)", - "configuration": "\(request.configuration.count)", ]) if !request.hasContainerID {