Skip to content

Commit d100ec9

Browse files
authored
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
1 parent c791052 commit d100ec9

11 files changed

Lines changed: 206 additions & 1 deletion

File tree

Package.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ let package = Package(
128128
.product(name: "Containerization", package: "containerization"),
129129
.product(name: "ContainerizationExtras", package: "containerization"),
130130
.product(name: "ContainerizationOS", package: "containerization"),
131+
.product(name: "ContainerizationEXT4", package: "containerization"),
131132
.product(name: "GRPC", package: "grpc-swift"),
132133
.product(name: "Logging", package: "swift-log"),
133134
"ContainerAPIService",

Sources/ContainerCommands/Application.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ public struct Application: AsyncLoggableCommand {
6363
ContainerStats.self,
6464
ContainerStop.self,
6565
ContainerPrune.self,
66+
ContainerExport.self,
6667
]
6768
),
6869
CommandGroup(
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
//===----------------------------------------------------------------------===//
2+
// Copyright © 2026 Apple Inc. and the container project authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// https://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
//===----------------------------------------------------------------------===//
16+
17+
import ArgumentParser
18+
import ContainerAPIClient
19+
import ContainerizationError
20+
import Foundation
21+
import TerminalProgress
22+
23+
extension Application {
24+
public struct ContainerExport: AsyncLoggableCommand {
25+
public init() {}
26+
public static var configuration: CommandConfiguration {
27+
CommandConfiguration(
28+
commandName: "export",
29+
abstract: "Export a container state to an image",
30+
)
31+
}
32+
33+
@OptionGroup
34+
public var logOptions: Flags.Logging
35+
36+
@Option(name: .long, help: "image name")
37+
var image: String?
38+
39+
@Argument(help: "container ID")
40+
var id: String
41+
42+
public func run() async throws {
43+
let client = ContainerClient()
44+
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
45+
46+
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
47+
defer {
48+
try? FileManager.default.removeItem(at: tempDir)
49+
}
50+
51+
let imageName = image ?? id
52+
53+
let archive = tempDir.appendingPathComponent("archive.tar")
54+
try await client.export(id: id, archive: archive)
55+
56+
let dockerfile = """
57+
FROM scratch
58+
ADD archive.tar .
59+
"""
60+
try dockerfile.data(using: .utf8)!.write(to: tempDir.appendingPathComponent("Dockerfile"), options: .atomic)
61+
62+
let builder = try BuildCommand.parse(["-t", imageName, tempDir.absolutePath()])
63+
64+
try await builder.run()
65+
}
66+
}
67+
}

Sources/ContainerResource/Container/Bundle.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ public struct Bundle: Sendable {
3838
self.path.appendingPathComponent("vminitd.log")
3939
}
4040

41-
private var containerRootfsBlock: URL {
41+
public var containerRootfsBlock: URL {
4242
self.path.appendingPathComponent(Self.containerRootFsBlockFilename)
4343
}
4444

Sources/Helpers/APIServer/APIServer+Start.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,7 @@ extension APIServer {
246246
routes[XPCRoute.containerKill] = harness.kill
247247
routes[XPCRoute.containerStats] = harness.stats
248248
routes[XPCRoute.containerDiskUsage] = harness.diskUsage
249+
routes[XPCRoute.containerExport] = harness.export
249250

250251
return service
251252
}

Sources/Services/ContainerAPIService/Client/ContainerClient.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,4 +321,20 @@ public struct ContainerClient: Sendable {
321321
)
322322
}
323323
}
324+
325+
public func export(id: String, archive: URL) async throws {
326+
let request = XPCMessage(route: .containerExport)
327+
request.set(key: .id, value: id)
328+
request.set(key: .archive, value: archive.absolutePath())
329+
330+
do {
331+
try await xpcClient.send(request)
332+
} catch {
333+
throw ContainerizationError(
334+
.internalError,
335+
message: "failed to export container",
336+
cause: error
337+
)
338+
}
339+
}
324340
}

