diff --git a/Sources/Containerization/ContainerManager.swift b/Sources/Containerization/ContainerManager.swift index ef70069f..f8279d43 100644 --- a/Sources/Containerization/ContainerManager.swift +++ b/Sources/Containerization/ContainerManager.swift @@ -17,10 +17,12 @@ #if os(macOS) import ContainerizationError +import ContainerizationEXT4 import ContainerizationOCI import ContainerizationOS import Foundation import ContainerizationExtras +import SystemPackage import Virtualization import vmnet @@ -369,11 +371,14 @@ 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. + /// - writableLayerSizeInBytes: Optional size for a separate writable layer. When provided, + /// the rootfs becomes read-only and an overlayfs is used with a separate writable layer of this size. /// - readOnly: Whether to mount the root filesystem as read-only. public mutating func create( _ id: String, reference: String, rootfsSizeInBytes: UInt64 = 8.gib(), + writableLayerSizeInBytes: UInt64? = nil, readOnly: Bool = false, configuration: (inout LinuxContainer.Configuration) throws -> Void ) async throws -> LinuxContainer { @@ -382,6 +387,7 @@ public struct ContainerManager: Sendable { id, image: image, rootfsSizeInBytes: rootfsSizeInBytes, + writableLayerSizeInBytes: writableLayerSizeInBytes, readOnly: readOnly, configuration: configuration ) @@ -392,11 +398,14 @@ public struct ContainerManager: Sendable { /// - id: The container ID. /// - image: The image. /// - rootfsSizeInBytes: The size of the root filesystem in bytes. Defaults to 8 GiB. + /// - writableLayerSizeInBytes: Optional size for a separate writable layer. When provided, + /// the rootfs becomes read-only and an overlayfs is used with a separate writable layer of this size. /// - readOnly: Whether to mount the root filesystem as read-only. public mutating func create( _ id: String, image: Image, rootfsSizeInBytes: UInt64 = 8.gib(), + writableLayerSizeInBytes: UInt64? = nil, readOnly: Bool = false, configuration: (inout LinuxContainer.Configuration) throws -> Void ) async throws -> LinuxContainer { @@ -410,10 +419,21 @@ public struct ContainerManager: Sendable { if readOnly { rootfs.options.append("ro") } + + // Create writable layer if size is specified. + var writableLayer: Mount? = nil + if let writableLayerSize = writableLayerSizeInBytes { + writableLayer = try createEmptyFilesystem( + at: path.appendingPathComponent("writable.ext4"), + size: writableLayerSize + ) + } + return try await create( id, image: image, rootfs: rootfs, + writableLayer: writableLayer, configuration: configuration ) } @@ -423,16 +443,22 @@ public struct ContainerManager: Sendable { /// - id: The container ID. /// - image: The image. /// - rootfs: The root filesystem mount pointing to an existing block file. + /// The `destination` field is ignored as mounting is handled internally. + /// - writableLayer: Optional writable layer mount. When provided, an overlayfs is used with + /// rootfs as the lower layer and this as the upper layer. + /// The `destination` field is ignored as mounting is handled internally. public mutating func create( _ id: String, image: Image, rootfs: Mount, + writableLayer: Mount? = nil, configuration: (inout LinuxContainer.Configuration) throws -> Void ) async throws -> LinuxContainer { let imageConfig = try await image.config(for: .current).config return try LinuxContainer( id, rootfs: rootfs, + writableLayer: writableLayer, vmm: self.vmm ) { config in if let imageConfig { @@ -490,6 +516,21 @@ public struct ContainerManager: Sendable { throw err } } + + private func createEmptyFilesystem(at destination: URL, size: UInt64) throws -> Mount { + let path = destination.absolutePath() + guard !FileManager.default.fileExists(atPath: path) else { + throw ContainerizationError(.exists, message: "filesystem already exists at \(path)") + } + let filesystem = try EXT4.Formatter(FilePath(path), minDiskSize: size) + try filesystem.close() + return .block( + format: "ext4", + source: path, + destination: "/", + options: [] + ) + } } extension CIDRv4 { diff --git a/Sources/Containerization/LinuxContainer.swift b/Sources/Containerization/LinuxContainer.swift index f3f0baee..86215f1f 100644 --- a/Sources/Containerization/LinuxContainer.swift +++ b/Sources/Containerization/LinuxContainer.swift @@ -31,8 +31,17 @@ public final class LinuxContainer: Container, Sendable { public let id: String /// Rootfs for the container. + /// + /// Note: The `destination` field of this mount is ignored as mounting is handled internally. public let rootfs: Mount + /// Optional writable layer for the container. When provided, the rootfs + /// is mounted as the lower layer of an overlayfs, with this as the upper layer. + /// All writes will go to this layer instead of the rootfs. + /// + /// Note: The `destination` field of this mount is ignored as mounting is handled internally. + public let writableLayer: Mount? + /// Configuration for the container. public let config: Configuration @@ -238,21 +247,27 @@ public final class LinuxContainer: Container, Sendable { /// - Parameters: /// - id: The identifier for the container. /// - rootfs: The root filesystem mount containing the container image contents. + /// The `destination` field is ignored as mounting is handled internally. + /// - writableLayer: Optional writable layer mount. When provided, an overlayfs is used with + /// rootfs as the lower layer and this as the upper layer. Must be a block device. + /// The `destination` field is ignored as mounting is handled internally. /// - 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 convenience init( _ id: String, rootfs: Mount, + writableLayer: Mount? = nil, vmm: VirtualMachineManager, logger: Logger? = nil, configuration: (inout Configuration) throws -> Void ) throws { var config = Configuration() try configuration(&config) - self.init( + try self.init( id, rootfs: rootfs, + writableLayer: writableLayer, vmm: vmm, configuration: config, logger: logger @@ -264,16 +279,29 @@ public final class LinuxContainer: Container, Sendable { /// - Parameters: /// - id: The identifier for the container. /// - rootfs: The root filesystem mount containing the container image contents. + /// The `destination` field is ignored as mounting is handled internally. + /// - writableLayer: Optional writable layer mount. When provided, an overlayfs is used with + /// rootfs as the lower layer and this as the upper layer. Must be a block device. + /// The `destination` field is ignored as mounting is handled internally. /// - vmm: The virtual machine manager that will handle launching the VM for the container. /// - configuration: The container configuration specifying process, resources, networking, and other settings. /// - logger: Optional logger for container operations. public init( _ id: String, rootfs: Mount, + writableLayer: Mount? = nil, vmm: VirtualMachineManager, configuration: LinuxContainer.Configuration, logger: Logger? = nil - ) { + ) throws { + if let writableLayer { + guard writableLayer.isBlock else { + throw ContainerizationError( + .invalidArgument, + message: "writableLayer must be a block device" + ) + } + } self.id = id self.vmm = vmm self.hostVsockPorts = Atomic(0x1000_0000) @@ -282,6 +310,7 @@ public final class LinuxContainer: Container, Sendable { self.config = configuration self.state = AsyncMutex(.initialized) self.rootfs = rootfs + self.writableLayer = writableLayer } private static func createDefaultRuntimeSpec(_ id: String) -> Spec { @@ -313,7 +342,8 @@ public final class LinuxContainer: Container, Sendable { // 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") + // However, if we have a writable layer, the overlay allows writes so we don't mark it read-only. + spec.root?.readonly = self.rootfs.options.contains("ro") && self.writableLayer == nil // Resource limits. // CPU: quota/period model where period is 100ms (100,000µs) and quota is cpus * period @@ -393,6 +423,67 @@ extension LinuxContainer { config.interfaces } + private func mountRootfs( + attachments: [AttachedFilesystem], + rootfsPath: String, + agent: VirtualMachineAgent + ) async throws { + guard let rootfsAttachment = attachments.first else { + throw ContainerizationError(.notFound, message: "rootfs mount not found") + } + + if self.writableLayer != nil { + // Set up overlayfs with image as lower layer and writable layer as upper. + guard attachments.count >= 2 else { + throw ContainerizationError( + .notFound, + message: "writable layer mount not found" + ) + } + let writableAttachment = attachments[1] + + let lowerPath = "/run/container/\(self.id)/lower" + let upperMountPath = "/run/container/\(self.id)/upper" + let upperPath = "/run/container/\(self.id)/upper/diff" + let workPath = "/run/container/\(self.id)/upper/work" + + // Mount the image (lower layer) as read-only. + var lowerMount = rootfsAttachment.to + lowerMount.destination = lowerPath + if !lowerMount.options.contains("ro") { + lowerMount.options.append("ro") + } + try await agent.mount(lowerMount) + + // Mount the writable layer. + var upperMount = writableAttachment.to + upperMount.destination = upperMountPath + try await agent.mount(upperMount) + + // Create the upper and work directories inside the writable layer. + try await agent.mkdir(path: upperPath, all: true, perms: 0o755) + try await agent.mkdir(path: workPath, all: true, perms: 0o755) + + // Mount the overlay. + let overlayMount = ContainerizationOCI.Mount( + type: "overlay", + source: "overlay", + destination: rootfsPath, + options: [ + "lowerdir=\(lowerPath)", + "upperdir=\(upperPath)", + "workdir=\(workPath)", + ] + ) + try await agent.mount(overlayMount) + } else { + // No writable layer. Mount rootfs directly. + var rootfs = rootfsAttachment.to + rootfs.destination = rootfsPath + try await agent.mount(rootfs) + } + } + /// Create and start the underlying container's virtual machine /// and set up the runtime environment. The container's init process /// is NOT running afterwards. @@ -428,11 +519,17 @@ extension LinuxContainer { // This is dumb, but alas. let fileMountContextHolder = Mutex(fileMountContext) + // Build the list of mounts to attach to the VM. + var containerMounts = [modifiedRootfs] + fileMountContext.transformedMounts + if let writableLayer = self.writableLayer { + containerMounts.insert(writableLayer, at: 1) + } + let vmConfig = VMConfiguration( cpus: self.cpus, memoryInBytes: vmMemory, interfaces: self.interfaces, - mountsByID: [self.id: [modifiedRootfs] + fileMountContext.transformedMounts], + mountsByID: [self.id: containerMounts], bootLog: self.config.bootLog, nestedVirtualization: self.config.virtualization ) @@ -445,13 +542,11 @@ extension LinuxContainer { try await vm.withAgent { agent in try await agent.standardSetup() - // Mount the rootfs. - guard let attachments = vm.mounts[self.id], let rootfsAttachment = attachments.first else { + guard let attachments = vm.mounts[self.id] else { throw ContainerizationError(.notFound, message: "rootfs mount not found") } - var rootfs = rootfsAttachment.to - rootfs.destination = Self.guestRootfsPath(self.id) - try await agent.mount(rootfs) + let rootfsPath = Self.guestRootfsPath(self.id) + try await self.mountRootfs(attachments: attachments, rootfsPath: rootfsPath, agent: agent) // Mount file mount holding directories under /run. if fileMountContext.hasFileMounts { @@ -493,10 +588,10 @@ extension LinuxContainer { // Setup /etc/resolv.conf and /etc/hosts if asked for. if let dns = self.config.dns { - try await agent.configureDNS(config: dns, location: rootfs.destination) + try await agent.configureDNS(config: dns, location: rootfsPath) } if let hosts = self.config.hosts { - try await agent.configureHosts(config: hosts, location: rootfs.destination) + try await agent.configureHosts(config: hosts, location: rootfsPath) } } @@ -518,12 +613,14 @@ extension LinuxContainer { let agent = try await createdState.vm.dialAgent() do { var spec = self.generateRuntimeSpec() - // We don't need the rootfs, nor do OCI runtimes want it included. + // We don't need the rootfs (or writable layer), nor do OCI runtimes want it included. // Also filter out file mount holding directories. We'll mount those separately under /run. let containerMounts = createdState.vm.mounts[self.id] ?? [] let holdingTags = createdState.fileMountContext.holdingDirectoryTags + // Drop rootfs, and writable layer if present. + let mountsToSkip = self.writableLayer != nil ? 2 : 1 spec.mounts = - containerMounts.dropFirst() + containerMounts.dropFirst(mountsToSkip) .filter { !holdingTags.contains($0.source) } .map { $0.to } + createdState.fileMountContext.ociBindMounts() @@ -666,6 +763,14 @@ extension LinuxContainer { flags: 0 ) + // If we have a writable layer, we also need to unmount the lower and upper layers. + if self.writableLayer != nil { + let upperPath = "/run/container/\(self.id)/upper" + let lowerPath = "/run/container/\(self.id)/lower" + try await agent.umount(path: upperPath, flags: 0) + try await agent.umount(path: lowerPath, flags: 0) + } + try await agent.sync() } } catch { diff --git a/Sources/Containerization/Mount.swift b/Sources/Containerization/Mount.swift index 06fdb692..91c7de87 100644 --- a/Sources/Containerization/Mount.swift +++ b/Sources/Containerization/Mount.swift @@ -226,4 +226,12 @@ extension Mount { fileprivate var readonly: Bool { self.options.contains("ro") } + + /// Returns true if this mount is a virtio block device. + public var isBlock: Bool { + if case .virtioblk = self.runtimeOptions { + return true + } + return false + } } diff --git a/Sources/Containerization/VZVirtualMachineInstance.swift b/Sources/Containerization/VZVirtualMachineInstance.swift index 27e1b082..9e429c5a 100644 --- a/Sources/Containerization/VZVirtualMachineInstance.swift +++ b/Sources/Containerization/VZVirtualMachineInstance.swift @@ -430,12 +430,6 @@ extension VZVirtualMachineInstance.Configuration { } } -extension Mount { - var isBlock: Bool { - type == "ext4" - } -} - extension Kernel { func linuxCommandline(initialFilesystem: Mount) -> String { var args = self.commandLine.kernelArgs diff --git a/Sources/Integration/ContainerTests.swift b/Sources/Integration/ContainerTests.swift index 76f9d622..064a803e 100644 --- a/Sources/Integration/ContainerTests.swift +++ b/Sources/Integration/ContainerTests.swift @@ -16,6 +16,7 @@ import ArgumentParser import Containerization +import ContainerizationEXT4 import ContainerizationError import ContainerizationExtras import ContainerizationOCI @@ -23,6 +24,7 @@ import ContainerizationOS import Crypto import Foundation import Logging +import SystemPackage extension IntegrationSuite { func testProcessTrue() async throws { @@ -1069,7 +1071,7 @@ extension IntegrationSuite { let config = LinuxContainer.Configuration( process: LinuxProcessConfiguration(arguments: ["/bin/true"]) ) - let container = LinuxContainer( + let container = try LinuxContainer( id, rootfs: bs.rootfs, vmm: bs.vmm, @@ -2587,4 +2589,300 @@ extension IntegrationSuite { throw error } } + + func testWritableLayer() async throws { + let id = "test-writable-layer" + + let bs = try await bootstrap(id) + + let writableLayerPath = Self.testDir.appending(component: "\(id)-writable.ext4") + try? FileManager.default.removeItem(at: writableLayerPath) + let filesystem = try EXT4.Formatter(FilePath(writableLayerPath.absolutePath()), minDiskSize: 512.mib()) + try filesystem.close() + let writableLayer = Mount.block( + format: "ext4", + source: writableLayerPath.absolutePath(), + destination: "/", + options: [] + ) + + let buffer = BufferWriter() + let container = try LinuxContainer(id, rootfs: bs.rootfs, writableLayer: writableLayer, vmm: bs.vmm) { config in + // Write a file, then read it back to verify writes work + config.process.arguments = ["/bin/sh", "-c", "echo 'writable layer test' > /tmp/testfile && cat /tmp/testfile"] + config.process.stdout = buffer + 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: "process 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.trimmingCharacters(in: .whitespacesAndNewlines) == "writable layer test" else { + throw IntegrationError.assert(msg: "unexpected output: \(output)") + } + } + + func testWritableLayerPreservesLowerLayer() async throws { + let id = "test-writable-layer-preserves-lower" + + let bs = try await bootstrap(id) + + let writableLayerPath = Self.testDir.appending(component: "\(id)-writable.ext4") + try? FileManager.default.removeItem(at: writableLayerPath) + let filesystem = try EXT4.Formatter(FilePath(writableLayerPath.absolutePath()), minDiskSize: 512.mib()) + try filesystem.close() + let writableLayer = Mount.block( + format: "ext4", + source: writableLayerPath.absolutePath(), + destination: "/", + options: [] + ) + + // Get the size of /bin/sh before any modifications + let buffer1 = BufferWriter() + let container1 = try LinuxContainer("\(id)-1", rootfs: bs.rootfs, writableLayer: writableLayer, vmm: bs.vmm) { config in + // Modify a file in /bin. This should go in the writable layer. + config.process.arguments = ["/bin/sh", "-c", "ls -la /bin/sh && echo 'modified' > /bin/test-file"] + config.process.stdout = buffer1 + config.bootLog = bs.bootLog + } + + try await container1.create() + try await container1.start() + let status1 = try await container1.wait() + try await container1.stop() + + guard status1.exitCode == 0 else { + throw IntegrationError.assert(msg: "first container failed with status \(status1)") + } + + // Now run a second container with the SAME rootfs but without the writable layer + // The /bin/test-file should NOT exist because it was written to the writable layer + let buffer2 = BufferWriter() + let container2 = try LinuxContainer("\(id)-2", rootfs: bs.rootfs, vmm: bs.vmm) { config in + config.process.arguments = ["/bin/sh", "-c", "test -f /bin/test-file && echo 'exists' || echo 'not-exists'"] + config.process.stdout = buffer2 + config.bootLog = bs.bootLog + } + + try await container2.create() + try await container2.start() + let status2 = try await container2.wait() + try await container2.stop() + + guard status2.exitCode == 0 else { + throw IntegrationError.assert(msg: "second container failed with status \(status2)") + } + + guard let output2 = String(data: buffer2.data, encoding: .utf8) else { + throw IntegrationError.assert(msg: "failed to convert stdout to UTF8") + } + + guard output2.trimmingCharacters(in: .whitespacesAndNewlines) == "not-exists" else { + throw IntegrationError.assert(msg: "expected 'not-exists' but got: \(output2)") + } + } + + func testWritableLayerReadsFromLower() async throws { + let id = "test-writable-layer-reads-lower" + + let bs = try await bootstrap(id) + + let writableLayerPath = Self.testDir.appending(component: "\(id)-writable.ext4") + try? FileManager.default.removeItem(at: writableLayerPath) + let filesystem = try EXT4.Formatter(FilePath(writableLayerPath.absolutePath()), minDiskSize: 512.mib()) + try filesystem.close() + let writableLayer = Mount.block( + format: "ext4", + source: writableLayerPath.absolutePath(), + destination: "/", + options: [] + ) + + let buffer = BufferWriter() + let container = try LinuxContainer(id, rootfs: bs.rootfs, writableLayer: writableLayer, vmm: bs.vmm) { config in + config.process.arguments = ["head", "-1", "/etc/passwd"] + config.process.stdout = buffer + 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: "process failed with status \(status)") + } + + guard let output = String(data: buffer.data, encoding: .utf8) else { + throw IntegrationError.assert(msg: "failed to convert stdout to UTF8") + } + + // Alpine's first line of /etc/passwd should be root + guard output.hasPrefix("root:") else { + throw IntegrationError.assert(msg: "expected /etc/passwd to start with 'root:', got: \(output)") + } + } + + func testWritableLayerWithReadOnlyLower() async throws { + let id = "test-writable-layer-ro-lower" + + let bs = try await bootstrap(id) + var rootfs = bs.rootfs + rootfs.options.append("ro") + + let writableLayerPath = Self.testDir.appending(component: "\(id)-writable.ext4") + try? FileManager.default.removeItem(at: writableLayerPath) + let filesystem = try EXT4.Formatter(FilePath(writableLayerPath.absolutePath()), minDiskSize: 512.mib()) + try filesystem.close() + let writableLayer = Mount.block( + format: "ext4", + source: writableLayerPath.absolutePath(), + destination: "/", + options: [] + ) + + let buffer = BufferWriter() + let container = try LinuxContainer(id, rootfs: rootfs, writableLayer: writableLayer, vmm: bs.vmm) { config in + // Even though lower layer is ro, writes should succeed via overlay + config.process.arguments = ["/bin/sh", "-c", "echo 'overlay write test' > /tmp/test && cat /tmp/test"] + config.process.stdout = buffer + 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: "process 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.trimmingCharacters(in: .whitespacesAndNewlines) == "overlay write test" else { + throw IntegrationError.assert(msg: "unexpected output: \(output)") + } + } + + func testWritableLayerSize() async throws { + let id = "test-writable-layer-size" + + let bs = try await bootstrap(id) + + // Create a 1 GiB writable layer + let expectedSizeBytes: UInt64 = 1.gib() + let writableLayerPath = Self.testDir.appending(component: "\(id)-writable.ext4") + try? FileManager.default.removeItem(at: writableLayerPath) + let filesystem = try EXT4.Formatter(FilePath(writableLayerPath.absolutePath()), minDiskSize: expectedSizeBytes) + try filesystem.close() + let writableLayer = Mount.block( + format: "ext4", + source: writableLayerPath.absolutePath(), + destination: "/", + options: [] + ) + + let buffer = BufferWriter() + let container = try LinuxContainer(id, rootfs: bs.rootfs, writableLayer: writableLayer, vmm: bs.vmm) { config in + // Use df to check the available space on the root filesystem + // The overlay will report the size of the upper layer's backing store + config.process.arguments = ["/bin/sh", "-c", "df -B1 / | tail -1 | awk '{print $2}'"] + config.process.stdout = buffer + 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: "process 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 let reportedSize = UInt64(output.trimmingCharacters(in: .whitespacesAndNewlines)) else { + throw IntegrationError.assert(msg: "failed to parse df output as UInt64: \(output)") + } + + // The reported size should be close to our expected size (within 10%) + let minExpected: UInt64 = (expectedSizeBytes * 90) / 100 + let maxExpected: UInt64 = (expectedSizeBytes * 110) / 100 + + guard reportedSize >= minExpected && reportedSize <= maxExpected else { + throw IntegrationError.assert(msg: "expected size ~\(expectedSizeBytes) bytes, but df reported \(reportedSize) bytes") + } + } + + func testWritableLayerWithDNSAndHosts() async throws { + let id = "test-writable-layer-dns-hosts" + + let bs = try await bootstrap(id) + + let writableLayerPath = Self.testDir.appending(component: "\(id)-writable.ext4") + try? FileManager.default.removeItem(at: writableLayerPath) + let filesystem = try EXT4.Formatter(FilePath(writableLayerPath.absolutePath()), minDiskSize: 512.mib()) + try filesystem.close() + let writableLayer = Mount.block( + format: "ext4", + source: writableLayerPath.absolutePath(), + destination: "/", + options: [] + ) + + let buffer = BufferWriter() + let dnsEntry = "8.8.8.8" + let hostsEntry = Hosts.Entry.localHostIPV4(comment: "WritableLayerTest") + let container = try LinuxContainer(id, rootfs: bs.rootfs, writableLayer: writableLayer, vmm: bs.vmm) { config in + config.process.arguments = ["/bin/sh", "-c", "cat /etc/resolv.conf && echo '---' && cat /etc/hosts"] + config.process.stdout = buffer + config.dns = DNS(nameservers: [dnsEntry]) + config.hosts = Hosts(entries: [hostsEntry]) + 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: "process 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(dnsEntry) else { + throw IntegrationError.assert(msg: "expected /etc/resolv.conf to contain \(dnsEntry), got: \(output)") + } + + guard output.contains("WritableLayerTest") else { + throw IntegrationError.assert(msg: "expected /etc/hosts to contain our entry, got: \(output)") + } + } } diff --git a/Sources/Integration/Suite.swift b/Sources/Integration/Suite.swift index 4e93b368..bd36740c 100644 --- a/Sources/Integration/Suite.swift +++ b/Sources/Integration/Suite.swift @@ -325,6 +325,12 @@ struct IntegrationSuite: AsyncParsableCommand { Test("container read-only rootfs", testReadOnlyRootfs), Test("container read-only rootfs hosts file", testReadOnlyRootfsHostsFileWritten), Test("container read-only rootfs DNS", testReadOnlyRootfsDNSConfigured), + Test("container writable layer", testWritableLayer), + Test("container writable layer preserves lower", testWritableLayerPreservesLowerLayer), + Test("container writable layer reads from lower", testWritableLayerReadsFromLower), + Test("container writable layer with ro lower", testWritableLayerWithReadOnlyLower), + Test("container writable layer size", testWritableLayerSize), + Test("container writable layer DNS and hosts", testWritableLayerWithDNSAndHosts), Test("large stdin input", testLargeStdinInput), Test("exec large stdin input", testExecLargeStdinInput), Test("stdin explicit close", testStdinExplicitClose),