Skip to content
Open
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
10 changes: 9 additions & 1 deletion Sources/Containerization/ContainerManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -352,17 +352,20 @@ public struct ContainerManager: Sendable {
/// - id: The container ID.
/// - reference: The image reference.
/// - rootfsSizeInBytes: The size of the root filesystem in bytes. Defaults to 8 GiB.
/// - readOnly: Whether to mount the root filesystem as read-only.
public mutating func create(
_ id: String,
reference: String,
rootfsSizeInBytes: UInt64 = 8.gib(),
readOnly: Bool = false,
configuration: (inout LinuxContainer.Configuration) throws -> Void
) async throws -> LinuxContainer {
let image = try await imageStore.get(reference: reference, pull: true)
return try await create(
id,
image: image,
rootfsSizeInBytes: rootfsSizeInBytes,
readOnly: readOnly,
configuration: configuration
)
}
Expand All @@ -372,19 +375,24 @@ public struct ContainerManager: Sendable {
/// - id: The container ID.
/// - image: The image.
/// - rootfsSizeInBytes: The size of the root filesystem in bytes. Defaults to 8 GiB.
/// - readOnly: Whether to mount the root filesystem as read-only.
public mutating func create(
_ id: String,
image: Image,
rootfsSizeInBytes: UInt64 = 8.gib(),
readOnly: Bool = false,
configuration: (inout LinuxContainer.Configuration) throws -> Void
) async throws -> LinuxContainer {
let path = try createContainerRoot(id)

let rootfs = try await unpack(
var rootfs = try await unpack(
image: image,
destination: path.appendingPathComponent("rootfs.ext4"),
size: rootfsSizeInBytes
)
if readOnly {
rootfs.options.append("ro")
}
return try await create(
id,
image: image,
Expand Down
38 changes: 24 additions & 14 deletions Sources/Containerization/LinuxContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -235,25 +235,22 @@ public final class LinuxContainer: Container, Sendable {
/// - vmm: The virtual machine manager that will handle launching the VM for the container.
/// - logger: Optional logger for container operations.
/// - configuration: A closure that configures the container by modifying the Configuration instance.
public init(
public convenience init(
_ id: String,
rootfs: Mount,
vmm: VirtualMachineManager,
logger: Logger? = nil,
configuration: (inout Configuration) throws -> Void
) throws {
self.id = id
self.vmm = vmm
self.hostVsockPorts = Atomic<UInt32>(0x1000_0000)
self.guestVsockPorts = Atomic<UInt32>(0x1000_0000)
self.rootfs = rootfs
self.logger = logger

var config = Configuration()
try configuration(&config)

self.config = config
self.state = AsyncMutex(.initialized)
self.init(
id,
rootfs: rootfs,
vmm: vmm,
configuration: config,
logger: logger
)
}

/// Create a new `LinuxContainer`.
Expand All @@ -275,11 +272,10 @@ public final class LinuxContainer: Container, Sendable {
self.vmm = vmm
self.hostVsockPorts = Atomic<UInt32>(0x1000_0000)
self.guestVsockPorts = Atomic<UInt32>(0x1000_0000)
self.rootfs = rootfs
self.logger = logger

self.config = configuration
self.state = AsyncMutex(.initialized)
self.rootfs = rootfs
}

private static func createDefaultRuntimeSpec(_ id: String) -> Spec {
Expand Down Expand Up @@ -309,6 +305,10 @@ public final class LinuxContainer: Container, Sendable {
// Linux toggles.
spec.linux?.sysctl = config.sysctl

// If the rootfs was requested as read-only, set it in the OCI spec.
// We let the OCI runtime remount as ro, instead of doing it originally.
spec.root?.readonly = self.rootfs.options.contains("ro")

// Resource limits.
// CPU: quota/period model where period is 100ms (100,000µs) and quota is cpus * period
// Memory: limit in bytes
Expand Down Expand Up @@ -394,11 +394,21 @@ extension LinuxContainer {
try await self.state.withLock { state in
try state.validateForCreate()

// This is a bit of an annoyance, but because the type we use for the rootfs is simply
// the same Mount type we use for non-rootfs mounts, it's possible someone passed 'ro'
// in the options (which should be perfectly valid). However, the problem is when we go to
// setup /etc/hosts and /etc/resolv.conf, as we'd get EROFS if they did supply 'ro'.
// To remedy this, remove any "ro" options before passing to VZ. Having the OCI runtime
// remount "ro" (which is what we do later in the guest) is truthfully the right thing,
// but this bit here is just a tad awkward.
var modifiedRootfs = self.rootfs
modifiedRootfs.options.removeAll(where: { $0 == "ro" })

let vmConfig = VMConfiguration(
cpus: self.cpus,
memoryInBytes: self.memoryInBytes,
interfaces: self.interfaces,
mountsByID: [self.id: [self.rootfs] + self.config.mounts],
mountsByID: [self.id: [modifiedRootfs] + self.config.mounts],
bootLog: self.config.bootLog,
nestedVirtualization: self.config.virtualization
)
Expand Down
16 changes: 12 additions & 4 deletions Sources/Containerization/LinuxPod.swift
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ public final class LinuxPod: Sendable {
)
}

private func generateRuntimeSpec(containerID: String, config: ContainerConfiguration) -> Spec {
private func generateRuntimeSpec(containerID: String, config: ContainerConfiguration, rootfs: Mount) -> Spec {
var spec = Self.createDefaultRuntimeSpec(containerID, podID: self.id)

// Process configuration
Expand All @@ -207,6 +207,10 @@ public final class LinuxPod: Sendable {
// Linux toggles
spec.linux?.sysctl = config.sysctl

// If the rootfs was requested as read-only, set it in the OCI spec.
// We let the OCI runtime remount as ro, instead of doing it originally.
spec.root?.readonly = rootfs.options.contains("ro")

// Resource limits (if specified)
if let cpus = config.cpus, cpus > 0 {
spec.linux?.resources?.cpu = LinuxCPU(
Expand Down Expand Up @@ -287,9 +291,13 @@ extension LinuxPod {
try state.phase.validateForCreate()

// Build mountsByID for all containers.
// Strip "ro" from rootfs options - we handle readonly via the OCI spec's
// root.readonly field and remount in vmexec after setup is complete.
var mountsByID: [String: [Mount]] = [:]
for (id, container) in state.containers {
mountsByID[id] = [container.rootfs] + container.config.mounts
var modifiedRootfs = container.rootfs
modifiedRootfs.options.removeAll(where: { $0 == "ro" })
mountsByID[id] = [modifiedRootfs] + container.config.mounts
}

let vmConfig = VMConfiguration(
Expand Down Expand Up @@ -450,7 +458,7 @@ extension LinuxPod {

let agent = try await createdState.vm.dialAgent()
do {
var spec = self.generateRuntimeSpec(containerID: containerID, config: container.config)
var spec = self.generateRuntimeSpec(containerID: containerID, config: container.config, rootfs: container.rootfs)
// We don't need the rootfs, nor do OCI runtimes want it included.
let containerMounts = createdState.vm.mounts[containerID] ?? []
spec.mounts = containerMounts.dropFirst().map { $0.to }
Expand Down Expand Up @@ -685,7 +693,7 @@ extension LinuxPod {
)
}

var spec = self.generateRuntimeSpec(containerID: containerID, config: container.config)
var spec = self.generateRuntimeSpec(containerID: containerID, config: container.config, rootfs: container.rootfs)
var config = LinuxProcessConfiguration()
try configuration(&config)
spec.process = config.toOCI()
Expand Down
92 changes: 92 additions & 0 deletions Sources/Integration/ContainerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1436,4 +1436,96 @@ extension IntegrationSuite {
throw IntegrationError.assert(msg: "container with CAP_CHOWN should succeed, got exit code \(status.exitCode)")
}
}

func testReadOnlyRootfs() async throws {
let id = "test-readonly-rootfs"

let bs = try await bootstrap(id)
var rootfs = bs.rootfs
rootfs.options.append("ro")
let container = try LinuxContainer(id, rootfs: rootfs, vmm: bs.vmm) { config in
config.process.arguments = ["touch", "/testfile"]
config.bootLog = bs.bootLog
}

try await container.create()
try await container.start()

let status = try await container.wait()
try await container.stop()

// touch should fail on a read-only rootfs
guard status.exitCode != 0 else {
throw IntegrationError.assert(msg: "touch should have failed on read-only rootfs")
}
}

func testReadOnlyRootfsHostsFileWritten() async throws {
let id = "test-readonly-rootfs-hosts"

let bs = try await bootstrap(id)
var rootfs = bs.rootfs
rootfs.options.append("ro")
let buffer = BufferWriter()
let entry = Hosts.Entry.localHostIPV4(comment: "ReadOnlyTest")
let container = try LinuxContainer(id, rootfs: rootfs, vmm: bs.vmm) { config in
// Verify /etc/hosts was written before rootfs was remounted read-only
config.process.arguments = ["cat", "/etc/hosts"]
config.process.stdout = buffer
config.hosts = Hosts(entries: [entry])
config.bootLog = bs.bootLog
}

try await container.create()
try await container.start()

let status = try await container.wait()
try await container.stop()

guard status.exitCode == 0 else {
throw IntegrationError.assert(msg: "cat /etc/hosts failed with status \(status)")
}

guard let output = String(data: buffer.data, encoding: .utf8) else {
throw IntegrationError.assert(msg: "failed to convert stdout to UTF8")
}

guard output.contains("ReadOnlyTest") else {
throw IntegrationError.assert(msg: "expected /etc/hosts to contain our entry, got: \(output)")
}
}

func testReadOnlyRootfsDNSConfigured() async throws {
let id = "test-readonly-rootfs-dns"

let bs = try await bootstrap(id)
var rootfs = bs.rootfs
rootfs.options.append("ro")
let buffer = BufferWriter()
let container = try LinuxContainer(id, rootfs: rootfs, vmm: bs.vmm) { config in
// Verify /etc/resolv.conf was written before rootfs was remounted read-only
config.process.arguments = ["cat", "/etc/resolv.conf"]
config.process.stdout = buffer
config.dns = DNS(nameservers: ["8.8.8.8", "8.8.4.4"])
config.bootLog = bs.bootLog
}

try await container.create()
try await container.start()

let status = try await container.wait()
try await container.stop()

guard status.exitCode == 0 else {
throw IntegrationError.assert(msg: "cat /etc/resolv.conf failed with status \(status)")
}

guard let output = String(data: buffer.data, encoding: .utf8) else {
throw IntegrationError.assert(msg: "failed to convert stdout to UTF8")
}

guard output.contains("8.8.8.8") && output.contains("8.8.4.4") else {
throw IntegrationError.assert(msg: "expected /etc/resolv.conf to contain DNS servers, got: \(output)")
}
}
}
67 changes: 67 additions & 0 deletions Sources/Integration/PodTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -746,4 +746,71 @@ extension IntegrationSuite {
throw IntegrationError.assert(msg: "ps output should contain 'sleep 300', got: '\(output)'")
}
}

func testPodReadOnlyRootfs() async throws {
let id = "test-pod-readonly-rootfs"

let bs = try await bootstrap(id)
var rootfs = bs.rootfs
rootfs.options.append("ro")
let pod = try LinuxPod(id, vmm: bs.vmm) { config in
config.cpus = 4
config.memoryInBytes = 1024.mib()
config.bootLog = bs.bootLog
}

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

try await pod.create()
try await pod.startContainer("container1")

let status = try await pod.waitContainer("container1")
try await pod.stop()

// touch should fail on a read-only rootfs
guard status.exitCode != 0 else {
throw IntegrationError.assert(msg: "touch should have failed on read-only rootfs")
}
}

func testPodReadOnlyRootfsDNSConfigured() async throws {
let id = "test-pod-readonly-rootfs-dns"

let bs = try await bootstrap(id)
var rootfs = bs.rootfs
rootfs.options.append("ro")
let pod = try LinuxPod(id, vmm: bs.vmm) { config in
config.cpus = 4
config.memoryInBytes = 1024.mib()
config.bootLog = bs.bootLog
config.dns = DNS(nameservers: ["8.8.8.8", "8.8.4.4"])
}

let buffer = BufferWriter()
try await pod.addContainer("container1", rootfs: rootfs) { config in
// Verify /etc/resolv.conf was written before rootfs was remounted read-only
config.process.arguments = ["cat", "/etc/resolv.conf"]
config.process.stdout = buffer
}

try await pod.create()
try await pod.startContainer("container1")

let status = try await pod.waitContainer("container1")
try await pod.stop()

guard status.exitCode == 0 else {
throw IntegrationError.assert(msg: "cat /etc/resolv.conf failed with status \(status)")
}

guard let output = String(data: buffer.data, encoding: .utf8) else {
throw IntegrationError.assert(msg: "failed to convert stdout to UTF8")
}

guard output.contains("8.8.8.8") && output.contains("8.8.4.4") else {
throw IntegrationError.assert(msg: "expected /etc/resolv.conf to contain DNS servers, got: \(output)")
}
}
}
5 changes: 5 additions & 0 deletions Sources/Integration/Suite.swift
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,9 @@ struct IntegrationSuite: AsyncParsableCommand {
Test("container capabilities OCI default", testCapabilitiesOCIDefault),
Test("container capabilities all capabilities", testCapabilitiesAllCapabilities),
Test("container capabilities file ownership", testCapabilitiesFileOwnership),
Test("container read-only rootfs", testReadOnlyRootfs),
Test("container read-only rootfs hosts file", testReadOnlyRootfsHostsFileWritten),
Test("container read-only rootfs DNS", testReadOnlyRootfsDNSConfigured),

// Pods
Test("pod single container", testPodSingleContainer),
Expand All @@ -321,6 +324,8 @@ struct IntegrationSuite: AsyncParsableCommand {
Test("pod container PID namespace isolation", testPodContainerPIDNamespaceIsolation),
Test("pod container independent resource limits", testPodContainerIndependentResourceLimits),
Test("pod shared PID namespace", testPodSharedPIDNamespace),
Test("pod read-only rootfs", testPodReadOnlyRootfs),
Test("pod read-only rootfs DNS", testPodReadOnlyRootfsDNSConfigured),
]

let passed: Atomic<Int> = Atomic(0)
Expand Down
6 changes: 5 additions & 1 deletion Sources/cctl/RunCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ extension Application {
@Option(name: .long, help: "Path to OCI runtime to use for spawning the container")
var ociRuntimePath: String?

@Flag(name: .long, help: "Make rootfs readonly")
var readOnly: Bool = false

@Option(
name: [.customLong("kernel"), .customShort("k")], help: "Kernel binary path", completion: .file(),
transform: { str in
Expand Down Expand Up @@ -94,7 +97,8 @@ extension Application {
let container = try await manager.create(
id,
reference: imageReference,
rootfsSizeInBytes: fsSizeInMB.mib()
rootfsSizeInBytes: fsSizeInMB.mib(),
readOnly: readOnly
) { config in
config.cpus = cpus
config.memoryInBytes = memory.mib()
Expand Down
Loading