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 @@ -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),
],
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 @@ -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))
}

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

Expand Down
5 changes: 5 additions & 0 deletions Sources/Services/Runtime/RuntimeClient/RuntimeKeys.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
2 changes: 2 additions & 0 deletions Sources/Services/Runtime/RuntimeClient/RuntimeRoutes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
47 changes: 47 additions & 0 deletions Sources/Services/RuntimeLinux/Server/RuntimeService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 {
Expand Down
30 changes: 30 additions & 0 deletions Tests/CLITests/Subcommands/Containers/TestCLIExport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
12 changes: 8 additions & 4 deletions Tests/CLITests/Utilities/CLITest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
}
Expand Down
5 changes: 3 additions & 2 deletions docs/command-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -365,12 +365,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 @@ -380,6 +380,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