Skip to content
Merged
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
85 changes: 85 additions & 0 deletions Sources/Integration/RuncPodTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -358,4 +358,89 @@ extension IntegrationSuite {
throw error
}
}

/// `runc kill` fails with "container not running" once the init process has exited,
/// and CRI requires StopContainer to succeed on a stopped container.
func testRuncPodKillAfterExit() async throws {
let runtime = try Self.requireRunc()
let id = "test-runc-pod-kill-after-exit"

let bs = try await bootstrap(id)
let pod = try LinuxPod(id, vmm: bs.vmm, vm: .default) { config in
config.ociRuntimePath = runtime
config.bootLog = bs.bootLog
}

try await pod.addContainer("container1", rootfs: bs.rootfs) { config in
config.process.arguments = ["/bin/true"]
}

do {
try await withDeadline(seconds: 60, "runc pod kill-after-exit create") { try await pod.create() }
try await withDeadline(seconds: 60, "runc pod kill-after-exit start") { try await pod.startContainer("container1") }
let status = try await withDeadline(seconds: 60, "runc pod kill-after-exit wait") { try await pod.waitContainer("container1") }
guard status.exitCode == 0 else {
throw IntegrationError.assert(msg: "container status \(status) != 0")
}

try await withDeadline(seconds: 60, "runc pod kill-after-exit kill") {
try await pod.killContainer("container1", signal: .kill)
}

try await withDeadline(seconds: 60, "runc pod kill-after-exit stop") { try await pod.stop() }
} catch {
try? await withDeadline(seconds: 60, "runc pod kill-after-exit stop after failure") { try await pod.stop() }
throw error
}
}

/// An OCI runtime refuses a `net.*` sysctl unless the container's spec owns a network
/// namespace, which pod containers never do. The guest applies those keys itself, so
/// the container starts and the workload observes the value.
func testRuncPodNetworkSysctl() async throws {
let runtime = try Self.requireRunc()
let id = "test-runc-pod-network-sysctl"

let bs = try await bootstrap(id)
let pod = try LinuxPod(id, vmm: bs.vmm, vm: .default) { config in
config.ociRuntimePath = runtime
config.bootLog = bs.bootLog
}

let buffer = BufferWriter()
try await pod.addContainer("container1", rootfs: bs.rootfs) { config in
// A net.* key runc would reject, alongside an ipc-namespaced one it accepts,
// so the test covers the partition rather than just the net.* path.
config.sysctl = [
"net.ipv4.ip_local_port_range": "20000 30000",
"kernel.shm_rmid_forced": "1",
]
config.process.arguments = [
"/bin/sh", "-c",
"echo net=$(cat /proc/sys/net/ipv4/ip_local_port_range | tr -s ' \\t' ',') shm=$(cat /proc/sys/kernel/shm_rmid_forced)",
]
config.process.stdout = buffer
}

do {
try await withDeadline(seconds: 60, "runc pod sysctl create") { try await pod.create() }
try await withDeadline(seconds: 60, "runc pod sysctl start") { try await pod.startContainer("container1") }
let status = try await withDeadline(seconds: 60, "runc pod sysctl wait") { try await pod.waitContainer("container1") }
try await withDeadline(seconds: 60, "runc pod sysctl stop") { try await pod.stop() }

let output = String(data: buffer.data, encoding: .utf8) ?? "<non-utf8 output>"
guard status.exitCode == 0 else {
throw IntegrationError.assert(msg: "sysctl probe failed (exit \(status.exitCode)): \(output)")
}
guard output.contains("net=20000,30000") else {
throw IntegrationError.assert(msg: "net.ipv4.ip_local_port_range not applied in guest: \(output)")
}
guard output.contains("shm=1") else {
throw IntegrationError.assert(msg: "kernel.shm_rmid_forced not applied by the runtime: \(output)")
}
} catch {
try? await withDeadline(seconds: 60, "runc pod sysctl stop after failure") { try await pod.stop() }
throw error
}
}
}
2 changes: 2 additions & 0 deletions Sources/Integration/Suite.swift
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,8 @@ struct IntegrationSuite: AsyncParsableCommand {
Test("runc pod seccomp container override", testRuncPodSeccompContainerOverride),
Test("runc pod shared PID namespace", testRuncPodSharedPIDNamespace),
Test("runc pod process tty ensure TERM", testRuncPodProcessTty),
Test("runc pod kill after exit", testRuncPodKillAfterExit),
Test("runc pod network sysctl", testRuncPodNetworkSysctl),
]

