From 90cd26fc47f53d05d611af67de2a5fbf8b147d11 Mon Sep 17 00:00:00 2001 From: Danny Canter Date: Sun, 28 Dec 2025 08:12:07 -0800 Subject: [PATCH] CLI: Add read-only flag to run/create Sets the rootfs for a container to read-only. --- .../ContainerResource/Container/Bundle.swift | 8 ++++++-- .../Container/ContainerConfiguration.swift | 4 ++++ .../ContainerAPIService/Client/Flags.swift | 3 +++ .../ContainerAPIService/Client/Utility.swift | 1 + .../Server/Containers/ContainersService.swift | 2 +- .../Subcommands/Run/TestCLIRunCommand.swift | 17 +++++++++++++++++ 6 files changed, 32 insertions(+), 3 deletions(-) diff --git a/Sources/ContainerResource/Container/Bundle.swift b/Sources/ContainerResource/Container/Bundle.swift index a2ba7242..999b13da 100644 --- a/Sources/ContainerResource/Container/Bundle.swift +++ b/Sources/ContainerResource/Container/Bundle.swift @@ -122,8 +122,12 @@ extension Bundle { path.appendingPathComponent(name) } - public func setContainerRootFs(cloning fs: Filesystem) throws { - let cloned = try fs.clone(to: self.containerRootfsBlock.absolutePath()) + public func setContainerRootFs(cloning fs: Filesystem, readonly: Bool = false) throws { + var mutableFs = fs + if readonly && !mutableFs.options.contains("ro") { + mutableFs.options.append("ro") + } + let cloned = try mutableFs.clone(to: self.containerRootfsBlock.absolutePath()) let fsData = try JSONEncoder().encode(cloned) try fsData.write(to: self.containerRootfsConfig) } diff --git a/Sources/ContainerResource/Container/ContainerConfiguration.swift b/Sources/ContainerResource/Container/ContainerConfiguration.swift index b0c98f79..e81d09db 100644 --- a/Sources/ContainerResource/Container/ContainerConfiguration.swift +++ b/Sources/ContainerResource/Container/ContainerConfiguration.swift @@ -49,6 +49,8 @@ public struct ContainerConfiguration: Sendable, Codable { public var virtualization: Bool = false /// Enable SSH agent socket forwarding from host to container. public var ssh: Bool = false + /// Whether to mount the rootfs as read-only. + public var readOnly: Bool = false enum CodingKeys: String, CodingKey { case id @@ -67,6 +69,7 @@ public struct ContainerConfiguration: Sendable, Codable { case runtimeHandler case virtualization case ssh + case readOnly } /// Create a configuration from the supplied Decoder, initializing missing @@ -96,6 +99,7 @@ public struct ContainerConfiguration: Sendable, Codable { runtimeHandler = try container.decodeIfPresent(String.self, forKey: .runtimeHandler) ?? "container-runtime-linux" virtualization = try container.decodeIfPresent(Bool.self, forKey: .virtualization) ?? false ssh = try container.decodeIfPresent(Bool.self, forKey: .ssh) ?? false + readOnly = try container.decodeIfPresent(Bool.self, forKey: .readOnly) ?? false } public struct DNSConfiguration: Sendable, Codable { diff --git a/Sources/Services/ContainerAPIService/Client/Flags.swift b/Sources/Services/ContainerAPIService/Client/Flags.swift index 54324829..eb9721a3 100644 --- a/Sources/Services/ContainerAPIService/Client/Flags.swift +++ b/Sources/Services/ContainerAPIService/Client/Flags.swift @@ -202,6 +202,9 @@ public struct Flags { "Expose virtualization capabilities to the container (requires host and guest support)" ) public var virtualization: Bool = false + + @Flag(name: .long, help: "Mount the container's root filesystem as read-only") + public var readOnly = false } public struct Progress: ParsableArguments { diff --git a/Sources/Services/ContainerAPIService/Client/Utility.swift b/Sources/Services/ContainerAPIService/Client/Utility.swift index 73626527..b0e168d7 100644 --- a/Sources/Services/ContainerAPIService/Client/Utility.swift +++ b/Sources/Services/ContainerAPIService/Client/Utility.swift @@ -239,6 +239,7 @@ public struct Utility { config.publishedSockets = try Parser.publishSockets(management.publishSockets) config.ssh = management.ssh + config.readOnly = management.readOnly return (config, kernel) } diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift index 119233b5..1a1e4f13 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift @@ -243,7 +243,7 @@ public actor ContainersService { do { let containerImage = ClientImage(description: configuration.image) let imageFs = try await containerImage.getCreateSnapshot(platform: configuration.platform) - try bundle.setContainerRootFs(cloning: imageFs) + try bundle.setContainerRootFs(cloning: imageFs, readonly: configuration.readOnly) try bundle.write(filename: "options.json", value: options) let snapshot = ContainerSnapshot( diff --git a/Tests/CLITests/Subcommands/Run/TestCLIRunCommand.swift b/Tests/CLITests/Subcommands/Run/TestCLIRunCommand.swift index 70f83969..7e84714a 100644 --- a/Tests/CLITests/Subcommands/Run/TestCLIRunCommand.swift +++ b/Tests/CLITests/Subcommands/Run/TestCLIRunCommand.swift @@ -639,6 +639,23 @@ class TestCLIRunCommand: CLITest { } } + @Test func testRunCommandReadOnly() throws { + do { + let name = getTestName() + try doLongRun(name: name, args: ["--read-only"]) + defer { + try? doStop(name: name) + } + // Attempt to touch a file on the read-only rootfs should fail + #expect(throws: (any Error).self) { + try doExec(name: name, cmd: ["touch", "/testfile"]) + } + } catch { + Issue.record("failed to run container \(error)") + return + } + } + func getDefaultDomain() throws -> String? { let (_, output, err, status) = try run(arguments: ["system", "property", "get", "dns.domain"]) try #require(status == 0, "default DNS domain retrieval returned status \(status): \(err)")