Skip to content

Commit 2f55d75

Browse files
authored
Support read only rootfs (#461)
1 parent 44ecb87 commit 2f55d75

File tree

8 files changed

+244
-25
lines changed

8 files changed

+244
-25
lines changed

Sources/Containerization/ContainerManager.swift

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//===----------------------------------------------------------------------===//
2-
// Copyright © 2025 Apple Inc. and the Containerization project authors.
2+
// Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
33
//
44
// Licensed under the Apache License, Version 2.0 (the "License");
55
// you may not use this file except in compliance with the License.
@@ -352,17 +352,20 @@ public struct ContainerManager: Sendable {
352352
/// - id: The container ID.
353353
/// - reference: The image reference.
354354
/// - rootfsSizeInBytes: The size of the root filesystem in bytes. Defaults to 8 GiB.
355+
/// - readOnly: Whether to mount the root filesystem as read-only.
355356
public mutating func create(
356357
_ id: String,
357358
reference: String,
358359
rootfsSizeInBytes: UInt64 = 8.gib(),
360+
readOnly: Bool = false,
359361
configuration: (inout LinuxContainer.Configuration) throws -> Void
360362
) async throws -> LinuxContainer {
361363
let image = try await imageStore.get(reference: reference, pull: true)
362364
return try await create(
363365
id,
364366
image: image,
365367
rootfsSizeInBytes: rootfsSizeInBytes,
368+
readOnly: readOnly,
366369
configuration: configuration
367370
)
368371
}
@@ -372,19 +375,24 @@ public struct ContainerManager: Sendable {
372375
/// - id: The container ID.
373376
/// - image: The image.
374377
/// - rootfsSizeInBytes: The size of the root filesystem in bytes. Defaults to 8 GiB.
378+
/// - readOnly: Whether to mount the root filesystem as read-only.
375379
public mutating func create(
376380
_ id: String,
377381
image: Image,
378382
rootfsSizeInBytes: UInt64 = 8.gib(),
383+
readOnly: Bool = false,
379384
configuration: (inout LinuxContainer.Configuration) throws -> Void
380385
) async throws -> LinuxContainer {
381386
let path = try createContainerRoot(id)
382387

383-
let rootfs = try await unpack(
388+
var rootfs = try await unpack(
384389
image: image,
385390
destination: path.appendingPathComponent("rootfs.ext4"),
386391
size: rootfsSizeInBytes
387392
)
393+
if readOnly {
394+
rootfs.options.append("ro")
395+
}
388396
return try await create(
389397
id,
390398
image: image,

Sources/Containerization/LinuxContainer.swift

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -235,25 +235,22 @@ public final class LinuxContainer: Container, Sendable {
235235
/// - vmm: The virtual machine manager that will handle launching the VM for the container.
236236
/// - logger: Optional logger for container operations.
237237
/// - configuration: A closure that configures the container by modifying the Configuration instance.
238-
public init(
238+
public convenience init(
239239
_ id: String,
240240
rootfs: Mount,
241241
vmm: VirtualMachineManager,
242242
logger: Logger? = nil,
243243
configuration: (inout Configuration) throws -> Void
244244
) throws {
245-
self.id = id
246-
self.vmm = vmm
247-
self.hostVsockPorts = Atomic<UInt32>(0x1000_0000)
248-
self.guestVsockPorts = Atomic<UInt32>(0x1000_0000)
249-
self.rootfs = rootfs
250-
self.logger = logger
251-
252245
var config = Configuration()
253246
try configuration(&config)
254-
255-
self.config = config
256-
self.state = AsyncMutex(.initialized)
247+
self.init(
248+
id,
249+
rootfs: rootfs,
250+
vmm: vmm,
251+
configuration: config,
252+
logger: logger
253+
)
257254
}
258255

259256
/// Create a new `LinuxContainer`.
@@ -275,11 +272,10 @@ public final class LinuxContainer: Container, Sendable {
275272
self.vmm = vmm
276273
self.hostVsockPorts = Atomic<UInt32>(0x1000_0000)
277274
self.guestVsockPorts = Atomic<UInt32>(0x1000_0000)
278-
self.rootfs = rootfs
279275
self.logger = logger
280-
281276
self.config = configuration
282277
self.state = AsyncMutex(.initialized)
278+
self.rootfs = rootfs
283279
}
284280

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

308+
// If the rootfs was requested as read-only, set it in the OCI spec.
309+
// We let the OCI runtime remount as ro, instead of doing it originally.
310+
spec.root?.readonly = self.rootfs.options.contains("ro")
311+
312312
// Resource limits.
313313
// CPU: quota/period model where period is 100ms (100,000µs) and quota is cpus * period
314314
// Memory: limit in bytes
@@ -394,11 +394,21 @@ extension LinuxContainer {
394394
try await self.state.withLock { state in
395395
try state.validateForCreate()
396396

397+
// This is a bit of an annoyance, but because the type we use for the rootfs is simply
398+
// the same Mount type we use for non-rootfs mounts, it's possible someone passed 'ro'
399+
// in the options (which should be perfectly valid). However, the problem is when we go to
400+
// setup /etc/hosts and /etc/resolv.conf, as we'd get EROFS if they did supply 'ro'.
401+
// To remedy this, remove any "ro" options before passing to VZ. Having the OCI runtime
402+
// remount "ro" (which is what we do later in the guest) is truthfully the right thing,
403+
// but this bit here is just a tad awkward.
404+
var modifiedRootfs = self.rootfs
405+
modifiedRootfs.options.removeAll(where: { $0 == "ro" })
406+
397407
let vmConfig = VMConfiguration(
398408
cpus: self.cpus,
399409
memoryInBytes: self.memoryInBytes,
400410
interfaces: self.interfaces,
401-
mountsByID: [self.id: [self.rootfs] + self.config.mounts],
411+
mountsByID: [self.id: [modifiedRootfs] + self.config.mounts],
402412
bootLog: self.config.bootLog,
403413
nestedVirtualization: self.config.virtualization
404414
)

Sources/Containerization/LinuxPod.swift

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//===----------------------------------------------------------------------===//
2-
// Copyright © 2025 Apple Inc. and the Containerization project authors.
2+
// Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
33
//
44
// Licensed under the Apache License, Version 2.0 (the "License");
55
// you may not use this file except in compliance with the License.
@@ -195,7 +195,7 @@ public final class LinuxPod: Sendable {
195195
)
196196
}
197197

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

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

210+
// If the rootfs was requested as read-only, set it in the OCI spec.
211+
// We let the OCI runtime remount as ro, instead of doing it originally.
212+
spec.root?.readonly = rootfs.options.contains("ro")
213+
210214
// Resource limits (if specified)
211215
if let cpus = config.cpus, cpus > 0 {
212216
spec.linux?.resources?.cpu = LinuxCPU(
@@ -287,9 +291,13 @@ extension LinuxPod {
287291
try state.phase.validateForCreate()
288292

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

295303
let vmConfig = VMConfiguration(
@@ -450,7 +458,7 @@ extension LinuxPod {
450458

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

688-
var spec = self.generateRuntimeSpec(containerID: containerID, config: container.config)
696+
var spec = self.generateRuntimeSpec(containerID: containerID, config: container.config, rootfs: container.rootfs)
689697
var config = LinuxProcessConfiguration()
690698
try configuration(&config)
691699
spec.process = config.toOCI()

Sources/Integration/ContainerTests.swift

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1613,4 +1613,96 @@ extension IntegrationSuite {
16131613
throw error
16141614
}
16151615
}
1616+
1617+
func testReadOnlyRootfs() async throws {
1618+
let id = "test-readonly-rootfs"
1619+
1620+
let bs = try await bootstrap(id)
1621+
var rootfs = bs.rootfs
1622+
rootfs.options.append("ro")
1623+
let container = try LinuxContainer(id, rootfs: rootfs, vmm: bs.vmm) { config in
1624+
config.process.arguments = ["touch", "/testfile"]
1625+
config.bootLog = bs.bootLog
1626+
}
1627+
1628+
try await container.create()
1629+
try await container.start()
1630+
1631+
let status = try await container.wait()
1632+
try await container.stop()
1633+
1634+
// touch should fail on a read-only rootfs
1635+
guard status.exitCode != 0 else {
1636+
throw IntegrationError.assert(msg: "touch should have failed on read-only rootfs")
1637+
}
1638+
}
1639+
1640+
func testReadOnlyRootfsHostsFileWritten() async throws {
1641+
let id = "test-readonly-rootfs-hosts"
1642+
1643+
let bs = try await bootstrap(id)
1644+
var rootfs = bs.rootfs
1645+
rootfs.options.append("ro")
1646+
let buffer = BufferWriter()
1647+
let entry = Hosts.Entry.localHostIPV4(comment: "ReadOnlyTest")
1648+
let container = try LinuxContainer(id, rootfs: rootfs, vmm: bs.vmm) { config in
1649+
// Verify /etc/hosts was written before rootfs was remounted read-only
1650+
config.process.arguments = ["cat", "/etc/hosts"]
1651+
config.process.stdout = buffer
1652+
config.hosts = Hosts(entries: [entry])
1653+
config.bootLog = bs.bootLog
1654+
}
1655+
1656+
try await container.create()
1657+
try await container.start()
1658+
1659+
let status = try await container.wait()
1660+
try await container.stop()
1661+
1662+
guard status.exitCode == 0 else {
1663+
throw IntegrationError.assert(msg: "cat /etc/hosts failed with status \(status)")
1664+
}
1665+
1666+
guard let output = String(data: buffer.data, encoding: .utf8) else {
1667+
throw IntegrationError.assert(msg: "failed to convert stdout to UTF8")
1668+
}
1669+
1670+
guard output.contains("ReadOnlyTest") else {
1671+
throw IntegrationError.assert(msg: "expected /etc/hosts to contain our entry, got: \(output)")
1672+
}
1673+
}
1674+
1675+
func testReadOnlyRootfsDNSConfigured() async throws {
1676+
let id = "test-readonly-rootfs-dns"
1677+
1678+
let bs = try await bootstrap(id)
1679+
var rootfs = bs.rootfs
1680+
rootfs.options.append("ro")
1681+
let buffer = BufferWriter()
1682+
let container = try LinuxContainer(id, rootfs: rootfs, vmm: bs.vmm) { config in
1683+
// Verify /etc/resolv.conf was written before rootfs was remounted read-only
1684+
config.process.arguments = ["cat", "/etc/resolv.conf"]
1685+
config.process.stdout = buffer
1686+
config.dns = DNS(nameservers: ["8.8.8.8", "8.8.4.4"])
1687+
config.bootLog = bs.bootLog
1688+
}
1689+
1690+
try await container.create()
1691+
try await container.start()
1692+
1693+
let status = try await container.wait()
1694+
try await container.stop()
1695+
1696+
guard status.exitCode == 0 else {
1697+
throw IntegrationError.assert(msg: "cat /etc/resolv.conf failed with status \(status)")
1698+
}
1699+
1700+
guard let output = String(data: buffer.data, encoding: .utf8) else {
1701+
throw IntegrationError.assert(msg: "failed to convert stdout to UTF8")
1702+
}
1703+
1704+
guard output.contains("8.8.8.8") && output.contains("8.8.4.4") else {
1705+
throw IntegrationError.assert(msg: "expected /etc/resolv.conf to contain DNS servers, got: \(output)")
1706+
}
1707+
}
16161708
}

Sources/Integration/PodTests.swift

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//===----------------------------------------------------------------------===//
2-
// Copyright © 2025 Apple Inc. and the Containerization project authors.
2+
// Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
33
//
44
// Licensed under the Apache License, Version 2.0 (the "License");
55
// you may not use this file except in compliance with the License.
@@ -746,4 +746,71 @@ extension IntegrationSuite {
746746
throw IntegrationError.assert(msg: "ps output should contain 'sleep 300', got: '\(output)'")
747747
}
748748
}
749+
750+
func testPodReadOnlyRootfs() async throws {
751+
let id = "test-pod-readonly-rootfs"
752+
753+
let bs = try await bootstrap(id)
754+
var rootfs = bs.rootfs
755+
rootfs.options.append("ro")
756+
let pod = try LinuxPod(id, vmm: bs.vmm) { config in
757+
config.cpus = 4
758+
config.memoryInBytes = 1024.mib()
759+
config.bootLog = bs.bootLog
760+
}
761+
762+
try await pod.addContainer("container1", rootfs: rootfs) { config in
763+
config.process.arguments = ["touch", "/testfile"]
764+
}
765+
766+
try await pod.create()
767+
try await pod.startContainer("container1")
768+
769+
let status = try await pod.waitContainer("container1")
770+
try await pod.stop()
771+
772+
// touch should fail on a read-only rootfs
773+
guard status.exitCode != 0 else {
774+
throw IntegrationError.assert(msg: "touch should have failed on read-only rootfs")
775+
}
776+
}
777+
778+
func testPodReadOnlyRootfsDNSConfigured() async throws {
779+
let id = "test-pod-readonly-rootfs-dns"
780+
781+
let bs = try await bootstrap(id)
782+
var rootfs = bs.rootfs
783+
rootfs.options.append("ro")
784+
let pod = try LinuxPod(id, vmm: bs.vmm) { config in
785+
config.cpus = 4
786+
config.memoryInBytes = 1024.mib()
787+
config.bootLog = bs.bootLog
788+
config.dns = DNS(nameservers: ["8.8.8.8", "8.8.4.4"])
789+
}
790+
791+
let buffer = BufferWriter()
792+
try await pod.addContainer("container1", rootfs: rootfs) { config in
793+
// Verify /etc/resolv.conf was written before rootfs was remounted read-only
794+
config.process.arguments = ["cat", "/etc/resolv.conf"]
795+
config.process.stdout = buffer
796+
}
797+
798+
try await pod.create()
799+
try await pod.startContainer("container1")
800+
801+
let status = try await pod.waitContainer("container1")
802+
try await pod.stop()
803+
804+
guard status.exitCode == 0 else {
805+
throw IntegrationError.assert(msg: "cat /etc/resolv.conf failed with status \(status)")
806+
}
807+
808+
guard let output = String(data: buffer.data, encoding: .utf8) else {
809+
throw IntegrationError.assert(msg: "failed to convert stdout to UTF8")
810+
}
811+
812+
guard output.contains("8.8.8.8") && output.contains("8.8.4.4") else {
813+
throw IntegrationError.assert(msg: "expected /etc/resolv.conf to contain DNS servers, got: \(output)")
814+
}
815+
}
749816
}

Sources/Integration/Suite.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,9 @@ struct IntegrationSuite: AsyncParsableCommand {
308308
Test("container copy in", testCopyIn),
309309
Test("container copy out", testCopyOut),
310310
Test("container copy large file", testCopyLargeFile),
311+
Test("container read-only rootfs", testReadOnlyRootfs),
312+
Test("container read-only rootfs hosts file", testReadOnlyRootfsHostsFileWritten),
313+
Test("container read-only rootfs DNS", testReadOnlyRootfsDNSConfigured),
311314

312315
// Pods
313316
Test("pod single container", testPodSingleContainer),
@@ -324,6 +327,8 @@ struct IntegrationSuite: AsyncParsableCommand {
324327
Test("pod container PID namespace isolation", testPodContainerPIDNamespaceIsolation),
325328
Test("pod container independent resource limits", testPodContainerIndependentResourceLimits),
326329
Test("pod shared PID namespace", testPodSharedPIDNamespace),
330+
Test("pod read-only rootfs", testPodReadOnlyRootfs),
331+
Test("pod read-only rootfs DNS", testPodReadOnlyRootfsDNSConfigured),
327332
]
328333

329334
let passed: Atomic<Int> = Atomic(0)

0 commit comments

Comments
 (0)