400 lines
14 KiB
Swift
400 lines
14 KiB
Swift
//===----------------------------------------------------------------------===//
|
|
// 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 ContainerAPIClient
|
|
import ContainerPersistence
|
|
import ContainerResource
|
|
import ContainerXPC
|
|
import ContainerizationError
|
|
import ContainerizationOCI
|
|
import Foundation
|
|
import TerminalProgress
|
|
|
|
/// A client for interacting with the container machine API server.
|
|
public struct MachineClient: Sendable {
|
|
public static let serviceIdentifier = "com.apple.container.core.machine-apiserver"
|
|
|
|
public static func machineConfigFromFlags(
|
|
id: String,
|
|
image: String,
|
|
management: Flags.MachineManagement,
|
|
registry: Flags.Registry,
|
|
imageFetch: Flags.ImageFetch,
|
|
containerSystemConfig: ContainerSystemConfig,
|
|
progressUpdate: @escaping ProgressUpdateHandler
|
|
) async throws -> (MachineConfiguration, MachineResources?) {
|
|
var requestedPlatform = Parser.platform(os: management.os, arch: management.arch)
|
|
// Prefer --platform
|
|
if let platform = management.platform {
|
|
requestedPlatform = try Parser.platform(from: platform)
|
|
}
|
|
let scheme = try RequestScheme(registry.scheme)
|
|
|
|
await progressUpdate([
|
|
.setDescription("Fetching image"),
|
|
.setItemsName("blobs"),
|
|
])
|
|
let taskManager = ProgressTaskCoordinator()
|
|
let fetchTask = await taskManager.startTask()
|
|
let img = try await ClientImage.fetch(
|
|
reference: image,
|
|
platform: requestedPlatform,
|
|
scheme: scheme,
|
|
containerSystemConfig: containerSystemConfig,
|
|
progressUpdate: ProgressTaskCoordinator.handler(for: fetchTask, from: progressUpdate),
|
|
maxConcurrentDownloads: imageFetch.maxConcurrentDownloads
|
|
)
|
|
|
|
// Unpack a fetched image before use
|
|
await progressUpdate([
|
|
.setDescription("Unpacking image"),
|
|
.setItemsName("entries"),
|
|
])
|
|
let unpackTask = await taskManager.startTask()
|
|
try await img.getCreateSnapshot(
|
|
platform: requestedPlatform,
|
|
progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: progressUpdate))
|
|
|
|
let userSetup = UserSetup(
|
|
username: NSUserName(),
|
|
uid: getuid(),
|
|
gid: getgid())
|
|
|
|
let config = try MachineConfiguration(
|
|
id: id,
|
|
image: img.description,
|
|
platform: requestedPlatform,
|
|
userSetup: userSetup)
|
|
|
|
let resources = try? await Self.fetchMachineArtifact(
|
|
reference: img.reference, platform: requestedPlatform, scheme: scheme)
|
|
|
|
return (config, resources)
|
|
}
|
|
|
|
private let xpcClient: XPCClient
|
|
|
|
public init() {
|
|
self.xpcClient = XPCClient(service: Self.serviceIdentifier)
|
|
}
|
|
|
|
@discardableResult
|
|
private func xpcSend(
|
|
message: XPCMessage,
|
|
timeout: Duration? = .seconds(10)
|
|
) async throws -> XPCMessage {
|
|
try await xpcClient.send(message, responseTimeout: timeout)
|
|
}
|
|
|
|
/// List container machines
|
|
public func list() async throws -> [MachineSnapshot] {
|
|
do {
|
|
let request = XPCMessage(route: MachineRoutes.listMachine.rawValue)
|
|
|
|
let response = try await xpcSend(
|
|
message: request,
|
|
timeout: .seconds(10)
|
|
)
|
|
let data = response.dataNoCopy(key: MachineKeys.machines.rawValue)
|
|
guard let data else {
|
|
return []
|
|
}
|
|
return try JSONDecoder().decode([MachineSnapshot].self, from: data)
|
|
} catch {
|
|
throw ContainerizationError(
|
|
.internalError,
|
|
message: "failed to list container machines",
|
|
cause: error
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Create a new container machine with the given configuration
|
|
public func create(
|
|
configuration: MachineConfiguration,
|
|
resources: MachineResources?,
|
|
bootConfig: MachineConfig,
|
|
) async throws {
|
|
do {
|
|
let request = XPCMessage(route: MachineRoutes.createMachine.rawValue)
|
|
|
|
let config = try JSONEncoder().encode(configuration)
|
|
request.set(key: MachineKeys.machineConfig.rawValue, value: config)
|
|
|
|
if let resources {
|
|
let data = try JSONEncoder().encode(resources)
|
|
request.set(key: MachineKeys.machineResources.rawValue, value: data)
|
|
}
|
|
|
|
let bootData = try JSONEncoder().encode(bootConfig)
|
|
request.set(key: MachineKeys.bootConfig.rawValue, value: bootData)
|
|
|
|
let _ = try await xpcSend(message: request)
|
|
} catch {
|
|
throw ContainerizationError(
|
|
.internalError,
|
|
message: "failed to create container machine",
|
|
cause: error
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Delete the container machine along with any resources.
|
|
public func delete(id: String) async throws {
|
|
do {
|
|
let request = XPCMessage(route: MachineRoutes.deleteMachine.rawValue)
|
|
request.set(key: MachineKeys.id.rawValue, value: id)
|
|
|
|
let _ = try await xpcSend(message: request, timeout: .seconds(15))
|
|
} catch {
|
|
throw ContainerizationError(
|
|
.internalError,
|
|
message: "failed to delete container machine",
|
|
cause: error
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Get the default container machine.
|
|
public func getDefault() async throws -> String? {
|
|
do {
|
|
let request = XPCMessage(route: MachineRoutes.getDefault.rawValue)
|
|
|
|
let response = try await xpcSend(message: request)
|
|
let id = response.string(key: MachineKeys.id.rawValue)
|
|
guard let id else {
|
|
return nil
|
|
}
|
|
|
|
return id
|
|
} catch {
|
|
throw ContainerizationError(
|
|
.internalError,
|
|
message: "failed to get the default container machine",
|
|
cause: error
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Set a default container machine.
|
|
public func setDefault(id: String) async throws {
|
|
do {
|
|
let request = XPCMessage(route: MachineRoutes.setDefault.rawValue)
|
|
request.set(key: MachineKeys.id.rawValue, value: id)
|
|
|
|
let _ = try await xpcSend(message: request)
|
|
} catch {
|
|
throw ContainerizationError(
|
|
.internalError,
|
|
message: "failed to set a default container machine",
|
|
cause: error
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Boot a container machine.
|
|
public func boot(id: String?, dynamicEnv: [String: String] = [:]) async throws -> MachineSnapshot {
|
|
do {
|
|
let request = XPCMessage(route: MachineRoutes.bootMachine.rawValue)
|
|
if let id {
|
|
request.set(key: MachineKeys.id.rawValue, value: id)
|
|
}
|
|
|
|
let dynamicEnvData = try JSONEncoder().encode(dynamicEnv)
|
|
request.set(key: MachineKeys.dynamicEnv.rawValue, value: dynamicEnvData)
|
|
|
|
let response = try await xpcSend(message: request)
|
|
guard let data = response.dataNoCopy(key: MachineKeys.snapshot.rawValue) else {
|
|
throw ContainerizationError(
|
|
.internalError,
|
|
message: "missing snapshot in response"
|
|
)
|
|
}
|
|
return try JSONDecoder().decode(MachineSnapshot.self, from: data)
|
|
} catch {
|
|
throw ContainerizationError(
|
|
.internalError,
|
|
message: "failed to boot container machine",
|
|
cause: error
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Stop a running container machine.
|
|
public func stop(id: String) async throws {
|
|
do {
|
|
let request = XPCMessage(route: MachineRoutes.stopMachine.rawValue)
|
|
request.set(key: MachineKeys.id.rawValue, value: id)
|
|
|
|
let _ = try await xpcSend(message: request, timeout: .seconds(30))
|
|
} catch {
|
|
throw ContainerizationError(
|
|
.internalError,
|
|
message: "failed to stop container machine",
|
|
cause: error
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Set boot-time config for a container machine.
|
|
public func setConfig(id: String, bootConfig: MachineConfig) async throws {
|
|
do {
|
|
let request = XPCMessage(route: MachineRoutes.setConfig.rawValue)
|
|
request.set(key: MachineKeys.id.rawValue, value: id)
|
|
let data = try JSONEncoder().encode(bootConfig)
|
|
request.set(key: MachineKeys.bootConfig.rawValue, value: data)
|
|
let _ = try await xpcSend(message: request)
|
|
} catch {
|
|
throw ContainerizationError(
|
|
.internalError,
|
|
message: "failed to set container machine config",
|
|
cause: error
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Inspect a container machine and return its snapshot.
|
|
public func inspect(id: String) async throws -> MachineSnapshot {
|
|
do {
|
|
let request = XPCMessage(route: MachineRoutes.inspectMachine.rawValue)
|
|
request.set(key: MachineKeys.id.rawValue, value: id)
|
|
|
|
let response = try await xpcSend(message: request)
|
|
guard let data = response.dataNoCopy(key: MachineKeys.snapshot.rawValue) else {
|
|
throw ContainerizationError(
|
|
.internalError,
|
|
message: "missing snapshot in response"
|
|
)
|
|
}
|
|
return try JSONDecoder().decode(MachineSnapshot.self, from: data)
|
|
} catch {
|
|
throw ContainerizationError(
|
|
.internalError,
|
|
message: "failed to inspect container machine",
|
|
cause: error
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Get the log file handles for a container machine.
|
|
public func logs(id: String) async throws -> [FileHandle] {
|
|
do {
|
|
let request = XPCMessage(route: MachineRoutes.logsMachine.rawValue)
|
|
request.set(key: MachineKeys.id.rawValue, value: id)
|
|
|
|
let response = try await xpcSend(message: request)
|
|
let fds = response.fileHandles(key: MachineKeys.logs.rawValue)
|
|
guard let fds else {
|
|
throw ContainerizationError(
|
|
.internalError,
|
|
message: "no log fds returned"
|
|
)
|
|
}
|
|
return fds
|
|
} catch {
|
|
throw ContainerizationError(
|
|
.internalError,
|
|
message: "failed to get logs for container machine \(id)",
|
|
cause: error
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: Container machine artifact fetching
|
|
|
|
extension MachineClient {
|
|
/// Fetch machine metadata from an OCI artifact attached to an image via the referrers API.
|
|
///
|
|
/// Returns `nil` if no artifact is found or the registry doesn't support referrers.
|
|
static func fetchMachineArtifact(
|
|
reference: String,
|
|
platform: Platform,
|
|
scheme: RequestScheme
|
|
) async throws -> MachineResources? {
|
|
let ref = try Reference.parse(reference)
|
|
guard let domain = ref.resolvedDomain else {
|
|
return nil
|
|
}
|
|
|
|
let insecure = try scheme.schemeFor(host: ref.resolvedDomain ?? "", internalDnsDomain: nil) == .http
|
|
|
|
// Look up credentials from keychain
|
|
let keychain = KeychainHelper(securityDomain: Constants.keychainID)
|
|
let auth = try? keychain.lookup(hostname: domain)
|
|
|
|
let client = try RegistryClient(reference: reference, insecure: insecure, auth: auth)
|
|
let name = ref.path
|
|
|
|
// Resolve the image reference to get the manifest digest.
|
|
// We need the platform-specific manifest digest, not the index digest.
|
|
let tag = ref.digest ?? ref.tag ?? "latest"
|
|
let topDescriptor = try await client.resolve(name: name, tag: tag)
|
|
|
|
// If the top-level is an index, find the platform-specific manifest
|
|
let manifestDigest: String
|
|
switch topDescriptor.mediaType {
|
|
case MediaTypes.index, MediaTypes.dockerManifest:
|
|
let index: Index = try await client.fetch(name: name, descriptor: topDescriptor)
|
|
guard let platformDesc = index.manifests.first(where: { $0.platform == platform }) else {
|
|
return nil
|
|
}
|
|
manifestDigest = platformDesc.digest
|
|
case MediaTypes.imageManifest:
|
|
manifestDigest = topDescriptor.digest
|
|
default:
|
|
return nil
|
|
}
|
|
|
|
// Query referrers API for container machine config artifacts
|
|
let referrersIndex = try await client.referrers(
|
|
name: name,
|
|
digest: manifestDigest,
|
|
artifactType: MachineResources.configMediaType
|
|
)
|
|
|
|
guard let artifactDesc = referrersIndex.manifests.first else {
|
|
return nil
|
|
}
|
|
|
|
// Fetch the artifact manifest
|
|
let artifactManifest: Manifest = try await client.fetch(name: name, descriptor: artifactDesc)
|
|
|
|
// Extract metadata JSON and setup script from artifact layers
|
|
var resources: MachineResources?
|
|
var setupScript: String?
|
|
|
|
for layer in artifactManifest.layers {
|
|
if layer.mediaType == MachineResources.configMediaType {
|
|
let data = try await client.fetchData(name: name, descriptor: layer)
|
|
resources = try JSONDecoder().decode(MachineResources.self, from: data)
|
|
} else if layer.mediaType == MachineResources.setupScriptMediaType {
|
|
let data = try await client.fetchData(name: name, descriptor: layer)
|
|
let script = String(decoding: data, as: UTF8.self)
|
|
if !script.isEmpty {
|
|
setupScript = script
|
|
}
|
|
}
|
|
}
|
|
|
|
if var resources, let setupScript {
|
|
resources.setupScript = setupScript
|
|
}
|
|
|
|
return resources
|
|
}
|
|
}
|