#if os(macOS)
Expand Down
49 changes: 49 additions & 0 deletions vminitd/Sources/VminitdCore/ManagedContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,28 @@ public actor ManagedContainer {
) async throws {
try Self.validate(id: id)

var spec = spec

var cgroupsPath: String
if let cgPath = spec.linux?.cgroupsPath {
cgroupsPath = cgPath
} else {
cgroupsPath = "/container/\(id)"
}

// An OCI runtime refuses a `net.*` sysctl unless the container's spec owns a
// network namespace, which none do here — containers share the VM's, which is
// how they get the pod's address. Apply those keys directly instead and hand
// the runtime only the rest. The remaining keys are ipc- or uts-namespaced and
// the spec does declare those, so the runtime accepts them.
if ociRuntimePath != nil, let sysctls = spec.linux?.sysctl, !sysctls.isEmpty {
let networkKeys = sysctls.filter { $0.key.hasPrefix("net.") }
if !networkKeys.isEmpty {
try Self.applyNetworkSysctls(networkKeys, log: log)
spec.linux?.sysctl = sysctls.filter { !$0.key.hasPrefix("net.") }
}
}

let bundle = try ContainerizationOCI.Bundle.create(
path: Self.craftBundlePath(id: id),
spec: spec
Expand Down Expand Up @@ -332,6 +347,40 @@ extension ContainerizationOCI.Bundle {
}

extension ManagedContainer {
/// Writes `net.*` sysctls to the VM's own `/proc/sys`.
///
/// vminitd is PID 1 in the VM and nothing unshares a network namespace, so these
/// land in the namespace every container in the pod shares — the scope a pod-level
/// sysctl means. Values are written once per container that declares them, which is
/// idempotent.
static func applyNetworkSysctls(_ sysctls: [String: String], log: Logger) throws {
for (key, value) in sysctls.sorted(by: { $0.key < $1.key }) {
let path = "/proc/sys/" + key.replacingOccurrences(of: ".", with: "/")
// Raw open/write rather than Data.write(to:): procfs rejects the
// create-and-rename Foundation may use, and these are single-write files.
let fd = open(path, O_WRONLY, 0)
let openErrno = errno
if fd == -1 {
throw ContainerizationError(
.invalidArgument,
message: "failed to open \(path) for sysctl \(key): \(String(cString: strerror(openErrno)))"
)
}
defer { close(fd) }

let bytes = Array(value.utf8)
let written = bytes.withUnsafeBytes { write(fd, $0.baseAddress, bytes.count) }
let writeErrno = errno
if written != bytes.count {
throw ContainerizationError(
.invalidArgument,
message: "failed to set sysctl \(key)=\(value): \(String(cString: strerror(writeErrno)))"
)
}
log.debug("applied sysctl \(key)=\(value)")
}
}

static func craftBundlePath(id: String) -> URL {
URL(fileURLWithPath: "/run/container").appending(path: id)
}
Expand Down
11 changes: 11 additions & 0 deletions vminitd/Sources/VminitdCore/RuncProcess.swift
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,17 @@ final class RuncProcess: ContainerProcess, Sendable {
}

func kill(_ signal: Int32) async throws {
// `runc kill` exits 1 with "container not running" once the init process has
// exited, so signalling an exited container has to be a no-op here: CRI
// requires StopContainer to succeed on an already-stopped container. Same
// guard `resize` below and `RuncExecProcess.kill` already apply.
let exited = self.state.withLock {
if case .exited = $0.state { return true }
return false
}
if exited {
return
}
self.log.info("sending signal \(signal) to runc container \(id)")
try await self.runc.kill(id: self.id, signal: signal)
}
Expand Down
Loading