Files
container/Sources/cctl/ImageCommand.swift
T
Michael Crosby b438e97b93 Add cloud-hypervisor VMM backend for Linux hosts (#782)
apple/containerization currently runs containers in per-container VMs on
macOS hosts via Virtualization.framework. This adds a second VMM backend
so the same Swift orchestration layer (LinuxContainer / LinuxPod /
Vminitd gRPC contract) runs on Linux hosts via cloud-hypervisor + KVM.

**CloudHypervisor Swift package** (`Sources/CloudHypervisor/`) — a thin
client for cloud-hypervisor's REST-over-UDS API, layered on
AsyncHTTPClient. Endpoints cover VMM / VM lifecycle / hotplug (disk, fs,
net, vsock, remove-device). Cross-platform (compiles on macOS for unit
tests; consumed at runtime only by the Linux side of Containerization).

**CH backend in Containerization** — one cloud-hypervisor subprocess per
VM, gated behind `#if os(Linux)`. CHVirtualMachineManager /
CHVirtualMachineInstance mirror the VZ shape behind the existing
VirtualMachineManager / VirtualMachineInstance protocol. CHProcess and
VirtiofsdProcess manage the binaries; CHHotplugProvider handles
virtio-blk and virtio-fs runtime hotplug (with one virtiofsd per unique
source-hash tag, refcounted across containers).

**Linux host networking** — BridgeManager brings up a Linux bridge with
an IPv4 subnet and (opt-in via `--enable-nat`) iptables MASQUERADE +
scoped FORWARD rules. LinuxBridgedNetwork enslaves a fresh TAP per
container to the bridge. State is recorded under `/run/containerization`
so `cctl bridge delete` reverses exactly what create did. Bridge
teardown verifies the link kind via sysfs to refuse deleting non-bridge
interfaces.

**cctl run / bridge** — end-to-end Linux container run path (image pull,
ext4 rootfs assembly, VM boot, container exec) plus `cctl bridge
create|delete` for the host network plumbing.

**Build & dist** — `make linux-build` / `make linux-integration` build
and exercise the host side inside an apple/container `--virtualization`
dev container. `make dist-x86_64` produces a deployment tarball (cctl +
cloud-hypervisor + virtiofsd + initfs + kernel) cross-compiled from the
aarch64 dev container; pipeline documented in `docs/x86_64-build.md`.
Static-musl C deps and the Zig cross compiler are pinned by SHA256.

The host orchestrator runs as root. Per-VM runtime state lives under
`/run/containerization/ch/<UUID>` with mode 0700; UDS sockets inside are
bound with mode 0600. Vminitd's gRPC channel inherits that trust
boundary — socket-file perms are the auth.

Sandbox flags are upstream-secure by default. Two per-component opt-outs
exist for the apple/container dev-container case (where the host seccomp
profile SIGSYS-kills CH and virtiofsd):
- `CONTAINERIZATION_NO_CH_SECCOMP=1` — `cloud-hypervisor --seccomp
false`.
- `CONTAINERIZATION_NO_VIRTIOFSD_SANDBOX=1` — `virtiofsd --sandbox
none`. Each emits a one-shot `logger.warning` at process start. Legacy
alias `CONTAINERIZATION_RELAXED_SANDBOX=1` flips both. cctl spawns both
binaries with `setsid` and a minimal env allowlist (PATH / HOME /
RUST_LOG / RUST_BACKTRACE) so the parent's secrets don't leak to
children.

`make linux-integration` runs the cross-platform integration suite
against a real cloud-hypervisor VM inside the dev container. Linux runs
the cross-platform subset (`process true`/`false`/`echo hi`, virtiofs
round-trip, hotplug); the macOS suite is unchanged.

Signed-off-by: michael_crosby <michael_crosby@apple.com>
2026-07-02 11:20:22 -04:00

297 lines
12 KiB
Swift

//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the Containerization 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 Containerization
import ContainerizationArchive
import ContainerizationError
import ContainerizationExtras
import ContainerizationOCI
import Foundation
extension Application {
struct Images: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "images",
abstract: "Manage images",
subcommands: [
Get.self,
Delete.self,
Pull.self,
Tag.self,
Push.self,
Save.self,
Load.self,
]
)
func run() async throws {
let store = Application.imageStore
let images = try await store.list()
print("REFERENCE\tMEDIA TYPE\tDIGEST")
for image in images {
print("\(image.reference)\t\(image.mediaType)\t\(image.digest)")
}
}
struct Delete: AsyncParsableCommand {
@Argument var reference: String
func run() async throws {
let store = Application.imageStore
try await store.delete(reference: reference)
}
}
struct Tag: AsyncParsableCommand {
@Argument var old: String
@Argument var new: String
func run() async throws {
let store = Application.imageStore
_ = try await store.tag(existing: old, new: new)
}
}
struct Get: AsyncParsableCommand {
@Argument var reference: String
func run() async throws {
let store = Application.imageStore
let image = try await store.get(reference: reference)
let index = try await image.index()
let enc = JSONEncoder()
enc.outputFormatting = .prettyPrinted
let data = try enc.encode(ImageDisplay(reference: image.reference, index: index))
print(String(data: data, encoding: .utf8)!)
}
}
struct ImageDisplay: Codable {
let reference: String
let index: Index
}
struct Pull: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "pull",
abstract: "Pull an image's contents into a content store"
)
@Argument var ref: String
@Option(name: .customLong("platform"), help: "Platform string in the form 'os/arch/variant'. Example 'linux/arm64/v8', 'linux/amd64'") var platformString: String?
@Option(
name: .customLong("unpack-path"), help: "Path to a new directory to unpack the image into",
transform: { str in
URL(fileURLWithPath: str, relativeTo: .currentDirectory()).absoluteURL.path(percentEncoded: false)
})
var unpackPath: String?
@Flag(help: "Pull via plain text http") var http: Bool = false
func run() async throws {
let imageStore = Application.imageStore
let platform: Platform? = try {
if let platformString {
return try Platform(from: platformString)
}
return nil
}()
let reference = try Reference.parse(ref)
reference.normalize()
let normalizedReference = reference.description
if normalizedReference != ref {
print("Reference resolved to \(reference.description)")
}
var startTime = ContinuousClock.now
let image = try await Images.withAuthentication(ref: normalizedReference) { auth in
try await imageStore.pull(reference: normalizedReference, platform: platform, insecure: http, auth: auth)
}
guard let image else {
print("image pull failed")
Application.exit(withError: POSIXError(.EACCES))
}
var duration = ContinuousClock.now - startTime
print("Image pull took: \(duration)\n")
guard let unpackPath else {
return
}
guard !FileManager.default.fileExists(atPath: unpackPath) else {
throw ContainerizationError(.exists, message: "directory already exists at \(unpackPath)")
}
let unpackUrl = URL(filePath: unpackPath)
try FileManager.default.createDirectory(at: unpackUrl, withIntermediateDirectories: true)
let unpacker = EXT4Unpacker.init(blockSizeInBytes: 2.gib())
startTime = ContinuousClock.now
if let platform {
let name = platform.description.replacingOccurrences(of: "/", with: "-")
let _ = try await unpacker.unpack(image, for: platform, at: unpackUrl.appending(component: name))
} else {
for descriptor in try await image.index().manifests {
if let referenceType = descriptor.annotations?["vnd.docker.reference.type"], referenceType == "attestation-manifest" {
continue
}
guard let descPlatform = descriptor.platform else {
continue
}
let name = descPlatform.description.replacingOccurrences(of: "/", with: "-")
let _ = try await unpacker.unpack(image, for: descPlatform, at: unpackUrl.appending(component: name))
print("created snapshot for platform \(descPlatform.description)")
}
}
duration = ContinuousClock.now - startTime
print("\nUnpacking took: \(duration)")
}
}
struct Push: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "push",
abstract: "Push an image to a remote registry"
)
@Option(help: "Platform string in the form 'os/arch/variant'. Example 'linux/arm64/v8', 'linux/amd64'") var platformString: String?
@Flag(help: "Push via plain text http") var http: Bool = false
@Argument var ref: String
func run() async throws {
let imageStore = Application.imageStore
let platform: Platform? = try {
if let platformString {
return try Platform(from: platformString)
}
return nil
}()
let reference = try Reference.parse(ref)
reference.normalize()
let normalizedReference = reference.description
if normalizedReference != ref {
print("Reference resolved to \(reference.description)")
}
try await Images.withAuthentication(ref: normalizedReference) { auth in
try await imageStore.push(reference: normalizedReference, platform: platform, insecure: http, auth: auth)
}
print("image pushed")
}
}
struct Save: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "save",
abstract: "Save one or more images to a tar archive"
)
@Option(help: "Platform string in the form 'os/arch/variant'. Example 'linux/arm64/v8', 'linux/amd64'") var platform: String?
@Option(name: .shortAndLong, help: "Path to tar archive")
var output: String
@Argument var reference: [String]
func run() async throws {
var p: Platform? = nil
if let platform {
p = try Platform(from: platform)
}
let store = Application.imageStore
let tempDir = FileManager.default.uniqueTemporaryDirectory()
defer {
try? FileManager.default.removeItem(at: tempDir)
}
try await store.save(references: reference, out: tempDir, platform: p)
let writer = try ArchiveWriter(format: .pax, filter: .none, file: URL(filePath: output))
try writer.archiveDirectory(tempDir)
try writer.finishEncoding()
print("image exported")
}
}
struct Load: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "load",
abstract: "Load one or more images from a tar archive"
)
@Option(name: .shortAndLong, help: "Path to tar archive")
var input: String
func run() async throws {
let store = Application.imageStore
let tarFile = URL(fileURLWithPath: input)
let reader = try ArchiveReader(file: tarFile.absoluteURL)
let tempDir = FileManager.default.uniqueTemporaryDirectory()
defer {
try? FileManager.default.removeItem(at: tempDir)
}
let rejectedPaths = try reader.extractContents(to: tempDir)
let imported = try await store.load(from: tempDir)
for image in imported {
print("imported \(image.reference)")
}
for rejectedPath in rejectedPaths {
print("warning: skipped image archive member \(rejectedPath)")
}
}
}
private static func withAuthentication<T>(
ref: String, _ body: @Sendable @escaping (_ auth: Authentication?) async throws -> T?
) async throws -> T? {
var authentication: Authentication?
let ref = try Reference.parse(ref)
guard let host = ref.resolvedDomain else {
throw ContainerizationError(.invalidArgument, message: "no host specified in image reference")
}
authentication = Self.authenticationFromEnv(host: host)
if let authentication {
return try await body(authentication)
}
#if os(macOS)
let keychain = KeychainHelper(securityDomain: Application.keychainID)
authentication = try? keychain.lookup(hostname: host)
#endif
return try await body(authentication)
}
private static func authenticationFromEnv(host: String) -> Authentication? {
let env = ProcessInfo.processInfo.environment
guard env["REGISTRY_HOST"] == host else {
return nil
}
guard let user = env["REGISTRY_USERNAME"], let password = env["REGISTRY_TOKEN"] else {
return nil
}
return BasicAuthentication(username: user, password: password)
}
}
}