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..d4c049b4b 100644 --- a/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper+Start.swift +++ b/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper+Start.swift @@ -106,6 +106,7 @@ extension RuntimeLinuxHelper { RuntimeRoutes.statistics.rawValue: XPCServer.route(server.statistics), 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 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 a4d5aebd3..478d9376f 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..043a24189 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift @@ -897,17 +897,27 @@ 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() + 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 + } + 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..32a4db062 100644 --- a/Sources/Services/Runtime/RuntimeClient/RuntimeClient.swift +++ b/Sources/Services/Runtime/RuntimeClient/RuntimeClient.swift @@ -319,6 +319,22 @@ extension RuntimeClient { } } + 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 snapshot disk 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..1d3548cfe 100644 --- a/Sources/Services/Runtime/RuntimeClient/RuntimeKeys.swift +++ b/Sources/Services/Runtime/RuntimeClient/RuntimeKeys.swift @@ -48,6 +48,8 @@ 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 diff --git a/Sources/Services/Runtime/RuntimeClient/RuntimeRoutes.swift b/Sources/Services/Runtime/RuntimeClient/RuntimeRoutes.swift index cda604387..bbe1485f4 100644 --- a/Sources/Services/Runtime/RuntimeClient/RuntimeRoutes.swift +++ b/Sources/Services/Runtime/RuntimeClient/RuntimeRoutes.swift @@ -56,4 +56,6 @@ public enum RuntimeRoutes: String { 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 2320ff455..dd7ef3fcc 100644 --- a/Sources/Services/RuntimeLinux/Server/RuntimeService.swift +++ b/Sources/Services/RuntimeLinux/Server/RuntimeService.swift @@ -772,6 +772,67 @@ public actor RuntimeService { } } + /// 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: + /// - 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 snapshotDisk(_ message: XPCMessage) async throws -> XPCMessage { + self.log.info("`snapshotDisk` xpc handler") + switch self.state { + case .running, .booted: + guard let imagePath = message.string(key: RuntimeKeys.imagePath.rawValue) else { + throw ContainerizationError( + .invalidArgument, + 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() + + // 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 snapshot disk: container is not running" + ) + } + } + /// Dial a vsock port on the virtual machine. /// /// - Parameters: 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 c7d854806..35ab7bfc1 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**