Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion Sources/ContainerCommands/Container/ContainerExport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions Sources/Services/ContainerAPIService/Client/XPC+.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}

Expand Down
16 changes: 16 additions & 0 deletions Sources/Services/Runtime/RuntimeClient/RuntimeClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 2 additions & 0 deletions Sources/Services/Runtime/RuntimeClient/RuntimeKeys.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions Sources/Services/Runtime/RuntimeClient/RuntimeRoutes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
61 changes: 61 additions & 0 deletions Sources/Services/RuntimeLinux/Server/RuntimeService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
23 changes: 23 additions & 0 deletions Tests/IntegrationTests/Containers/TestCLIExportCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
}
5 changes: 3 additions & 2 deletions docs/command-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -381,12 +381,12 @@ container exec [--detach] [--env <env> ...] [--env-file <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 <output>] [--debug] <container-id>
container export [-o <output>] [--live] [--debug] <container-id>
```

**Arguments**
Expand All @@ -396,6 +396,7 @@ container export [-o <output>] [--debug] <container-id>
**Options**

* `-o, --output <output>`: Pathname for the saved container filesystem (defaults to stdout)
* `--live`: Export a container while it is running

**Examples**

Expand Down