diff --git a/Package.swift b/Package.swift index 8bbfe929..f79ef4ba 100644 --- a/Package.swift +++ b/Package.swift @@ -128,6 +128,7 @@ let package = Package( .product(name: "Containerization", package: "containerization"), .product(name: "ContainerizationExtras", package: "containerization"), .product(name: "ContainerizationOS", package: "containerization"), + .product(name: "ContainerizationEXT4", package: "containerization"), .product(name: "GRPC", package: "grpc-swift"), .product(name: "Logging", package: "swift-log"), "ContainerAPIService", diff --git a/Sources/ContainerCommands/Application.swift b/Sources/ContainerCommands/Application.swift index 266ab060..ba057fb3 100644 --- a/Sources/ContainerCommands/Application.swift +++ b/Sources/ContainerCommands/Application.swift @@ -63,6 +63,7 @@ public struct Application: AsyncLoggableCommand { ContainerStats.self, ContainerStop.self, ContainerPrune.self, + ContainerExport.self, ] ), CommandGroup( diff --git a/Sources/ContainerCommands/Container/ContainerExport.swift b/Sources/ContainerCommands/Container/ContainerExport.swift new file mode 100644 index 00000000..59dd3543 --- /dev/null +++ b/Sources/ContainerCommands/Container/ContainerExport.swift @@ -0,0 +1,67 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ArgumentParser +import ContainerAPIClient +import ContainerizationError +import Foundation +import TerminalProgress + +extension Application { + public struct ContainerExport: AsyncLoggableCommand { + public init() {} + public static var configuration: CommandConfiguration { + CommandConfiguration( + commandName: "export", + abstract: "Export a container state to an image", + ) + } + + @OptionGroup + public var logOptions: Flags.Logging + + @Option(name: .long, help: "image name") + var image: String? + + @Argument(help: "container ID") + var id: String + + public func run() async throws { + let client = ContainerClient() + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: tempDir) + } + + let imageName = image ?? id + + let archive = tempDir.appendingPathComponent("archive.tar") + try await client.export(id: id, archive: archive) + + let dockerfile = """ + FROM scratch + ADD archive.tar . + """ + try dockerfile.data(using: .utf8)!.write(to: tempDir.appendingPathComponent("Dockerfile"), options: .atomic) + + let builder = try BuildCommand.parse(["-t", imageName, tempDir.absolutePath()]) + + try await builder.run() + } + } +} diff --git a/Sources/ContainerResource/Container/Bundle.swift b/Sources/ContainerResource/Container/Bundle.swift index 93c8066d..28f67cca 100644 --- a/Sources/ContainerResource/Container/Bundle.swift +++ b/Sources/ContainerResource/Container/Bundle.swift @@ -38,7 +38,7 @@ public struct Bundle: Sendable { self.path.appendingPathComponent("vminitd.log") } - private var containerRootfsBlock: URL { + public var containerRootfsBlock: URL { self.path.appendingPathComponent(Self.containerRootFsBlockFilename) } diff --git a/Sources/Helpers/APIServer/APIServer+Start.swift b/Sources/Helpers/APIServer/APIServer+Start.swift index 53175b91..175a7a84 100644 --- a/Sources/Helpers/APIServer/APIServer+Start.swift +++ b/Sources/Helpers/APIServer/APIServer+Start.swift @@ -246,6 +246,7 @@ extension APIServer { routes[XPCRoute.containerKill] = harness.kill routes[XPCRoute.containerStats] = harness.stats routes[XPCRoute.containerDiskUsage] = harness.diskUsage + routes[XPCRoute.containerExport] = harness.export return service } diff --git a/Sources/Services/ContainerAPIService/Client/ContainerClient.swift b/Sources/Services/ContainerAPIService/Client/ContainerClient.swift index 99d145bc..27a86182 100644 --- a/Sources/Services/ContainerAPIService/Client/ContainerClient.swift +++ b/Sources/Services/ContainerAPIService/Client/ContainerClient.swift @@ -321,4 +321,20 @@ public struct ContainerClient: Sendable { ) } } + + public func export(id: String, archive: URL) async throws { + let request = XPCMessage(route: .containerExport) + request.set(key: .id, value: id) + request.set(key: .archive, value: archive.absolutePath()) + + do { + try await xpcClient.send(request) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to export container", + cause: error + ) + } + } } diff --git a/Sources/Services/ContainerAPIService/Client/XPC+.swift b/Sources/Services/ContainerAPIService/Client/XPC+.swift index d20a0c95..53f09851 100644 --- a/Sources/Services/ContainerAPIService/Client/XPC+.swift +++ b/Sources/Services/ContainerAPIService/Client/XPC+.swift @@ -54,6 +54,8 @@ public enum XPCKeys: String { case pluginName case plugins case plugin + /// Archive path to export rootfs + case archive /// Health check request. case ping @@ -148,6 +150,7 @@ public enum XPCRoute: String { case containerEvent case containerStats case containerDiskUsage + case containerExport case pluginLoad case pluginGet diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift index 6fffedbe..c315c3e0 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift @@ -305,4 +305,26 @@ public struct ContainersHarness: Sendable { reply.set(key: .statistics, value: data) return reply } + + @Sendable + public func export(_ message: XPCMessage) async throws -> XPCMessage { + let id = message.string(key: .id) + guard let id else { + throw ContainerizationError( + .invalidArgument, + message: "id cannot be empty" + ) + } + let archive = message.string(key: .archive) + guard let archive else { + throw ContainerizationError( + .invalidArgument, + message: "archive cannot be empty" + ) + } + let archiveUrl = URL(fileURLWithPath: archive) + + try await service.exportRootfs(id: id, archive: archiveUrl) + return message.reply() + } } diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift index eb48d41a..edf011d9 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift @@ -21,12 +21,14 @@ import ContainerResource import ContainerSandboxServiceClient import ContainerXPC import Containerization +import ContainerizationEXT4 import ContainerizationError import ContainerizationExtras import ContainerizationOCI import ContainerizationOS import Foundation import Logging +import SystemPackage public actor ContainersService { struct ContainerState { @@ -584,6 +586,20 @@ public actor ContainersService { return Self.calculateDirectorySize(at: containerPath) } + public func exportRootfs(id: String, archive: URL) async throws { + 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)) + } + private func handleContainerExit(id: String, code: ExitStatus? = nil) async throws { try await self.lock.withLock { [self] context in try await handleContainerExit(id: id, code: code, context: context) diff --git a/Tests/CLITests/Subcommands/Containers/TestCLIExport.swift b/Tests/CLITests/Subcommands/Containers/TestCLIExport.swift new file mode 100644 index 00000000..595b9a2a --- /dev/null +++ b/Tests/CLITests/Subcommands/Containers/TestCLIExport.swift @@ -0,0 +1,66 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import Foundation +import Testing + +class TestCLIExportCommand: CLITest { + private func getTestName() -> String { + Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased() + } + + @Test func testExportCommand() 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" + _ = try doExec(name: name, cmd: ["sh", "-c", "echo \(mustBeInImage) > /foo"]) + + _ = try doExec(name: name, cmd: ["sh", "-c", "mkdir -p /parent/child"]) + let hardlinkMustRemain = "hardlink-must-remain" + _ = try doExec(name: name, cmd: ["sh", "-c", "echo \(hardlinkMustRemain) > /parent/child/bar"]) + _ = try doExec(name: name, cmd: ["sh", "-c", "ln /parent/child/bar /bar"]) + + let symlinkMustRemain = "symlink-must-remain" + _ = try doExec(name: name, cmd: ["sh", "-c", "echo \(symlinkMustRemain) > /parent/child/baz"]) + _ = try doExec(name: name, cmd: ["sh", "-c", "ln /parent/child/baz /baz"]) + + try doStop(name: name) + try doExport(name: name, image: name) + defer { + try? doRemoveImages(images: [name]) + } + + let exported = "\(name)-from-exported" + try doLongRun(name: exported, image: name) + defer { + try? doStop(name: exported) + } + + let foo = try doExec(name: exported, cmd: ["cat", "/foo"]) + #expect(foo == mustBeInImage + "\n") + + let bar = try doExec(name: exported, cmd: ["cat", "/bar"]) + #expect(bar == hardlinkMustRemain + "\n") + + let baz = try doExec(name: exported, cmd: ["cat", "/baz"]) + #expect(baz == symlinkMustRemain + "\n") + } +} diff --git a/Tests/CLITests/Utilities/CLITest.swift b/Tests/CLITests/Utilities/CLITest.swift index fad18159..02d4df17 100644 --- a/Tests/CLITests/Utilities/CLITest.swift +++ b/Tests/CLITests/Utilities/CLITest.swift @@ -583,4 +583,16 @@ class CLITest { .filter { (key, val) in proxyVars.contains(key) } .flatMap { (key, val) in ["-e", "\(key)=\(val)"] } } + + func doExport(name: String, image: String) throws { + let (_, _, error, status) = try run(arguments: [ + "export", + "--image", + image, + name, + ]) + if status != 0 { + throw CLIError.executionFailed("command failed: \(error)") + } + } }