diff --git a/Makefile b/Makefile index c79d6995d..3d01b93a1 100644 --- a/Makefile +++ b/Makefile @@ -64,7 +64,7 @@ LIBARCHIVE_UPSTREAM_REPO := https://github.com/libarchive/libarchive LIBARCHIVE_UPSTREAM_VERSION := v3.7.7 LIBARCHIVE_LOCAL_DIR := workdir/libarchive -KATA_BINARY_PACKAGE := https://github.com/kata-containers/kata-containers/releases/download/3.17.0/kata-static-3.17.0-arm64.tar.xz +KATA_BINARY_PACKAGE := https://github.com/kata-containers/kata-containers/releases/download/3.28.0/kata-static-3.28.0-arm64.tar.zst CLOUD_HYPERVISOR_URL := https://github.com/cloud-hypervisor/cloud-hypervisor/releases/download/v52.0/cloud-hypervisor-static-aarch64 # SHA256 of the v52.0 aarch64 static binary (verified locally from the # upstream release artifact). Bump alongside CLOUD_HYPERVISOR_URL. @@ -410,11 +410,11 @@ integration: .PHONY: fetch-default-kernel fetch-default-kernel: @mkdir -p .local/ bin/ -ifeq (,$(wildcard .local/kata.tar.gz)) - @curl -SsL -o .local/kata.tar.gz ${KATA_BINARY_PACKAGE} +ifeq (,$(wildcard .local/kata.tar)) + @curl -SsL -o .local/kata.tar ${KATA_BINARY_PACKAGE} endif ifeq (,$(wildcard .local/vmlinux-$(KERNEL_ARCH))) - @tar -zxf .local/kata.tar.gz -C .local/ --strip-components=1 + @tar -xf .local/kata.tar -C .local/ --strip-components=1 @cp -L .local/opt/kata/share/kata-containers/vmlinux.container .local/vmlinux-$(KERNEL_ARCH) endif ifeq (,$(wildcard bin/vmlinux-$(KERNEL_ARCH))) diff --git a/README.md b/README.md index f658b08dd..372a872a1 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,16 @@ After building, run basic and integration tests: make test integration ``` +Tests that only apply to Linux are compiled out on macOS, so `make test` passes +without running them. Run those with: + +```bash +make linux-test +``` + +which runs the same tests inside the Linux dev container, as the Linux build +workflow does. + A kernel is required to run integration tests. If you do not have a kernel locally, a default kernel can be fetched using the `make fetch-default-kernel` target. diff --git a/Sources/ContainerizationOS/Mount/Mount.swift b/Sources/ContainerizationOS/Mount/Mount.swift index b4f406173..b1e0b094c 100644 --- a/Sources/ContainerizationOS/Mount/Mount.swift +++ b/Sources/ContainerizationOS/Mount/Mount.swift @@ -101,13 +101,29 @@ extension Mount { "sync": .init(false, MS_SYNCHRONOUS), ] + /// Mount propagation options, which the kernel takes in a call of their + /// own rather than alongside the mount whose propagation they set. + /// https://github.com/opencontainers/runc/blob/main/libcontainer/specconv/spec_linux.go + internal static let propagationDictionary: [String: Int32] = [ + "private": Int32(MS_PRIVATE), + "rprivate": Int32(MS_PRIVATE) | Int32(MS_REC), + "shared": Int32(MS_SHARED), + "rshared": Int32(MS_SHARED) | Int32(MS_REC), + "slave": Int32(MS_SLAVE), + "rslave": Int32(MS_SLAVE) | Int32(MS_REC), + "unbindable": Int32(MS_UNBINDABLE), + "runbindable": Int32(MS_UNBINDABLE) | Int32(MS_REC), + ] + internal struct MountOptions { var flags: Int32 var data: [String] + var propagation: [Int32] - public init(_ flags: Int32 = 0, data: [String] = []) { + public init(_ flags: Int32 = 0, data: [String] = [], propagation: [Int32] = []) { self.flags = flags self.data = data + self.propagation = propagation } } @@ -304,10 +320,7 @@ extension Mount { throw Error.validation("data string exceeds page size (\(dataString.count) > \(pageSize))") } - let propagationTypes: Int32 = Int32(MS_SHARED) | Int32(MS_PRIVATE) | Int32(MS_SLAVE) | Int32(MS_UNBINDABLE) - - // Ensure propagation type change flags aren't included in other calls. - let originalFlags = opts.flags & ~(propagationTypes) + let originalFlags = opts.flags // When targetResolved is true, the target path has already been securely // resolved and the mount point created by secureResolveInRoot. Skip @@ -352,10 +365,8 @@ extension Mount { } } - if opts.flags & propagationTypes != 0 { - // Change the propagation type. - let pflags = propagationTypes | Int32(MS_REC) | Int32(MS_SILENT) - guard _mount("", target, "", UInt(opts.flags & pflags), "") == 0 else { + for propagation in opts.propagation { + guard _mount("", target, "", UInt(propagation | Int32(MS_SILENT)), "") == 0 else { throw Error.errno(errno, "failed propagation change mount") } } @@ -379,7 +390,9 @@ extension Mount { private func parseMountOptions() -> MountOptions { var mountOpts = MountOptions() for option in self.options { - if let entry = Self.flagsDictionary[option], entry.flag != 0 { + if let propagation = Self.propagationDictionary[option] { + mountOpts.propagation.append(propagation) + } else if let entry = Self.flagsDictionary[option], entry.flag != 0 { if entry.clear { mountOpts.flags &= ~Int32(entry.flag) } else { diff --git a/Sources/Integration/ContainerTests.swift b/Sources/Integration/ContainerTests.swift index dd2a91656..f29bc4e1b 100644 --- a/Sources/Integration/ContainerTests.swift +++ b/Sources/Integration/ContainerTests.swift @@ -137,6 +137,39 @@ extension IntegrationSuite { } } + /// A mount's propagation is set by a mount call of its own, so asking for + /// it has to survive the option parsing rather than land in the mount data. + func testContainerMountPropagation() async throws { + let id = "test-container-mount-propagation" + let bs = try await bootstrap(id) + + let buffer = BufferWriter() + let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in + config.mounts.append( + .any( + type: "tmpfs", source: "tmpfs", destination: "/shared-mount", + options: ["rw", "nosuid", "nodev", "rshared"])) + config.process.arguments = [ + "/bin/sh", "-c", "grep ' /shared-mount ' /proc/self/mountinfo", + ] + 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: "mount is missing from mountinfo") + } + // Only a mount the kernel was told to share carries a peer group. + let info = String(data: buffer.data, encoding: .utf8) ?? "" + guard info.contains("shared:") else { + throw IntegrationError.assert(msg: "mount was not made shared: '\(info)'") + } + } + func testProcessEchoHi() async throws { let id = "test-process-echo-hi" let bs = try await bootstrap(id) diff --git a/Sources/Integration/Suite.swift b/Sources/Integration/Suite.swift index 9dae84351..efbe6bd01 100644 --- a/Sources/Integration/Suite.swift +++ b/Sources/Integration/Suite.swift @@ -259,12 +259,16 @@ struct IntegrationSuite: AsyncParsableCommand { // a ~2GB rootfs and a ~512MB initfs, so without reaping the dev // container fills its CoW layer in ~10 tests. if self.maxConcurrency == 1 { - let preserve = fsPath.absolutePath() + // contentsOfDirectory reports paths under /private, while testDir is + // built from FileManager's /var view of the same directory, so both + // sides are resolved before comparing. + let preserve = fsPath.resolvingSymlinksInPathWithPrivate().absolutePath() if let entries = try? FileManager.default.contentsOfDirectory( at: Self.testDir, includingPropertiesForKeys: nil ) { - for url in entries where url.absolutePath() != preserve { + for url in entries + where url.resolvingSymlinksInPathWithPrivate().absolutePath() != preserve { try? FileManager.default.removeItem(at: url) } } @@ -422,6 +426,7 @@ struct IntegrationSuite: AsyncParsableCommand { // Process basics Test("process true", testProcessTrue), Test("process false", testProcessFalse), + Test("container mount propagation", testContainerMountPropagation), Test("process echo hi", testProcessEchoHi), Test("process no executable", testProcessNoExecutable), Test("process user", testProcessUser),