From d353155b56e60aadeddbeb1b72b219816c3dae0b 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 1/3] 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/TestCLIExportCommand.swift | 23 +++++++++ docs/command-reference.md | 5 +- 12 files changed, 139 insertions(+), 7 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 b1b64a66e..54707d47a 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 41f33d491..cc3d1bf12 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift @@ -897,17 +897,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 087a81204..7db3df871 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 2320ff455..03fdc64a4 100644 --- a/Sources/Services/RuntimeLinux/Server/RuntimeService.swift +++ b/Sources/Services/RuntimeLinux/Server/RuntimeService.swift @@ -772,6 +772,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: @@ -1324,6 +1357,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/IntegrationTests/Containers/TestCLIExportCommand.swift b/Tests/IntegrationTests/Containers/TestCLIExportCommand.swift index b3c3ec293..f1e48ee4c 100644 --- a/Tests/IntegrationTests/Containers/TestCLIExportCommand.swift +++ b/Tests/IntegrationTests/Containers/TestCLIExportCommand.swift @@ -52,4 +52,27 @@ struct TestCLIExportCommand { } } } + + @Test func testExportCommandLive() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image, autoRemove: false) { name in + let mustBeInImage = "must-be-in-image-live" + try f.doExec(name, cmd: ["sh", "-c", "echo \(mustBeInImage) > /foo-live"]) + + let exportPath = f.testDir.appending("export-live.tar") + try f.run(["export", "--live", name, "-o", exportPath.string]).check() + + let exportURL = URL(filePath: exportPath.string) + let attrs = try FileManager.default.attributesOfItem(atPath: exportPath.string) + let fileSize = attrs[.size] as! UInt64 + #expect(fileSize > 0) + + let reader = try ArchiveReader(file: exportURL) + 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/docs/command-reference.md b/docs/command-reference.md index 4066e3a38..f29c96496 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -381,12 +381,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** @@ -396,6 +396,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** From bdc46e859b434bccfbc5065200af6af24e13a37a Mon Sep 17 00:00:00 2001 From: saehejkang <20051028+saehejkang@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:49:28 -0700 Subject: [PATCH 2/3] plumbing for service apis + sandbox --- Sources/APIServer/APIServer+Start.swift | 2 + .../Client/ContainerClient.swift | 32 ++++++++++ .../ContainerAPIService/Client/XPC+.swift | 2 + .../Server/Containers/ContainersHarness.swift | 26 ++++++++ .../Server/Containers/ContainersService.swift | 62 +++++++++++++++++++ .../Runtime/RuntimeClient/RuntimeClient.swift | 1 + 6 files changed, 125 insertions(+) diff --git a/Sources/APIServer/APIServer+Start.swift b/Sources/APIServer/APIServer+Start.swift index b6038b550..19945efe3 100644 --- a/Sources/APIServer/APIServer+Start.swift +++ b/Sources/APIServer/APIServer+Start.swift @@ -297,6 +297,8 @@ extension APIServer { routes[XPCRoute.containerBootstrap] = XPCServer.route(harness.bootstrap) routes[XPCRoute.containerDial] = XPCServer.route(harness.dial) routes[XPCRoute.containerStop] = XPCServer.route(harness.stop) + routes[XPCRoute.containerFreeze] = XPCServer.route(harness.freeze) + routes[XPCRoute.containerThaw] = XPCServer.route(harness.thaw) routes[XPCRoute.containerStartProcess] = XPCServer.route(harness.startProcess) routes[XPCRoute.containerCreateProcess] = XPCServer.route(harness.createProcess) routes[XPCRoute.containerResize] = XPCServer.route(harness.resize) diff --git a/Sources/Services/ContainerAPIService/Client/ContainerClient.swift b/Sources/Services/ContainerAPIService/Client/ContainerClient.swift index 54707d47a..d1ec610a5 100644 --- a/Sources/Services/ContainerAPIService/Client/ContainerClient.swift +++ b/Sources/Services/ContainerAPIService/Client/ContainerClient.swift @@ -193,6 +193,38 @@ public struct ContainerClient: Sendable { } } + /// Freeze writes on the container root filesystem. + public func freeze(id: String) async throws { + do { + let request = XPCMessage(route: .containerFreeze) + request.set(key: .id, value: id) + + try await xpcClient.send(request) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to freeze container", + cause: error + ) + } + } + + /// Thaw writes on the container root filesystem. + public func thaw(id: String) async throws { + do { + let request = XPCMessage(route: .containerThaw) + request.set(key: .id, value: id) + + try await xpcClient.send(request) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to thaw container", + cause: error + ) + } + } + /// Delete the container along with any resources. public func delete(id: String, force: Bool = false) async throws { do { diff --git a/Sources/Services/ContainerAPIService/Client/XPC+.swift b/Sources/Services/ContainerAPIService/Client/XPC+.swift index 192da0860..546973da6 100644 --- a/Sources/Services/ContainerAPIService/Client/XPC+.swift +++ b/Sources/Services/ContainerAPIService/Client/XPC+.swift @@ -155,6 +155,8 @@ public enum XPCRoute: String { case containerWait case containerDelete case containerStop + case containerFreeze + case containerThaw case containerDial case containerResize case containerKill diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift index 8509c489a..b8bfdeccd 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift @@ -77,6 +77,32 @@ public struct ContainersHarness: Sendable { return message.reply() } + @Sendable + public func freeze(_ message: XPCMessage) async throws -> XPCMessage { + let id = message.string(key: .id) + guard let id else { + throw ContainerizationError( + .invalidArgument, + message: "id cannot be empty" + ) + } + try await service.freeze(id: id) + return message.reply() + } + + @Sendable + public func thaw(_ message: XPCMessage) async throws -> XPCMessage { + let id = message.string(key: .id) + guard let id else { + throw ContainerizationError( + .invalidArgument, + message: "id cannot be empty" + ) + } + try await service.thaw(id: id) + return message.reply() + } + @Sendable public func dial(_ message: XPCMessage) async throws -> XPCMessage { let id = message.string(key: .id) diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift index cc3d1bf12..2e47f0f32 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift @@ -645,6 +645,68 @@ public actor ContainersService { try await handleContainerExit(id: id) } + /// Freeze writes on the root filesystem of a running container. + public func freeze(id: String) async throws { + log.debug( + "ContainersService: enter", + metadata: [ + "func": "\(#function)", + "id": "\(id)", + ] + ) + defer { + log.debug( + "ContainersService: exit", + metadata: [ + "func": "\(#function)", + "id": "\(id)", + ] + ) + } + + let state = try self._getContainerState(id: id) + guard state.snapshot.status == .running else { + throw ContainerizationError( + .invalidState, + message: "container \(id) is \(state.snapshot.status) and cannot be frozen" + ) + } + + let client = try state.getClient() + try await client.filesystemOperation(operation: .freeze, path: "/") + } + + /// Thaw writes on the root filesystem of a running container. + public func thaw(id: String) async throws { + log.debug( + "ContainersService: enter", + metadata: [ + "func": "\(#function)", + "id": "\(id)", + ] + ) + defer { + log.debug( + "ContainersService: exit", + metadata: [ + "func": "\(#function)", + "id": "\(id)", + ] + ) + } + + let state = try self._getContainerState(id: id) + guard state.snapshot.status == .running else { + throw ContainerizationError( + .invalidState, + message: "container \(id) is \(state.snapshot.status) and cannot be thawed" + ) + } + + let client = try state.getClient() + try await client.filesystemOperation(operation: .thaw, path: "/") + } + public func dial(id: String, port: UInt32) async throws -> FileHandle { log.debug( "ContainersService: enter", diff --git a/Sources/Services/Runtime/RuntimeClient/RuntimeClient.swift b/Sources/Services/Runtime/RuntimeClient/RuntimeClient.swift index 7db3df871..79a0d02c6 100644 --- a/Sources/Services/Runtime/RuntimeClient/RuntimeClient.swift +++ b/Sources/Services/Runtime/RuntimeClient/RuntimeClient.swift @@ -327,6 +327,7 @@ extension RuntimeClient { switch operation { case .freeze: "freeze" case .thaw: "thaw" + case .trim: "trim" } }()) request.set(key: RuntimeKeys.filesystemPath.rawValue, value: path) From c8a9cdf4e234f3ec0ad6f8b0560fcd71803b160e Mon Sep 17 00:00:00 2001 From: saehejkang <20051028+saehejkang@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:09:17 -0700 Subject: [PATCH 3/3] remove api server work + add runtime api --- Sources/APIServer/APIServer+Start.swift | 2 - .../RuntimeLinuxHelper+Start.swift | 2 +- .../Client/ContainerClient.swift | 32 ------- .../ContainerAPIService/Client/XPC+.swift | 2 - .../Server/Containers/ContainersHarness.swift | 26 ------ .../Server/Containers/ContainersService.swift | 83 +------------------ .../Runtime/RuntimeClient/RuntimeClient.swift | 18 ++-- .../Runtime/RuntimeClient/RuntimeKeys.swift | 7 +- .../Runtime/RuntimeClient/RuntimeRoutes.swift | 4 +- .../RuntimeLinux/Server/RuntimeService.swift | 62 ++++++++------ 10 files changed, 52 insertions(+), 186 deletions(-) diff --git a/Sources/APIServer/APIServer+Start.swift b/Sources/APIServer/APIServer+Start.swift index 19945efe3..b6038b550 100644 --- a/Sources/APIServer/APIServer+Start.swift +++ b/Sources/APIServer/APIServer+Start.swift @@ -297,8 +297,6 @@ extension APIServer { routes[XPCRoute.containerBootstrap] = XPCServer.route(harness.bootstrap) routes[XPCRoute.containerDial] = XPCServer.route(harness.dial) routes[XPCRoute.containerStop] = XPCServer.route(harness.stop) - routes[XPCRoute.containerFreeze] = XPCServer.route(harness.freeze) - routes[XPCRoute.containerThaw] = XPCServer.route(harness.thaw) routes[XPCRoute.containerStartProcess] = XPCServer.route(harness.startProcess) routes[XPCRoute.containerCreateProcess] = XPCServer.route(harness.createProcess) routes[XPCRoute.containerResize] = XPCServer.route(harness.resize) diff --git a/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper+Start.swift b/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper+Start.swift index de8889b75..d4c049b4b 100644 --- a/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper+Start.swift +++ b/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper+Start.swift @@ -104,9 +104,9 @@ 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), + RuntimeRoutes.snapshotDisk.rawValue: XPCServer.route(server.snapshotDisk), ], log: log ) diff --git a/Sources/Services/ContainerAPIService/Client/ContainerClient.swift b/Sources/Services/ContainerAPIService/Client/ContainerClient.swift index d1ec610a5..54707d47a 100644 --- a/Sources/Services/ContainerAPIService/Client/ContainerClient.swift +++ b/Sources/Services/ContainerAPIService/Client/ContainerClient.swift @@ -193,38 +193,6 @@ public struct ContainerClient: Sendable { } } - /// Freeze writes on the container root filesystem. - public func freeze(id: String) async throws { - do { - let request = XPCMessage(route: .containerFreeze) - request.set(key: .id, value: id) - - try await xpcClient.send(request) - } catch { - throw ContainerizationError( - .internalError, - message: "failed to freeze container", - cause: error - ) - } - } - - /// Thaw writes on the container root filesystem. - public func thaw(id: String) async throws { - do { - let request = XPCMessage(route: .containerThaw) - request.set(key: .id, value: id) - - try await xpcClient.send(request) - } catch { - throw ContainerizationError( - .internalError, - message: "failed to thaw container", - cause: error - ) - } - } - /// Delete the container along with any resources. public func delete(id: String, force: Bool = false) async throws { do { diff --git a/Sources/Services/ContainerAPIService/Client/XPC+.swift b/Sources/Services/ContainerAPIService/Client/XPC+.swift index 546973da6..192da0860 100644 --- a/Sources/Services/ContainerAPIService/Client/XPC+.swift +++ b/Sources/Services/ContainerAPIService/Client/XPC+.swift @@ -155,8 +155,6 @@ public enum XPCRoute: String { case containerWait case containerDelete case containerStop - case containerFreeze - case containerThaw case containerDial case containerResize case containerKill diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift index b8bfdeccd..8509c489a 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift @@ -77,32 +77,6 @@ public struct ContainersHarness: Sendable { return message.reply() } - @Sendable - public func freeze(_ message: XPCMessage) async throws -> XPCMessage { - let id = message.string(key: .id) - guard let id else { - throw ContainerizationError( - .invalidArgument, - message: "id cannot be empty" - ) - } - try await service.freeze(id: id) - return message.reply() - } - - @Sendable - public func thaw(_ message: XPCMessage) async throws -> XPCMessage { - let id = message.string(key: .id) - guard let id else { - throw ContainerizationError( - .invalidArgument, - message: "id cannot be empty" - ) - } - try await service.thaw(id: id) - return message.reply() - } - @Sendable public func dial(_ message: XPCMessage) async throws -> XPCMessage { let id = message.string(key: .id) diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift index 2e47f0f32..043a24189 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift @@ -645,68 +645,6 @@ public actor ContainersService { try await handleContainerExit(id: id) } - /// Freeze writes on the root filesystem of a running container. - public func freeze(id: String) async throws { - log.debug( - "ContainersService: enter", - metadata: [ - "func": "\(#function)", - "id": "\(id)", - ] - ) - defer { - log.debug( - "ContainersService: exit", - metadata: [ - "func": "\(#function)", - "id": "\(id)", - ] - ) - } - - let state = try self._getContainerState(id: id) - guard state.snapshot.status == .running else { - throw ContainerizationError( - .invalidState, - message: "container \(id) is \(state.snapshot.status) and cannot be frozen" - ) - } - - let client = try state.getClient() - try await client.filesystemOperation(operation: .freeze, path: "/") - } - - /// Thaw writes on the root filesystem of a running container. - public func thaw(id: String) async throws { - log.debug( - "ContainersService: enter", - metadata: [ - "func": "\(#function)", - "id": "\(id)", - ] - ) - defer { - log.debug( - "ContainersService: exit", - metadata: [ - "func": "\(#function)", - "id": "\(id)", - ] - ) - } - - let state = try self._getContainerState(id: id) - guard state.snapshot.status == .running else { - throw ContainerizationError( - .invalidState, - message: "container \(id) is \(state.snapshot.status) and cannot be thawed" - ) - } - - let client = try state.getClient() - try await client.filesystemOperation(operation: .thaw, path: "/") - } - public func dial(id: String, port: UInt32) async throws -> FileHandle { log.debug( "ContainersService: enter", @@ -973,23 +911,10 @@ public actor ContainersService { 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: "/") + let snapshot = rootfs.appendingPathExtension("snapshot") + defer { try? FileManager.default.removeItem(at: snapshot) } + try await client.snapshotDisk(imagePath: rootfs.path, destinationPath: snapshot.path) + try EXT4.EXT4Reader(blockDevice: FilePath(snapshot)).export(archive: FilePath(archive)) return } diff --git a/Sources/Services/Runtime/RuntimeClient/RuntimeClient.swift b/Sources/Services/Runtime/RuntimeClient/RuntimeClient.swift index 79a0d02c6..32a4db062 100644 --- a/Sources/Services/Runtime/RuntimeClient/RuntimeClient.swift +++ b/Sources/Services/Runtime/RuntimeClient/RuntimeClient.swift @@ -319,25 +319,17 @@ 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" - case .trim: "trim" - } - }()) - request.set(key: RuntimeKeys.filesystemPath.rawValue, value: path) + public func snapshotDisk(imagePath: String, destinationPath: String) async throws { + let request = XPCMessage(route: RuntimeRoutes.snapshotDisk.rawValue) + request.set(key: RuntimeKeys.imagePath.rawValue, value: imagePath) + request.set(key: RuntimeKeys.destinationPath.rawValue, value: destinationPath) do { try await self.client.send(request, responseTimeout: .seconds(300)) } catch { throw ContainerizationError( .internalError, - message: "failed to perform filesystem operation in container \(self.id)", + message: "failed to snapshot disk in container \(self.id)", cause: error ) } diff --git a/Sources/Services/Runtime/RuntimeClient/RuntimeKeys.swift b/Sources/Services/Runtime/RuntimeClient/RuntimeKeys.swift index c62c3fe85..1d3548cfe 100644 --- a/Sources/Services/Runtime/RuntimeClient/RuntimeKeys.swift +++ b/Sources/Services/Runtime/RuntimeClient/RuntimeKeys.swift @@ -48,15 +48,12 @@ public enum RuntimeKeys: String { case destinationPath case fileMode case createParents + /// Image path for snapshot operations + case imagePath /// 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 754e07163..bbe1485f4 100644 --- a/Sources/Services/Runtime/RuntimeClient/RuntimeRoutes.swift +++ b/Sources/Services/Runtime/RuntimeClient/RuntimeRoutes.swift @@ -52,10 +52,10 @@ 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. case copyOut = "com.apple.container.runtime/copyOut" + /// Snapshot the container's root filesystem to an image file. + case snapshotDisk = "com.apple.container.runtime/snapshotDisk" } diff --git a/Sources/Services/RuntimeLinux/Server/RuntimeService.swift b/Sources/Services/RuntimeLinux/Server/RuntimeService.swift index 03fdc64a4..dd7ef3fcc 100644 --- a/Sources/Services/RuntimeLinux/Server/RuntimeService.swift +++ b/Sources/Services/RuntimeLinux/Server/RuntimeService.swift @@ -772,35 +772,63 @@ public actor RuntimeService { } } - /// Perform a filesystem operation inside the container. + /// Snapshot the container's root filesystem by freezing it, cloning it to a destination image, + /// and then thawing it. This ensures the filesystem is frozen for the minimal duration. /// /// - Parameters: /// - message: An XPC message with the following parameters: - /// - filesystemOperation: The operation to perform. - /// - filesystemPath: The target path inside the container. + /// - imagePath: The path to the source filesystem image. + /// - destinationPath: The path where the snapshot will be written. /// /// - Returns: An XPC message with no parameters. @Sendable - public func filesystemOperation(_ message: XPCMessage) async throws -> XPCMessage { - self.log.info("`filesystemOperation` xpc handler") + public func snapshotDisk(_ message: XPCMessage) async throws -> XPCMessage { + self.log.info("`snapshotDisk` xpc handler") switch self.state { case .running, .booted: - let operation = try message.filesystemOperation() - guard let path = message.string(key: RuntimeKeys.filesystemPath.rawValue) else { + guard let imagePath = message.string(key: RuntimeKeys.imagePath.rawValue) else { throw ContainerizationError( .invalidArgument, - message: "no filesystem path supplied for filesystemOperation" + message: "no image path supplied for snapshotDisk" + ) + } + guard let destinationPath = message.string(key: RuntimeKeys.destinationPath.rawValue) else { + throw ContainerizationError( + .invalidArgument, + message: "no destination path supplied for snapshotDisk" ) } let ctr = try getContainer() - try await ctr.container.filesystemOperation(operation: operation, path: path) + + // Freeze the filesystem + try await ctr.container.filesystemOperation(operation: .freeze, path: "/") + + do { + // Clone the filesystem image atomically while frozen + try FileManager.default.copyItem(atPath: imagePath, toPath: destinationPath) + } catch { + // Ensure we thaw even on error + do { + try await ctr.container.filesystemOperation(operation: .thaw, path: "/") + } catch { + self.log.error( + "failed to thaw filesystem after snapshotDisk error", + metadata: [ + "error": "\(error)" + ]) + } + throw error + } + + // Thaw the filesystem + try await ctr.container.filesystemOperation(operation: .thaw, path: "/") return message.reply() default: throw ContainerizationError( .invalidState, - message: "cannot perform filesystem operation: container is not running" + message: "cannot snapshot disk: container is not running" ) } } @@ -1357,20 +1385,6 @@ 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 {