Sources/Services/ContainerAPIService/Client/XPC+.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ public enum XPCKeys: String {
5454
case pluginName
5555
case plugins
5656
case plugin
57+
/// Archive path to export rootfs
58+
case archive
5759

5860
/// Health check request.
5961
case ping
@@ -148,6 +150,7 @@ public enum XPCRoute: String {
148150
case containerEvent
149151
case containerStats
150152
case containerDiskUsage
153+
case containerExport
151154

152155
case pluginLoad
153156
case pluginGet

Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,4 +305,26 @@ public struct ContainersHarness: Sendable {
305305
reply.set(key: .statistics, value: data)
306306
return reply
307307
}
308+
309+
@Sendable
310+
public func export(_ message: XPCMessage) async throws -> XPCMessage {
311+
let id = message.string(key: .id)
312+
guard let id else {
313+
throw ContainerizationError(
314+
.invalidArgument,
315+
message: "id cannot be empty"
316+
)
317+
}
318+
let archive = message.string(key: .archive)
319+
guard let archive else {
320+
throw ContainerizationError(
321+
.invalidArgument,
322+
message: "archive cannot be empty"
323+
)
324+
}
325+
let archiveUrl = URL(fileURLWithPath: archive)
326+
327+
try await service.exportRootfs(id: id, archive: archiveUrl)
328+
return message.reply()
329+
}
308330
}

Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,14 @@ import ContainerResource
2121
import ContainerSandboxServiceClient
2222
import ContainerXPC
2323
import Containerization
24+
import ContainerizationEXT4
2425
import ContainerizationError
2526
import ContainerizationExtras
2627
import ContainerizationOCI
2728
import ContainerizationOS
2829
import Foundation
2930
import Logging
31+
import SystemPackage
3032

3133
public actor ContainersService {
3234
struct ContainerState {
@@ -584,6 +586,20 @@ public actor ContainersService {
584586
return Self.calculateDirectorySize(at: containerPath)
585587
}
586588

589+
public func exportRootfs(id: String, archive: URL) async throws {
590+
self.log.debug("\(#function)")
591+
592+
let state = try self._getContainerState(id: id)
593+
guard state.snapshot.status == .stopped else {
594+
throw ContainerizationError(.invalidState, message: "container is not stopped")
595+
}
596+
597+
let path = self.containerRoot.appendingPathComponent(id)
598+
let bundle = ContainerResource.Bundle(path: path)
599+
let rootfs = bundle.containerRootfsBlock
600+
try EXT4.EXT4Reader(blockDevice: FilePath(rootfs)).export(archive: FilePath(archive))
601+
}
602+
587603
private func handleContainerExit(id: String, code: ExitStatus? = nil) async throws {
588604
try await self.lock.withLock { [self] context in
589605
try await handleContainerExit(id: id, code: code, context: context)
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
//===----------------------------------------------------------------------===//
2+
// Copyright © 2026 Apple Inc. and the container project authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// https://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
//===----------------------------------------------------------------------===//
16+
17+
import Foundation
18+
import Testing
19+
20+
class TestCLIExportCommand: CLITest {
21+
private func getTestName() -> String {
22+
Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased()
23+
}
24+
25+
@Test func testExportCommand() throws {
26+
let name = getTestName()
27+
try doLongRun(name: name, autoRemove: false)
28+
defer {
29+
try? doStop(name: name)
30+
try? doRemove(name: name)
31+
}
32+
33+
let mustBeInImage = "must-be-in-image"
34+
_ = try doExec(name: name, cmd: ["sh", "-c", "echo \(mustBeInImage) > /foo"])
35+
36+
_ = try doExec(name: name, cmd: ["sh", "-c", "mkdir -p /parent/child"])
37+
let hardlinkMustRemain = "hardlink-must-remain"
38+
_ = try doExec(name: name, cmd: ["sh", "-c", "echo \(hardlinkMustRemain) > /parent/child/bar"])
39+
_ = try doExec(name: name, cmd: ["sh", "-c", "ln /parent/child/bar /bar"])
40+
41+
let symlinkMustRemain = "symlink-must-remain"
42+
_ = try doExec(name: name, cmd: ["sh", "-c", "echo \(symlinkMustRemain) > /parent/child/baz"])
43+
_ = try doExec(name: name, cmd: ["sh", "-c", "ln /parent/child/baz /baz"])
44+
45+
try doStop(name: name)
46+
try doExport(name: name, image: name)
47+
defer {
48+
try? doRemoveImages(images: [name])
49+
}
50+
51+
let exported = "\(name)-from-exported"
52+
try doLongRun(name: exported, image: name)
53+
defer {
54+
try? doStop(name: exported)
55+
}
56+
57+
let foo = try doExec(name: exported, cmd: ["cat", "/foo"])
58+
#expect(foo == mustBeInImage + "\n")
59+
60+
let bar = try doExec(name: exported, cmd: ["cat", "/bar"])
61+
#expect(bar == hardlinkMustRemain + "\n")
62+
63+
let baz = try doExec(name: exported, cmd: ["cat", "/baz"])
64+
#expect(baz == symlinkMustRemain + "\n")
65+
}
66+
}

0 commit comments

Comments
 (0)