From 4fd7b0ea9d4d7b3d32790b8e6be257acb5fb71f2 Mon Sep 17 00:00:00 2001 From: saehejkang <20051028+saehejkang@users.noreply.github.com> Date: Tue, 2 Jun 2026 22:41:43 -0700 Subject: [PATCH] add live flag to container export --- .../Container/ContainerExport.swift | 5 +- .../RuntimeLinuxHelper+Start.swift | 1 + .../Client/ContainerClient.swift | 3 +- .../ContainerAPIService/Client/XPC+.swift | 2 + .../Server/Containers/ContainersHarness.swift | 3 +- .../Server/Containers/ContainersService.swift | 27 ++++++++++- .../Runtime/RuntimeClient/RuntimeClient.swift | 23 +++++++++ .../Runtime/RuntimeClient/RuntimeKeys.swift | 5 ++ .../Runtime/RuntimeClient/RuntimeRoutes.swift | 2 + .../RuntimeLinux/Server/RuntimeService.swift | 47 +++++++++++++++++++ .../Containers/TestCLIExport.swift | 30 ++++++++++++ Tests/CLITests/Utilities/CLITest.swift | 12 +++-- docs/command-reference.md | 5 +- 13 files changed, 154 insertions(+), 11 deletions(-) diff --git a/Sources/ContainerCommands/Container/ContainerExport.swift b/Sources/ContainerCommands/Container/ContainerExport.swift index a7394f268..05ef65b0d 100644 --- a/Sources/ContainerCommands/Container/ContainerExport.swift +++ b/Sources/ContainerCommands/Container/ContainerExport.swift @@ -40,6 +40,9 @@ extension Application { }) var output: String? + @Flag(name: .long, help: "Export a container while it is running") + var live: Bool = false + @Argument(help: "container ID") var id: String @@ -53,7 +56,7 @@ extension Application { } let archive = tempDir.appendingPathComponent("archive.tar") - try await client.export(id: id, archive: archive) + try await client.export(id: id, archive: archive, live: live) if output == nil { guard let fileHandle = try? FileHandle(forReadingFrom: archive) else { diff --git a/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper+Start.swift b/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper+Start.swift index 3c7938b8e..de8889b75 100644 --- a/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper+Start.swift +++ b/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper+Start.swift @@ -104,6 +104,7 @@ extension RuntimeLinuxHelper { RuntimeRoutes.dial.rawValue: XPCServer.route(server.dial), RuntimeRoutes.shutdown.rawValue: XPCServer.route(server.shutdown), RuntimeRoutes.statistics.rawValue: XPCServer.route(server.statistics), + RuntimeRoutes.filesystemOperation.rawValue: XPCServer.route(server.filesystemOperation), RuntimeRoutes.copyIn.rawValue: XPCServer.route(server.copyIn), RuntimeRoutes.copyOut.rawValue: XPCServer.route(server.copyOut), ], diff --git a/Sources/Services/ContainerAPIService/Client/ContainerClient.swift b/Sources/Services/ContainerAPIService/Client/ContainerClient.swift index 291e8cf2f..ba57fa2d1 100644 --- a/Sources/Services/ContainerAPIService/Client/ContainerClient.swift +++ b/Sources/Services/ContainerAPIService/Client/ContainerClient.swift @@ -373,10 +373,11 @@ public struct ContainerClient: Sendable { } } - public func export(id: String, archive: URL) async throws { + public func export(id: String, archive: URL, live: Bool = false) async throws { let request = XPCMessage(route: .containerExport) request.set(key: .id, value: id) request.set(key: .archive, value: archive.absolutePath()) + request.set(key: .live, value: live) do { try await xpcClient.send(request) diff --git a/Sources/Services/ContainerAPIService/Client/XPC+.swift b/Sources/Services/ContainerAPIService/Client/XPC+.swift index 499b82b84..192da0860 100644 --- a/Sources/Services/ContainerAPIService/Client/XPC+.swift +++ b/Sources/Services/ContainerAPIService/Client/XPC+.swift @@ -58,6 +58,8 @@ public enum XPCKeys: String { case plugin /// Archive path to export rootfs case archive + /// Whether to allow export from a running container + case live /// Special-case environment variables recomputed on each container start case dynamicEnv diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift index d7da46e3d..8509c489a 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift @@ -381,9 +381,10 @@ public struct ContainersHarness: Sendable { message: "archive cannot be empty" ) } + let live = message.bool(key: .live) let archiveUrl = URL(fileURLWithPath: archive) - try await service.exportRootfs(id: id, archive: archiveUrl) + try await service.exportRootfs(id: id, archive: archiveUrl, live: live) return message.reply() } } diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift index 827268504..3bbf7fc0c 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift @@ -870,17 +870,40 @@ public actor ContainersService { return FileManager.default.allocatedSize(of: URL(fileURLWithPath: containerPath)) } - public func exportRootfs(id: String, archive: URL) async throws { + public func exportRootfs(id: String, archive: URL, live: Bool = false) async throws { self.log.debug("\(#function)") let state = try self._getContainerState(id: id) - guard state.snapshot.status == .stopped else { + guard state.snapshot.status == .stopped || (live && state.snapshot.status == .running) else { throw ContainerizationError(.invalidState, message: "container is not stopped") } let path = self.containerRoot.appendingPathComponent(id) let bundle = ContainerResource.Bundle(path: path) let rootfs = bundle.containerRootfsBlock + + if live { + let client = try state.getClient() + try await client.filesystemOperation(operation: .freeze, path: "/") + do { + try EXT4.EXT4Reader(blockDevice: FilePath(rootfs)).export(archive: FilePath(archive)) + } catch { + do { + try await client.filesystemOperation(operation: .thaw, path: "/") + } catch { + self.log.error( + "failed to thaw filesystem after live export error", + metadata: [ + "id": "\(id)", + "error": "\(error)", + ]) + } + throw error + } + try await client.filesystemOperation(operation: .thaw, path: "/") + return + } + try EXT4.EXT4Reader(blockDevice: FilePath(rootfs)).export(archive: FilePath(archive)) } diff --git a/Sources/Services/Runtime/RuntimeClient/RuntimeClient.swift b/Sources/Services/Runtime/RuntimeClient/RuntimeClient.swift index 3040902d9..f3a319266 100644 --- a/Sources/Services/Runtime/RuntimeClient/RuntimeClient.swift +++ b/Sources/Services/Runtime/RuntimeClient/RuntimeClient.swift @@ -319,6 +319,29 @@ extension RuntimeClient { } } + public func filesystemOperation(operation: FilesystemOperation, path: String) async throws { + let request = XPCMessage(route: RuntimeRoutes.filesystemOperation.rawValue) + request.set( + key: RuntimeKeys.filesystemOperation.rawValue, + value: { + switch operation { + case .freeze: "freeze" + case .thaw: "thaw" + } + }()) + request.set(key: RuntimeKeys.filesystemPath.rawValue, value: path) + + do { + try await self.client.send(request, responseTimeout: .seconds(300)) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to perform filesystem operation in container \(self.id)", + cause: error + ) + } + } + public func statistics() async throws -> ContainerStats { let request = XPCMessage(route: RuntimeRoutes.statistics.rawValue) diff --git a/Sources/Services/Runtime/RuntimeClient/RuntimeKeys.swift b/Sources/Services/Runtime/RuntimeClient/RuntimeKeys.swift index b472d9dd1..c62c3fe85 100644 --- a/Sources/Services/Runtime/RuntimeClient/RuntimeKeys.swift +++ b/Sources/Services/Runtime/RuntimeClient/RuntimeKeys.swift @@ -52,6 +52,11 @@ public enum RuntimeKeys: String { /// Special-case environment variables recomputed on each container start case dynamicEnv + /// Filesystem operation to perform inside the guest. + case filesystemOperation + /// Target path for a guest filesystem operation. + case filesystemPath + /// Per-network connection info passed to the runtime so it can allocate directly. case networkBootstrapInfos } diff --git a/Sources/Services/Runtime/RuntimeClient/RuntimeRoutes.swift b/Sources/Services/Runtime/RuntimeClient/RuntimeRoutes.swift index cda604387..754e07163 100644 --- a/Sources/Services/Runtime/RuntimeClient/RuntimeRoutes.swift +++ b/Sources/Services/Runtime/RuntimeClient/RuntimeRoutes.swift @@ -52,6 +52,8 @@ public enum RuntimeRoutes: String { case exec = "com.apple.container.runtime/exec" // MARK: - File Management + /// Perform a filesystem operation inside the container. + case filesystemOperation = "com.apple.container.runtime/filesystemOperation" /// Copy a file or directory into the container. case copyIn = "com.apple.container.runtime/copyIn" /// Copy a file or directory out of the container. diff --git a/Sources/Services/RuntimeLinux/Server/RuntimeService.swift b/Sources/Services/RuntimeLinux/Server/RuntimeService.swift index a84fd21d3..58244382b 100644 --- a/Sources/Services/RuntimeLinux/Server/RuntimeService.swift +++ b/Sources/Services/RuntimeLinux/Server/RuntimeService.swift @@ -757,6 +757,39 @@ public actor RuntimeService { } } + /// Perform a filesystem operation inside the container. + /// + /// - Parameters: + /// - message: An XPC message with the following parameters: + /// - filesystemOperation: The operation to perform. + /// - filesystemPath: The target path inside the container. + /// + /// - Returns: An XPC message with no parameters. + @Sendable + public func filesystemOperation(_ message: XPCMessage) async throws -> XPCMessage { + self.log.info("`filesystemOperation` xpc handler") + switch self.state { + case .running, .booted: + let operation = try message.filesystemOperation() + guard let path = message.string(key: RuntimeKeys.filesystemPath.rawValue) else { + throw ContainerizationError( + .invalidArgument, + message: "no filesystem path supplied for filesystemOperation" + ) + } + + let ctr = try getContainer() + try await ctr.container.filesystemOperation(operation: operation, path: path) + + return message.reply() + default: + throw ContainerizationError( + .invalidState, + message: "cannot perform filesystem operation: container is not running" + ) + } + } + /// Dial a vsock port on the virtual machine. /// /// - Parameters: @@ -1299,6 +1332,20 @@ extension XPCMessage { return dynamicEnv } + fileprivate func filesystemOperation() throws -> FilesystemOperation { + guard let operation = self.string(key: RuntimeKeys.filesystemOperation.rawValue) else { + throw ContainerizationError(.invalidArgument, message: "empty filesystem operation") + } + switch operation { + case "freeze": + return .freeze + case "thaw": + return .thaw + default: + throw ContainerizationError(.invalidArgument, message: "invalid filesystem operation \(operation)") + } + } + } extension ContainerResource.Bundle { diff --git a/Tests/CLITests/Subcommands/Containers/TestCLIExport.swift b/Tests/CLITests/Subcommands/Containers/TestCLIExport.swift index 4f939b41f..474d3ac61 100644 --- a/Tests/CLITests/Subcommands/Containers/TestCLIExport.swift +++ b/Tests/CLITests/Subcommands/Containers/TestCLIExport.swift @@ -65,4 +65,34 @@ class TestCLIExportCommand: CLITest { #expect(foo.fileType == .regular) #expect(String(data: fooData, encoding: .utf8)?.starts(with: mustBeInImage) ?? false) } + + @Test func testExportCommandLive() throws { + let name = getTestName() + try doLongRun(name: name, autoRemove: false) + defer { + try? doStop(name: name) + try? doRemove(name: name) + } + + let mustBeInImage = "must-be-in-image-live" + _ = try doExec(name: name, cmd: ["sh", "-c", "echo \(mustBeInImage) > /foo-live"]) + + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: tempDir) + } + let tempFile = tempDir.appendingPathComponent(UUID().uuidString) + + try doExport(name: name, filepath: tempFile.path(), live: true) + + let attrs = try FileManager.default.attributesOfItem(atPath: tempFile.path()) + let fileSize = attrs[.size] as! UInt64 + #expect(fileSize > 0) + + let reader = try ArchiveReader(file: tempFile) + let (fooLive, fooLiveData) = try reader.extractFile(path: "/foo-live") + #expect(fooLive.fileType == .regular) + #expect(String(data: fooLiveData, encoding: .utf8)?.starts(with: mustBeInImage) ?? false) + } } diff --git a/Tests/CLITests/Utilities/CLITest.swift b/Tests/CLITests/Utilities/CLITest.swift index 2e45579bd..1ab268259 100644 --- a/Tests/CLITests/Utilities/CLITest.swift +++ b/Tests/CLITests/Utilities/CLITest.swift @@ -622,13 +622,17 @@ class CLITest { .flatMap { (key, val) in ["-e", "\(key)=\(val)"] } } - func doExport(name: String, filepath: String) throws { - let (_, _, error, status) = try run(arguments: [ + func doExport(name: String, filepath: String, live: Bool = false) throws { + var args = [ "export", - name, "-o", filepath, - ]) + ] + if live { + args.append("--live") + } + args.append(name) + let (_, _, error, status) = try run(arguments: args) if status != 0 { throw CLIError.executionFailed("command failed: \(error)") } diff --git a/docs/command-reference.md b/docs/command-reference.md index 373dbd149..04400ff0c 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -365,12 +365,12 @@ container exec [--detach] [--env ...] [--env-file ...] [--gid < ### `container export` -Exports a stopped container's filesystem as a tar archive. The container must be stopped before exporting. If no output file is specified, the tar stream is written to stdout. +Exports a container's filesystem as a tar archive. By default, the container must be stopped before exporting. Use `--live` to export while the container is running. If no output file is specified, the tar stream is written to stdout. **Usage** ```bash -container export [-o ] [--debug] +container export [-o ] [--live] [--debug] ``` **Arguments** @@ -380,6 +380,7 @@ container export [-o ] [--debug] **Options** * `-o, --output `: Pathname for the saved container filesystem (defaults to stdout) +* `--live`: Export a container while it is running **Examples**