diff --git a/Sources/ContainerCommands/Container/ContainerExport.swift b/Sources/ContainerCommands/Container/ContainerExport.swift index a7394f26..c0c21aae 100644 --- a/Sources/ContainerCommands/Container/ContainerExport.swift +++ b/Sources/ContainerCommands/Container/ContainerExport.swift @@ -67,7 +67,11 @@ extension Application { } try fileHandle.close() } else { - try FileManager.default.moveItem(at: archive, to: URL(fileURLWithPath: output!)) + let outputURL = URL(fileURLWithPath: output!) + if FileManager.default.fileExists(atPath: outputURL.path(percentEncoded: false)) { + try FileManager.default.removeItem(at: outputURL) + } + try FileManager.default.moveItem(at: archive, to: outputURL) } } } diff --git a/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper+Start.swift b/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper+Start.swift index 3c7938b8..d4c049b4 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/Server/Containers/ContainersService.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift index 41f33d49..81612495 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift @@ -901,14 +901,22 @@ public actor ContainersService { self.log.debug("\(#function)") let state = try self._getContainerState(id: id) - guard state.snapshot.status == .stopped 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 - try EXT4.EXT4Reader(blockDevice: FilePath(rootfs)).export(archive: FilePath(archive)) + + switch state.snapshot.status { + case .running: + 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)) + case .stopped: + try EXT4.EXT4Reader(blockDevice: FilePath(rootfs)).export(archive: FilePath(archive)) + default: + throw ContainerizationError(.invalidState, message: "container must be running or stopped") + } } private func handleContainerExit(id: String, code: ExitStatus? = nil) async throws { diff --git a/Sources/Services/Runtime/RuntimeClient/RuntimeClient.swift b/Sources/Services/Runtime/RuntimeClient/RuntimeClient.swift index 087a8120..32a4db06 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 b472d9dd..1d3548cf 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 cda60438..bbe1485f 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 062cef93..4778b228 100644 --- a/Sources/Services/RuntimeLinux/Server/RuntimeService.swift +++ b/Sources/Services/RuntimeLinux/Server/RuntimeService.swift @@ -782,6 +782,72 @@ public actor RuntimeService { } } + /// Snapshot the container's root filesystem. + /// + /// When the container is running, freeze/thaw around the copy for consistency. + /// When it is not running, copy directly without freeze/thaw. + /// + /// - 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() + let shouldFreeze = self.state == .running + + if shouldFreeze { + try await ctr.container.filesystemOperation(operation: .freeze, path: "/") + } + + do { + try FileManager.default.copyItem(atPath: imagePath, toPath: destinationPath) + } catch { + if shouldFreeze { + 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 + } + + if shouldFreeze { + 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 125c0a42..a851136c 100644 --- a/Tests/IntegrationTests/Containers/TestCLIExportCommand.swift +++ b/Tests/IntegrationTests/Containers/TestCLIExportCommand.swift @@ -53,4 +53,28 @@ struct TestCLIExportCommand { } } } + + @Test func testExportCommandRunningContainerAndOverwrite() async throws { + try await ContainerFixture.with { f in + let image = WarmupImage.alpine320.rawValue + 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", name, "-o", exportPath.string]).check() + try f.run(["export", 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 c7d85480..50f7cf21 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -381,7 +381,7 @@ 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. For running containers, export automatically takes a runtime snapshot to preserve consistency. If no output file is specified, the tar stream is written to stdout. **Usage**