mirror of
https://github.com/apple/container.git
synced 2026-07-23 09:51:30 +00:00
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:
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user