Support exporting current container to image (#1172)

This PR adds a command to export container to image (#1019).

## Type of Change
- [ ] Bug fix
- [X] New feature  
- [ ] Breaking change
- [ ] Documentation update

## Motivation and Context
Users can export their container to image.

## Testing
- [X] Tested locally
- [ ] Added/updated tests
- [ ] Added/updated docs
This commit is contained in:
jwhur
2026-02-18 14:21:06 -08:00
committed by GitHub
parent c791052741
commit d100ec9172
11 changed files with 206 additions and 1 deletions
+1
View File
@@ -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",
@@ -63,6 +63,7 @@ public struct Application: AsyncLoggableCommand {
ContainerStats.self,
ContainerStop.self,
ContainerPrune.self,
ContainerExport.self,
]
),
CommandGroup(
@@ -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()
}
}
}
@@ -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)
}
@@ -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
}
@@ -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
)
}
}
}
@@ -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
@@ -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()
}
}
@@ -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)
@@ -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")
}
}
+12
View File
@@ -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)")
}
}
}