mirror of
https://github.com/apple/container.git
synced 2026-08-24 10:05:43 -05:00
Add container machine for managing persistent Linux VMs (#1662)
## Type of Change
- [ ] Bug fix
- [x] New feature
- [ ] Breaking change
- [ ] Documentation update
## Motivation and Context
`container` runs each workload in an ephemeral VM, so there's no
built-in way to keep a persistent Linux environment you can log into and
work in. `container machine` adds one.
A container machine is a lightweight, persistent, and integrated Linux
environments that feel like an extension of your Mac, created from
standard OCI images with a familiar UX. The login user matches your host
account with passwordless `sudo`, your home directory is mounted inside
the VM, and each machine keeps its filesystem and runs the image's own
init system (such as`systemd` or `openrc`).
```bash
container machine create alpine:3.22 --name my-machine
container machine run -n my-machine # interactive shell
container machine set -n my-machine cpus=4 memory=8G
```
Subcommands: `create`, `run`, `list` (`ls`), `inspect`, `set`,
`set-default`, `logs`, `stop`, `delete` (`rm`); `m` aliases `machine`.
Docs added to `docs/command-reference.md` (Machine Management) and
`docs/how-to.md` ("Use container machines").
## Testing
- [x] Tested locally
- [x] Added/updated tests
- [x] Added/updated docs
Signed-off-by: Raj Aryan Singh <rajaryan_singh@apple.com>
Co-authored-by: Jaewon Hur <jaewon_hur@apple.com>
Co-authored-by: John Logan <john_logan@apple.com>
Co-authored-by: Michael Crosby <michael_crosby@apple.com>
Co-authored-by: Eric Ernst <eric_ernst@apple.com>
Co-authored-by: Danny Canter <danny_canter@apple.com>
This commit is contained in:
co-authored by
Jaewon Hur
John Logan
Michael Crosby
Eric Ernst
Danny Canter
parent
1b5576312f
commit
b2994ac369
@@ -52,6 +52,12 @@ public struct Parser {
|
||||
return Int64(mb.value)
|
||||
}
|
||||
|
||||
public static func memoryStringAsBytes(_ memory: String) throws -> UInt64 {
|
||||
let ram = try Measurement.parse(parsing: memory)
|
||||
let mb = ram.converted(to: .bytes)
|
||||
return UInt64(mb.value)
|
||||
}
|
||||
|
||||
public static func user(
|
||||
user: String?, uid: UInt32?, gid: UInt32?,
|
||||
defaultUser: ProcessConfiguration.User = .id(uid: 0, gid: 0)
|
||||
|
||||
@@ -104,24 +104,32 @@ public actor ContainersService {
|
||||
do {
|
||||
let (config, options) = try Self.getContainerConfiguration(at: dir)
|
||||
if options?.autoRemove ?? false {
|
||||
log.info(
|
||||
"reap auto-remove container",
|
||||
metadata: [
|
||||
"id": "\(config.id)"
|
||||
])
|
||||
|
||||
let label = Self.fullLaunchdServiceLabel(
|
||||
runtimeName: config.runtimeHandler,
|
||||
instanceId: config.id)
|
||||
|
||||
var status: Int32 = -1
|
||||
try? ServiceManager.deregister(fullServiceLabel: label, status: &status)
|
||||
if status == 0 {
|
||||
log.info(
|
||||
"reap auto-remove container",
|
||||
if status != 0 {
|
||||
log.warning(
|
||||
"failed to deregister service",
|
||||
metadata: [
|
||||
"id": "\(config.id)"
|
||||
"id": "\(config.id)",
|
||||
"service": "\(label)",
|
||||
"status": "\(status)",
|
||||
]
|
||||
)
|
||||
|
||||
let bundle = ContainerResource.Bundle(path: dir)
|
||||
try? bundle.delete()
|
||||
continue
|
||||
}
|
||||
|
||||
let bundle = ContainerResource.Bundle(path: dir)
|
||||
try? bundle.delete()
|
||||
continue
|
||||
}
|
||||
|
||||
let state = ContainerState(
|
||||
@@ -169,6 +177,16 @@ public actor ContainersService {
|
||||
)
|
||||
}
|
||||
|
||||
let labelPatterns: [(key: String, regex: Regex<AnyRegexOutput>)] = try filters.labels.map { key, pattern in
|
||||
do {
|
||||
return (key: key, regex: try Regex(pattern))
|
||||
} catch {
|
||||
throw ContainerizationError(
|
||||
.invalidArgument, message: "failed to compile regex '\(pattern)' for \(key)",
|
||||
cause: error)
|
||||
}
|
||||
}
|
||||
|
||||
return self.containers.values.compactMap { state -> ContainerSnapshot? in
|
||||
let snapshot = state.snapshot
|
||||
|
||||
@@ -184,8 +202,10 @@ public actor ContainersService {
|
||||
}
|
||||
}
|
||||
|
||||
for (key, value) in filters.labels {
|
||||
guard snapshot.configuration.labels[key] == value else {
|
||||
for (key, regex) in labelPatterns {
|
||||
let label = snapshot.configuration.labels[key] ?? ""
|
||||
|
||||
guard label.contains(regex) else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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
|
||||
|
||||
extension Flags {
|
||||
public struct MachineManagement: ParsableArguments {
|
||||
public init() {}
|
||||
|
||||
@Option(name: .shortAndLong, help: "Set arch if image can target multiple architectures")
|
||||
public var arch: String = Arch.hostArchitecture().rawValue
|
||||
|
||||
@Option(name: .long, help: "Set OS if image can target multiple operating systems")
|
||||
public var os = "linux"
|
||||
|
||||
@Option(name: .long, help: "Platform for the image if it's multi-platform. This takes precedence over --os and --arch")
|
||||
public var platform: String?
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 ContainerPersistence
|
||||
import ContainerResource
|
||||
import ContainerizationError
|
||||
import Foundation
|
||||
import SystemPackage
|
||||
|
||||
public struct MachineBundle: Sendable {
|
||||
private static let rootfsBlockFile = FilePath.Component("rootfs.ext4")
|
||||
private static let rootfsFile = FilePath.Component("rootfs.json")
|
||||
private static let configFile = FilePath.Component("config.json")
|
||||
private static let userSetupFile = FilePath.Component("create-user.sh")
|
||||
private static let bootLogFile = FilePath.Component("vminitd.log")
|
||||
private static let stdioLogFile = FilePath.Component("stdio.log")
|
||||
|
||||
public static let sbinDirectory = FilePath.Component("sbin.machine")
|
||||
public static let initFile = FilePath.Component("init")
|
||||
public static let initializedFile = FilePath.Component("machine.initialized")
|
||||
public static let bootConfigFile = FilePath.Component("boot-config.json")
|
||||
|
||||
/// The path to the bundle
|
||||
public let path: FilePath
|
||||
|
||||
public init(path: FilePath) {
|
||||
self.path = path
|
||||
}
|
||||
|
||||
private var machineRootfsBlock: FilePath {
|
||||
self.path.appending(Self.rootfsBlockFile)
|
||||
}
|
||||
|
||||
private var machineRootfsConfig: FilePath {
|
||||
self.path.appending(Self.rootfsFile)
|
||||
}
|
||||
|
||||
public var bootLog: FilePath {
|
||||
self.path.appending(Self.bootLogFile)
|
||||
}
|
||||
|
||||
public var stdioLog: FilePath {
|
||||
self.path.appending(Self.stdioLogFile)
|
||||
}
|
||||
|
||||
public var initialized: Bool {
|
||||
let hasOne = try? String(contentsOf: URL(filePath: self.path.appending(Self.initializedFile).string), encoding: .utf8).hasPrefix("1")
|
||||
return hasOne ?? false
|
||||
}
|
||||
|
||||
public var machineRootfs: Filesystem {
|
||||
get throws {
|
||||
let data = try Data(contentsOf: URL(filePath: machineRootfsConfig.string))
|
||||
let fs = try JSONDecoder().decode(Filesystem.self, from: data)
|
||||
return fs
|
||||
}
|
||||
}
|
||||
|
||||
private var persistedConfig: PersistedMachineConfig {
|
||||
get throws {
|
||||
let configPath = self.path.appending(Self.configFile)
|
||||
let data = try Data(contentsOf: URL(filePath: configPath.string))
|
||||
if let wrapper = try? JSONDecoder().decode(PersistedMachineConfig.self, from: data) {
|
||||
return wrapper
|
||||
}
|
||||
let config = try JSONDecoder().decode(MachineConfiguration.self, from: data)
|
||||
return PersistedMachineConfig(configuration: config, createdDate: nil)
|
||||
}
|
||||
}
|
||||
|
||||
public var configuration: MachineConfiguration {
|
||||
get throws {
|
||||
try persistedConfig.configuration
|
||||
}
|
||||
}
|
||||
|
||||
public var createdDate: Date? {
|
||||
get throws {
|
||||
try persistedConfig.createdDate
|
||||
}
|
||||
}
|
||||
|
||||
public var diskSize: UInt64? {
|
||||
let values = try? URL(filePath: machineRootfsBlock.string).resourceValues(forKeys: [.totalFileAllocatedSizeKey])
|
||||
guard let allocated = values?.totalFileAllocatedSize else { return nil }
|
||||
return UInt64(allocated)
|
||||
}
|
||||
|
||||
public var bootConfig: MachineConfig {
|
||||
get throws {
|
||||
try load(filename: Self.bootConfigFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Metadata from an OCI artifact or in-image file that describes how a container machine
|
||||
/// should be configured (shell, user creation script, etc.).
|
||||
public struct MachineResources: Sendable, Codable, Equatable {
|
||||
/// The media type for container machine configuration artifacts.
|
||||
public static let configMediaType = "application/vnd.apple.container.machine.config.v1+json"
|
||||
|
||||
/// The media type for container machine user setup scripts.
|
||||
public static let setupScriptMediaType = "application/vnd.apple.container.machine.setup.v1+sh"
|
||||
|
||||
public var schemaVersion: Int
|
||||
public var shell: String?
|
||||
public var setupScript: String?
|
||||
|
||||
public init(schemaVersion: Int = 1, shell: String? = nil, setupScript: String? = nil) {
|
||||
self.schemaVersion = schemaVersion
|
||||
self.shell = shell
|
||||
self.setupScript = setupScript
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.schemaVersion = try container.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 1
|
||||
self.shell = try container.decodeIfPresent(String.self, forKey: .shell)
|
||||
self.setupScript = try container.decodeIfPresent(String.self, forKey: .setupScript)
|
||||
}
|
||||
}
|
||||
|
||||
extension MachineBundle {
|
||||
public static func create(
|
||||
path: FilePath,
|
||||
machineConfiguration: MachineConfiguration,
|
||||
resourceRoot: FilePath,
|
||||
resources: MachineResources?,
|
||||
bootConfig: MachineConfig,
|
||||
) throws -> MachineBundle {
|
||||
let fm = FileManager.default
|
||||
|
||||
try fm.createDirectory(atPath: path.string, withIntermediateDirectories: true)
|
||||
let bundle = MachineBundle(path: path)
|
||||
|
||||
let persisted = PersistedMachineConfig(configuration: machineConfiguration, createdDate: Date())
|
||||
try bundle.write(filename: Self.configFile, value: persisted)
|
||||
try bundle.write(filename: Self.bootConfigFile, value: bootConfig)
|
||||
|
||||
let sbin = path.appending(sbinDirectory)
|
||||
let initPath = sbin.appending(initFile)
|
||||
let setupScriptPath = sbin.appending(userSetupFile)
|
||||
let initializedPath = path.appending(initializedFile)
|
||||
|
||||
try fm.createDirectory(atPath: sbin.string, withIntermediateDirectories: true)
|
||||
try fm.copyItem(atPath: resourceRoot.appending(initFile).string, toPath: initPath.string)
|
||||
|
||||
if let setupScript = resources?.setupScript {
|
||||
try setupScript.write(toFile: setupScriptPath.string, atomically: true, encoding: .utf8)
|
||||
try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: setupScriptPath.string)
|
||||
} else {
|
||||
try fm.copyItem(atPath: resourceRoot.appending(userSetupFile).string, toPath: setupScriptPath.string)
|
||||
}
|
||||
|
||||
guard fm.createFile(atPath: initializedPath.string, contents: "".data(using: .utf8)) else {
|
||||
throw ContainerizationError(.internalError, message: "failed to create \(initializedPath.string)")
|
||||
}
|
||||
|
||||
return bundle
|
||||
}
|
||||
|
||||
public static func sync(path: FilePath, resourceRoot: FilePath) throws {
|
||||
let fm = FileManager.default
|
||||
|
||||
try fm.createDirectory(atPath: path.string, withIntermediateDirectories: true)
|
||||
|
||||
let sbin = path.appending(sbinDirectory)
|
||||
let initPath = sbin.appending(initFile)
|
||||
let setupScriptPath = sbin.appending(userSetupFile)
|
||||
let initializedPath = path.appending(initializedFile)
|
||||
|
||||
try fm.createDirectory(atPath: sbin.string, withIntermediateDirectories: true)
|
||||
|
||||
if !fm.fileExists(atPath: setupScriptPath.string) {
|
||||
try fm.copyItem(atPath: resourceRoot.appending(userSetupFile).string, toPath: setupScriptPath.string)
|
||||
}
|
||||
|
||||
if fm.fileExists(atPath: initPath.string) {
|
||||
try fm.removeItem(atPath: initPath.string)
|
||||
}
|
||||
try fm.copyItem(atPath: resourceRoot.appending(initFile).string, toPath: initPath.string)
|
||||
|
||||
if !fm.fileExists(atPath: initializedPath.string) {
|
||||
guard fm.createFile(atPath: initializedPath.string, contents: "".data(using: .utf8)) else {
|
||||
throw ContainerizationError(.internalError, message: "failed to create \(initializedPath.string)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MachineBundle {
|
||||
/// Set the value of the configuration for the Bundle.
|
||||
public func set(configuration: MachineConfiguration) throws {
|
||||
let existing = try? self.persistedConfig
|
||||
let persisted = PersistedMachineConfig(configuration: configuration, createdDate: existing?.createdDate)
|
||||
try write(filename: Self.configFile, value: persisted)
|
||||
}
|
||||
|
||||
/// Set the boot-time configuration for the bundle.
|
||||
public func set(bootConfig: MachineConfig) throws {
|
||||
try write(filename: Self.bootConfigFile, value: bootConfig)
|
||||
}
|
||||
|
||||
/// Return the full filepath for a named resource in the Bundle.
|
||||
public func filePath(for name: FilePath.Component) -> FilePath {
|
||||
path.appending(name)
|
||||
}
|
||||
|
||||
public func setMachineRootFs(cloning fs: Filesystem, readonly: Bool = false) throws {
|
||||
var mutableFs = fs
|
||||
if readonly && !mutableFs.options.contains("ro") {
|
||||
mutableFs.options.append("ro")
|
||||
}
|
||||
let cloned = try mutableFs.clone(to: self.machineRootfsBlock.string)
|
||||
let fsData = try JSONEncoder().encode(cloned)
|
||||
try fsData.write(to: URL(filePath: self.machineRootfsConfig.string), options: .atomic)
|
||||
}
|
||||
|
||||
/// Delete the bundle and all of the resources contained inside.
|
||||
public func delete() throws {
|
||||
try FileManager.default.removeItem(atPath: self.path.string)
|
||||
}
|
||||
|
||||
public func write(filename: FilePath.Component, value: Encodable) throws {
|
||||
try Self.write(self.path.appending(filename), value: value)
|
||||
}
|
||||
|
||||
private static func write(_ path: FilePath, value: Encodable) throws {
|
||||
let data = try JSONEncoder().encode(value)
|
||||
try data.write(to: URL(filePath: path.string), options: .atomic)
|
||||
}
|
||||
|
||||
public func load<T>(filename: FilePath.Component) throws -> T where T: Decodable {
|
||||
try load(path: self.path.appending(filename))
|
||||
}
|
||||
|
||||
private func load<T>(path: FilePath) throws -> T where T: Decodable {
|
||||
let data = try Data(contentsOf: URL(filePath: path.string))
|
||||
return try JSONDecoder().decode(T.self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
struct PersistedMachineConfig: Codable, Sendable {
|
||||
var configuration: MachineConfiguration
|
||||
var createdDate: Date?
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 ContainerResource
|
||||
import Containerization
|
||||
import ContainerizationError
|
||||
import ContainerizationOCI
|
||||
import Foundation
|
||||
|
||||
/// User configuration created during first boot provisioning.
|
||||
/// Stores the mapping between host user and container machine user.
|
||||
public struct UserSetup: Sendable, Codable, Equatable {
|
||||
public var username: String
|
||||
public var uid: UInt32
|
||||
public var gid: UInt32
|
||||
|
||||
public var home: String {
|
||||
"/home/\(username)"
|
||||
}
|
||||
|
||||
public var user: ProcessConfiguration.User {
|
||||
.id(uid: uid, gid: gid)
|
||||
}
|
||||
|
||||
public init(username: String, uid: UInt32, gid: UInt32) {
|
||||
self.username = username
|
||||
self.uid = uid
|
||||
self.gid = gid
|
||||
}
|
||||
}
|
||||
|
||||
public struct MachineConfiguration: Sendable, Codable {
|
||||
public static let containerUUIDLength = 6
|
||||
|
||||
public static let defaultDNSDomain = "machine"
|
||||
|
||||
/// Identifier for the container machine.
|
||||
public var id: String
|
||||
/// Image used to create the container machine.
|
||||
public var image: ImageDescription
|
||||
/// Platform for the container machine
|
||||
public var platform: ContainerizationOCI.Platform
|
||||
/// User setup from first boot. Nil means provisioning has not run yet.
|
||||
public var userSetup: UserSetup
|
||||
|
||||
public var user: ProcessConfiguration.User {
|
||||
userSetup.user
|
||||
}
|
||||
|
||||
public var home: String {
|
||||
userSetup.home
|
||||
}
|
||||
|
||||
public var processEnvironment: [String] {
|
||||
[
|
||||
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
|
||||
"CONTAINER_MACHINE_ID=\(id)",
|
||||
"CONTAINER_USER=\(userSetup.username)",
|
||||
"CONTAINER_HOME=\(userSetup.home)",
|
||||
"CONTAINER_UID=\(userSetup.uid)",
|
||||
"CONTAINER_GID=\(userSetup.gid)",
|
||||
]
|
||||
}
|
||||
|
||||
public var dnsName: String {
|
||||
"\(id.lowercased()).\(Self.defaultDNSDomain)"
|
||||
}
|
||||
|
||||
public var dnsHostname: String {
|
||||
"\(dnsName)."
|
||||
}
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
image: ImageDescription,
|
||||
platform: ContainerizationOCI.Platform,
|
||||
userSetup: UserSetup
|
||||
) throws {
|
||||
self.id = id
|
||||
self.image = image
|
||||
self.platform = platform
|
||||
self.userSetup = userSetup
|
||||
|
||||
try self.validate()
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
self.id = try container.decode(String.self, forKey: .id)
|
||||
self.image = try container.decode(ImageDescription.self, forKey: .image)
|
||||
self.platform = try container.decode(ContainerizationOCI.Platform.self, forKey: .platform)
|
||||
// DEPRECATED 0.11.0.0 - `decodeIfPresent` used for down-revision compatibility, remove in 0.13.0.0
|
||||
self.userSetup = try container.decodeIfPresent(UserSetup.self, forKey: .userSetup) ?? UserSetup(username: NSUserName(), uid: getuid(), gid: getgid())
|
||||
|
||||
try self.validate()
|
||||
}
|
||||
|
||||
private func validate() throws {
|
||||
let maxNameLength = LinuxContainer.maxIDLength - Self.containerUUIDLength - 1
|
||||
guard self.id.count <= maxNameLength else {
|
||||
throw ContainerizationError(.invalidArgument, message: "machine name cannot be longer than \(maxNameLength)")
|
||||
}
|
||||
|
||||
let pattern = #"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$"#
|
||||
let regex = try Regex(pattern)
|
||||
guard try regex.firstMatch(in: id.lowercased()) != nil else {
|
||||
throw ContainerizationError(
|
||||
.invalidArgument,
|
||||
message: "machine name '\(id)' must start and end with a lowercase letter or digit, and contain only lowercase letters, digits, and hyphens"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
public enum MachineKeys: String {
|
||||
/// Container machine ID.
|
||||
case id
|
||||
/// Container machine configuration.
|
||||
case machineConfig
|
||||
/// Container machine resources.
|
||||
case machineResources
|
||||
/// List of container machine snapshots.
|
||||
case machines
|
||||
/// Single container machine snapshot.
|
||||
case snapshot
|
||||
/// Boot-time configuration.
|
||||
case bootConfig
|
||||
/// File handles to logs
|
||||
case logs
|
||||
/// Special-case environment variables recomputed on container machine start
|
||||
case dynamicEnv
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
public enum MachineRoutes: String {
|
||||
/// Create a container machine.
|
||||
case createMachine
|
||||
/// Delete a container machine.
|
||||
case deleteMachine
|
||||
/// List container machines.
|
||||
case listMachine
|
||||
/// Get the default container machine.
|
||||
case getDefault
|
||||
/// Set the default container machine.
|
||||
case setDefault
|
||||
/// Boot a container machine.
|
||||
case bootMachine
|
||||
/// Stop a container machine.
|
||||
case stopMachine
|
||||
/// Inspect a container machine.
|
||||
case inspectMachine
|
||||
/// Set boot-time config for a container machine.
|
||||
case setConfig
|
||||
/// Fetch logs of a container machine.
|
||||
case logsMachine
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 ContainerPersistence
|
||||
import ContainerResource
|
||||
import ContainerizationOCI
|
||||
import Foundation
|
||||
|
||||
public struct MachineSnapshot: Codable, Sendable {
|
||||
public var configuration: MachineConfiguration
|
||||
public var status: RuntimeStatus
|
||||
public var bootConfig: MachineConfig
|
||||
public var startedDate: Date?
|
||||
public var createdDate: Date?
|
||||
public var containerId: String?
|
||||
public var ipAddress: String?
|
||||
public var diskSize: UInt64?
|
||||
|
||||
public var initialized: Bool
|
||||
|
||||
public var id: String { configuration.id }
|
||||
public var platform: ContainerizationOCI.Platform { configuration.platform }
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case configuration
|
||||
case status
|
||||
case startedDate
|
||||
case createdDate
|
||||
case containerId
|
||||
case bootConfig
|
||||
case ipAddress
|
||||
case diskSize
|
||||
case initialized
|
||||
}
|
||||
|
||||
public init(
|
||||
configuration: MachineConfiguration,
|
||||
status: RuntimeStatus,
|
||||
bootConfig: MachineConfig,
|
||||
startedDate: Date? = nil,
|
||||
createdDate: Date? = nil,
|
||||
containerId: String? = nil,
|
||||
ipAddress: String? = nil,
|
||||
diskSize: UInt64? = nil,
|
||||
initialized: Bool = false,
|
||||
) {
|
||||
self.configuration = configuration
|
||||
self.status = status
|
||||
self.bootConfig = bootConfig
|
||||
self.startedDate = startedDate
|
||||
self.createdDate = createdDate
|
||||
self.containerId = containerId
|
||||
self.ipAddress = ipAddress
|
||||
self.diskSize = diskSize
|
||||
self.initialized = initialized
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
configuration = try container.decode(MachineConfiguration.self, forKey: .configuration)
|
||||
status = try container.decode(RuntimeStatus.self, forKey: .status)
|
||||
bootConfig = try container.decode(MachineConfig.self, forKey: .bootConfig)
|
||||
startedDate = try container.decodeIfPresent(Date.self, forKey: .startedDate)
|
||||
createdDate = try container.decodeIfPresent(Date.self, forKey: .createdDate)
|
||||
containerId = try container.decodeIfPresent(String.self, forKey: .containerId)
|
||||
ipAddress = try container.decodeIfPresent(String.self, forKey: .ipAddress)
|
||||
diskSize = try container.decodeIfPresent(UInt64.self, forKey: .diskSize)
|
||||
initialized = try container.decodeIfPresent(Bool.self, forKey: .initialized) ?? false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 ContainerPersistence
|
||||
import ContainerResource
|
||||
import ContainerXPC
|
||||
import ContainerizationError
|
||||
import Foundation
|
||||
import MachineAPIClient
|
||||
|
||||
public struct MachinesHarness: Sendable {
|
||||
let service: MachinesService
|
||||
|
||||
public init(service: MachinesService) {
|
||||
self.service = service
|
||||
}
|
||||
|
||||
@Sendable
|
||||
public func create(_ message: XPCMessage) async throws -> XPCMessage {
|
||||
let machineConfig = message.dataNoCopy(key: MachineKeys.machineConfig.rawValue)
|
||||
guard let machineConfig else {
|
||||
throw ContainerizationError(
|
||||
.invalidArgument,
|
||||
message: "container machine configuration cannot be empty"
|
||||
)
|
||||
}
|
||||
|
||||
let machineResources = message.dataNoCopy(key: MachineKeys.machineResources.rawValue)
|
||||
var resources: MachineResources? = nil
|
||||
if let machineResources {
|
||||
resources = try JSONDecoder().decode(MachineResources.self, from: machineResources)
|
||||
}
|
||||
|
||||
let bootConfigData = message.dataNoCopy(key: MachineKeys.bootConfig.rawValue)
|
||||
guard let bootConfigData else {
|
||||
throw ContainerizationError(.invalidArgument, message: "bootConfig cannot be empty")
|
||||
}
|
||||
let bootConfig = try JSONDecoder().decode(MachineConfig.self, from: bootConfigData)
|
||||
|
||||
let config = try JSONDecoder().decode(MachineConfiguration.self, from: machineConfig)
|
||||
|
||||
try await service.create(configuration: config, resources: resources, bootConfig: bootConfig)
|
||||
return message.reply()
|
||||
}
|
||||
|
||||
@Sendable
|
||||
public func delete(_ message: XPCMessage) async throws -> XPCMessage {
|
||||
let id = message.string(key: MachineKeys.id.rawValue)
|
||||
guard let id else {
|
||||
throw ContainerizationError(.invalidArgument, message: "id cannot be empty")
|
||||
}
|
||||
try await service.delete(id: id)
|
||||
return message.reply()
|
||||
}
|
||||
|
||||
@Sendable
|
||||
public func list(_ message: XPCMessage) async throws -> XPCMessage {
|
||||
let machines = try await service.list()
|
||||
let data = try JSONEncoder().encode(machines)
|
||||
|
||||
let reply = message.reply()
|
||||
reply.set(key: MachineKeys.machines.rawValue, value: data)
|
||||
return reply
|
||||
}
|
||||
|
||||
@Sendable
|
||||
public func getDefault(_ message: XPCMessage) async throws -> XPCMessage {
|
||||
let id = try await service.getDefault()
|
||||
|
||||
let reply = message.reply()
|
||||
if let id {
|
||||
reply.set(key: MachineKeys.id.rawValue, value: id)
|
||||
}
|
||||
return reply
|
||||
}
|
||||
|
||||
@Sendable
|
||||
public func setDefault(_ message: XPCMessage) async throws -> XPCMessage {
|
||||
let id = message.string(key: MachineKeys.id.rawValue)
|
||||
guard let id else {
|
||||
throw ContainerizationError(.invalidArgument, message: "id cannot be empty")
|
||||
}
|
||||
try await service.setDefault(id: id)
|
||||
|
||||
return message.reply()
|
||||
}
|
||||
|
||||
@Sendable
|
||||
public func boot(_ message: XPCMessage) async throws -> XPCMessage {
|
||||
let id = message.string(key: MachineKeys.id.rawValue)
|
||||
|
||||
var dynamicEnv: [String: String] = [:]
|
||||
if let dynamicEnvData = message.dataNoCopy(key: MachineKeys.dynamicEnv.rawValue) {
|
||||
dynamicEnv = try JSONDecoder().decode([String: String].self, from: dynamicEnvData)
|
||||
}
|
||||
|
||||
let snapshot = try await service.boot(id: id, dynamicEnv: dynamicEnv)
|
||||
let data = try JSONEncoder().encode(snapshot)
|
||||
|
||||
let reply = message.reply()
|
||||
reply.set(key: MachineKeys.snapshot.rawValue, value: data)
|
||||
return reply
|
||||
}
|
||||
|
||||
@Sendable
|
||||
public func stop(_ message: XPCMessage) async throws -> XPCMessage {
|
||||
let id = message.string(key: MachineKeys.id.rawValue)
|
||||
guard let id else {
|
||||
throw ContainerizationError(.invalidArgument, message: "id cannot be empty")
|
||||
}
|
||||
try await service.stop(id: id)
|
||||
return message.reply()
|
||||
}
|
||||
|
||||
@Sendable
|
||||
public func inspect(_ message: XPCMessage) async throws -> XPCMessage {
|
||||
let id = message.string(key: MachineKeys.id.rawValue)
|
||||
guard let id else {
|
||||
throw ContainerizationError(.invalidArgument, message: "id cannot be empty")
|
||||
}
|
||||
let snapshot = try await service.inspect(id: id)
|
||||
let data = try JSONEncoder().encode(snapshot)
|
||||
|
||||
let reply = message.reply()
|
||||
reply.set(key: MachineKeys.snapshot.rawValue, value: data)
|
||||
return reply
|
||||
}
|
||||
|
||||
@Sendable
|
||||
public func setConfig(_ message: XPCMessage) async throws -> XPCMessage {
|
||||
let id = message.string(key: MachineKeys.id.rawValue)
|
||||
guard let id else {
|
||||
throw ContainerizationError(.invalidArgument, message: "id cannot be empty")
|
||||
}
|
||||
let bootConfigData = message.dataNoCopy(key: MachineKeys.bootConfig.rawValue)
|
||||
guard let bootConfigData else {
|
||||
throw ContainerizationError(.invalidArgument, message: "boot config cannot be empty")
|
||||
}
|
||||
let bootConfig = try JSONDecoder().decode(MachineConfig.self, from: bootConfigData)
|
||||
try await service.setConfig(id: id, bootConfig: bootConfig)
|
||||
return message.reply()
|
||||
}
|
||||
|
||||
@Sendable
|
||||
public func logs(_ message: XPCMessage) async throws -> XPCMessage {
|
||||
let id = message.string(key: MachineKeys.id.rawValue)
|
||||
guard let id else {
|
||||
throw ContainerizationError(.invalidArgument, message: "id cannot be empty")
|
||||
}
|
||||
|
||||
let fds = try await service.logs(id: id)
|
||||
let reply = message.reply()
|
||||
try reply.set(key: MachineKeys.logs.rawValue, value: fds)
|
||||
return reply
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,697 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// 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 ContainerRuntimeClient
|
||||
import Containerization
|
||||
import ContainerizationEXT4
|
||||
import ContainerizationError
|
||||
import ContainerizationExtras
|
||||
import ContainerizationOCI
|
||||
import Darwin
|
||||
import Foundation
|
||||
import Logging
|
||||
import MachineAPIClient
|
||||
import SystemPackage
|
||||
|
||||
// systemd poweroff signal (SIGRTMIN+4 on Linux, where SIGRTMIN=34 under glibc)
|
||||
private let SIGRTMIN4: Int32 = 38
|
||||
|
||||
public actor MachinesService {
|
||||
private static let machinesDir = FilePath.Component("machines")
|
||||
private static let stateFile = FilePath.Component("state.json")
|
||||
|
||||
private struct MachineState {
|
||||
var snapshot: MachineSnapshot
|
||||
|
||||
var id: String { snapshot.configuration.id }
|
||||
|
||||
var logger: Task<Void, Never>?
|
||||
}
|
||||
|
||||
private var serviceState: ServiceState
|
||||
private let client: ContainerClient
|
||||
|
||||
private let resourceRoot: FilePath
|
||||
private let machineRoot: FilePath
|
||||
private let lock = AsyncLock()
|
||||
private var machines: [String: MachineState]
|
||||
private let exitMonitor: ExitMonitor
|
||||
private let log: Logger
|
||||
|
||||
private var `default`: MachineState? {
|
||||
guard let id = serviceState.defaultMachine else {
|
||||
return nil
|
||||
}
|
||||
// If a default is set but doesn't exist, treat as if no default is set
|
||||
// This can happen if the default container machine was deleted
|
||||
return self.machines[id]
|
||||
}
|
||||
|
||||
public init(appRoot: FilePath, resourceRoot: FilePath, log: Logger) throws {
|
||||
self.resourceRoot = resourceRoot
|
||||
|
||||
let machineRoot = appRoot.appending(Self.machinesDir)
|
||||
try FileManager.default.createDirectory(atPath: machineRoot.string, withIntermediateDirectories: true)
|
||||
self.machineRoot = machineRoot
|
||||
self.serviceState = try ServiceState.from(appRoot.appending(Self.stateFile))
|
||||
|
||||
self.log = log
|
||||
self.machines = try Self.loadAtBoot(root: machineRoot, resourceRoot: resourceRoot, log: log)
|
||||
self.client = ContainerClient()
|
||||
self.exitMonitor = ExitMonitor(log: log)
|
||||
}
|
||||
|
||||
static private func loadAtBoot(root: FilePath, resourceRoot: FilePath, log: Logger) throws -> [String: MachineState] {
|
||||
let entries = try FileManager.default.contentsOfDirectory(atPath: root.string)
|
||||
|
||||
var results = [String: MachineState]()
|
||||
for entry in entries {
|
||||
guard let component = FilePath.Component(entry) else {
|
||||
continue
|
||||
}
|
||||
let dir = root.appending(component)
|
||||
var isDirectory: ObjCBool = false
|
||||
guard FileManager.default.fileExists(atPath: dir.string, isDirectory: &isDirectory), isDirectory.boolValue else {
|
||||
continue
|
||||
}
|
||||
do {
|
||||
try MachineBundle.sync(path: dir, resourceRoot: resourceRoot)
|
||||
} catch {
|
||||
log.error("failed to sync resources for machine bundle", metadata: ["path": "\(dir.string)", "error": "\(error)"])
|
||||
continue
|
||||
}
|
||||
|
||||
do {
|
||||
let bundle = MachineBundle(path: dir)
|
||||
let config = try bundle.configuration
|
||||
let bootConfig = try bundle.bootConfig
|
||||
|
||||
let state = MachineState(
|
||||
snapshot: .init(
|
||||
configuration: config,
|
||||
status: .stopped,
|
||||
bootConfig: bootConfig,
|
||||
createdDate: try? bundle.createdDate,
|
||||
containerId: nil,
|
||||
initialized: bundle.initialized
|
||||
)
|
||||
)
|
||||
|
||||
results[config.id] = state
|
||||
} catch {
|
||||
log.warning("failed to load machine bundle", metadata: ["path": "\(dir.string)", "error": "\(error)"])
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
static private func pipeFile(from: FileHandle, to: FileHandle) async throws {
|
||||
try to.seekToEnd()
|
||||
|
||||
let stream = AsyncStream<Data> { cont in
|
||||
from.readabilityHandler = { handle in
|
||||
let data = handle.availableData
|
||||
if data.isEmpty {
|
||||
from.readabilityHandler = nil
|
||||
cont.finish()
|
||||
return
|
||||
}
|
||||
|
||||
cont.yield(data)
|
||||
}
|
||||
}
|
||||
|
||||
for await data in stream {
|
||||
try to.write(contentsOf: data)
|
||||
}
|
||||
}
|
||||
|
||||
public func list() async throws -> [MachineSnapshot] {
|
||||
self.log.debug("\(#function)")
|
||||
var snapshots: [MachineSnapshot] = []
|
||||
for state in self.machines.values {
|
||||
var snapshot = state.snapshot
|
||||
let path = try self.bundlePath(id: snapshot.id)
|
||||
let bundle = MachineBundle(path: path)
|
||||
snapshot.diskSize = bundle.diskSize
|
||||
snapshots.append(snapshot)
|
||||
}
|
||||
let runningIds = snapshots.compactMap { $0.status == .running ? $0.containerId : nil }
|
||||
if !runningIds.isEmpty {
|
||||
var containers: [ContainerSnapshot]?
|
||||
do {
|
||||
containers = try await self.client.list(filters: ContainerListFilters(ids: runningIds))
|
||||
} catch {
|
||||
self.log.warning("failed to fetch container addresses: \(error)")
|
||||
}
|
||||
let addressMap = (containers ?? []).reduce(into: [String: String]()) { result, c in
|
||||
if let addr = c.networks.first?.ipv4Address.address.description {
|
||||
result[c.id] = addr
|
||||
}
|
||||
}
|
||||
for i in snapshots.indices where snapshots[i].status == .running {
|
||||
if let cid = snapshots[i].containerId {
|
||||
snapshots[i].ipAddress = addressMap[cid]
|
||||
}
|
||||
}
|
||||
}
|
||||
return snapshots
|
||||
}
|
||||
|
||||
public func create(configuration: MachineConfiguration, resources: MachineResources?, bootConfig: MachineConfig) async throws {
|
||||
self.log.debug("\(#function)")
|
||||
|
||||
try await self.lock.withLock { context in
|
||||
guard await self.machines[configuration.id] == nil else {
|
||||
throw ContainerizationError(
|
||||
.exists,
|
||||
message: "container machine already exists: \(configuration.id)"
|
||||
)
|
||||
}
|
||||
|
||||
let path = try self.bundlePath(id: configuration.id)
|
||||
let bundle = try MachineBundle.create(
|
||||
path: path,
|
||||
machineConfiguration: configuration,
|
||||
resourceRoot: self.resourceRoot,
|
||||
resources: resources,
|
||||
bootConfig: bootConfig,
|
||||
)
|
||||
|
||||
do {
|
||||
let machineImage = ClientImage(description: configuration.image)
|
||||
let imageFs = try await machineImage.getCreateSnapshot(platform: configuration.platform)
|
||||
try bundle.setMachineRootFs(cloning: imageFs)
|
||||
|
||||
let state = MachineState(
|
||||
snapshot: .init(
|
||||
configuration: configuration,
|
||||
status: .stopped,
|
||||
bootConfig: bootConfig,
|
||||
createdDate: Date(),
|
||||
containerId: nil,
|
||||
)
|
||||
)
|
||||
await self.setMachineState(configuration.id, state, context: context)
|
||||
|
||||
if await self.default == nil {
|
||||
try await self._setDefault(id: configuration.id)
|
||||
}
|
||||
} catch {
|
||||
do {
|
||||
try bundle.delete()
|
||||
} catch {
|
||||
self.log.error("failed to delete bundle for container machine \(configuration.id)")
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func delete(id: String) async throws {
|
||||
self.log.debug("\(#function)")
|
||||
|
||||
try await self.lock.withLock { context in
|
||||
let state = try await self._getMachineState(id: id)
|
||||
|
||||
switch state.snapshot.status {
|
||||
case .running:
|
||||
throw ContainerizationError(
|
||||
.invalidState,
|
||||
message: "container machine \(id) is \(state.snapshot.status)")
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
if let defaultMachine = await self.default, defaultMachine.id == id {
|
||||
try await self._setDefault(id: nil)
|
||||
}
|
||||
|
||||
try await self._cleanUp(id: id)
|
||||
}
|
||||
}
|
||||
|
||||
public func getDefault() async throws -> String? {
|
||||
self.log.debug("\(#function)")
|
||||
|
||||
return self.default?.id
|
||||
}
|
||||
|
||||
public func setDefault(id: String) async throws {
|
||||
self.log.debug("\(#function)")
|
||||
|
||||
try await self.lock.withLock { context in
|
||||
let state = try await self._getMachineState(id: id)
|
||||
try await self._setDefault(id: state.id)
|
||||
}
|
||||
}
|
||||
|
||||
public func setConfig(id: String, bootConfig: MachineConfig) async throws {
|
||||
self.log.debug("\(#function)")
|
||||
try await self.lock.withLock { context in
|
||||
var state = try await self._getMachineState(id: id)
|
||||
let path = try self.bundlePath(id: id)
|
||||
let bundle = MachineBundle(path: path)
|
||||
try bundle.set(bootConfig: bootConfig)
|
||||
|
||||
state.snapshot.bootConfig = bootConfig
|
||||
await self.setMachineState(id, state, context: context)
|
||||
}
|
||||
}
|
||||
|
||||
private func _getMachineState(id: String) throws -> MachineState {
|
||||
let state = self.machines[id]
|
||||
guard let state else {
|
||||
throw ContainerizationError(
|
||||
.notFound,
|
||||
message: "container machine with ID \(id) not found")
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
private func setMachineState(_ id: String, _ state: MachineState, context: AsyncLock.Context) async {
|
||||
self.machines[id] = state
|
||||
}
|
||||
|
||||
private nonisolated func bundlePath(id: String) throws -> FilePath {
|
||||
guard let component = FilePath.Component(id) else {
|
||||
throw ContainerizationError(
|
||||
.invalidArgument,
|
||||
message: "container machine ID \(id) is not a valid path component"
|
||||
)
|
||||
}
|
||||
return self.machineRoot.appending(component)
|
||||
}
|
||||
|
||||
private func _setDefault(id: String?) throws {
|
||||
try serviceState.setDefault(id: id)
|
||||
}
|
||||
|
||||
private func _cleanUp(id: String) throws {
|
||||
self.log.debug("\(#function)")
|
||||
|
||||
if self.machines[id] == nil {
|
||||
return
|
||||
}
|
||||
|
||||
let path = try self.bundlePath(id: id)
|
||||
let bundle = MachineBundle(path: path)
|
||||
try bundle.delete()
|
||||
self.machines.removeValue(forKey: id)
|
||||
}
|
||||
|
||||
private func cleanUp(id: String, context: AsyncLock.Context) async throws {
|
||||
try self._cleanUp(id: id)
|
||||
}
|
||||
|
||||
private nonisolated func systemPlatform(from ociPlatform: ContainerizationOCI.Platform) -> SystemPlatform {
|
||||
ociPlatform.architecture == "amd64" ? .linuxAmd : .linuxArm
|
||||
}
|
||||
|
||||
public func boot(id: String?, dynamicEnv: [String: String] = [:]) async throws -> MachineSnapshot {
|
||||
self.log.debug("\(#function)")
|
||||
|
||||
guard let id = id ?? self.default?.id else {
|
||||
throw ContainerizationError(
|
||||
.invalidArgument,
|
||||
message: "no container machine specified and no default set"
|
||||
)
|
||||
}
|
||||
|
||||
return try await self.lock.withLock { context in
|
||||
var state = try await self._getMachineState(id: id)
|
||||
|
||||
switch state.snapshot.status {
|
||||
case .running:
|
||||
return state.snapshot
|
||||
case .stopped:
|
||||
break
|
||||
default:
|
||||
throw ContainerizationError(.invalidState, message: "container machine \(id) is \(state.snapshot.status)")
|
||||
}
|
||||
|
||||
let cid = "\(id)-\(UUID().uuidString.prefix(MachineConfiguration.containerUUIDLength).lowercased())"
|
||||
guard try await self.client.list(filters: .init(ids: [cid])).isEmpty else {
|
||||
throw ContainerizationError(.internalError, message: "container \(cid) already exists")
|
||||
}
|
||||
|
||||
let path = try self.bundlePath(id: id)
|
||||
let bundle = MachineBundle(path: path)
|
||||
let rootfs = try bundle.machineRootfs
|
||||
|
||||
let bootConfig = state.snapshot.bootConfig
|
||||
var config = try await state.snapshot.configuration.toContainerConfig(
|
||||
cid: cid,
|
||||
sbin: path.appending(MachineBundle.sbinDirectory),
|
||||
initializedFile: path.appending(MachineBundle.initializedFile),
|
||||
homeMountOption: bootConfig.homeMount,
|
||||
)
|
||||
|
||||
config.resources.cpus = bootConfig.cpus
|
||||
config.resources.cpuOverhead = 0
|
||||
config.resources.memoryInBytes = bootConfig.memory.toUInt64(unit: .bytes)
|
||||
|
||||
let kernel = try await ClientKernel.getDefaultKernel(for: .current)
|
||||
|
||||
var fhs: [FileHandle] = []
|
||||
do {
|
||||
try await self.client.create(
|
||||
configuration: config,
|
||||
options: ContainerCreateOptions(autoRemove: true, rootFsOverride: rootfs),
|
||||
kernel: kernel
|
||||
)
|
||||
|
||||
let process = try await self.client.bootstrap(
|
||||
id: cid, stdio: [nil, nil, nil], dynamicEnv: dynamicEnv)
|
||||
try await process.start()
|
||||
|
||||
try fhs.append(contentsOf: await self.client.logs(id: cid))
|
||||
|
||||
try bundle.createLogFiles()
|
||||
let stdioLog = try FileHandle(forWritingTo: URL(filePath: bundle.stdioLog.string))
|
||||
let bootLog = try FileHandle(forWritingTo: URL(filePath: bundle.bootLog.string))
|
||||
|
||||
state.logger = Task<Void, Never> { [log = self.log, id = state.id, fhs] in
|
||||
defer {
|
||||
try? fhs[0].close()
|
||||
try? fhs[1].close()
|
||||
|
||||
try? stdioLog.close()
|
||||
try? bootLog.close()
|
||||
}
|
||||
|
||||
await withTaskGroup(of: Result<Void, Error>.self) { group in
|
||||
for (from, to) in zip(fhs, [stdioLog, bootLog]) {
|
||||
group.addTask {
|
||||
do {
|
||||
try await Self.pipeFile(from: from, to: to)
|
||||
return .success(())
|
||||
} catch {
|
||||
return .failure(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for await result in group {
|
||||
switch result {
|
||||
case .success():
|
||||
continue
|
||||
case .failure(let error):
|
||||
log.error(
|
||||
"log pipe failed",
|
||||
metadata: [
|
||||
"id": "\(id)",
|
||||
"error": "\(error)",
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try await self.exitMonitor.registerProcess(
|
||||
id: id,
|
||||
onExit: self.handleMachineExit
|
||||
)
|
||||
|
||||
state.snapshot.status = .running
|
||||
state.snapshot.startedDate = Date()
|
||||
state.snapshot.containerId = cid
|
||||
state.snapshot.initialized = bundle.initialized
|
||||
await self.setMachineState(id, state, context: context)
|
||||
|
||||
// Monitor container exit in the background so we can update container machine state
|
||||
// when the backing container stops (e.g., VM crash, kill, etc.)
|
||||
try await self.exitMonitor.track(id: id) {
|
||||
self.log.info("registering container machine with exit monitor")
|
||||
let code = try await process.wait()
|
||||
self.log.info(
|
||||
"container machine exited in exit monitor",
|
||||
metadata: ["id": "\(id)", "rc": "\(code)"]
|
||||
)
|
||||
|
||||
return ExitStatus(exitCode: code)
|
||||
}
|
||||
|
||||
return state.snapshot
|
||||
} catch {
|
||||
await self.exitMonitor.stopTracking(id: id)
|
||||
|
||||
state.logger?.cancel()
|
||||
await state.logger?.value
|
||||
state.logger = nil
|
||||
|
||||
fhs.forEach { try? $0.close() }
|
||||
try? await self.client.delete(id: cid, force: true)
|
||||
|
||||
state.snapshot.status = .stopped
|
||||
state.snapshot.startedDate = nil
|
||||
state.snapshot.containerId = nil
|
||||
state.snapshot.ipAddress = nil
|
||||
await self.setMachineState(id, state, context: context)
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func stop(id: String) async throws {
|
||||
self.log.debug("\(#function)")
|
||||
|
||||
try await self.lock.withLock { context in
|
||||
let state = try await self._getMachineState(id: id)
|
||||
|
||||
switch state.snapshot.status {
|
||||
case .stopped:
|
||||
return
|
||||
case .running:
|
||||
break
|
||||
default:
|
||||
throw ContainerizationError(
|
||||
.invalidState,
|
||||
message: "container machine \(id) is \(state.snapshot.status)"
|
||||
)
|
||||
}
|
||||
|
||||
guard let cid = state.snapshot.containerId else {
|
||||
throw ContainerizationError(
|
||||
.internalError,
|
||||
message: "no container ID for running container machine"
|
||||
)
|
||||
}
|
||||
|
||||
try await self.client.stop(id: cid, opts: ContainerStopOptions(timeoutInSeconds: 10, signal: nil))
|
||||
await self.handleMachineExit(id: id, code: nil, context: context)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleMachineExit(id: String, code: ExitStatus? = nil) async {
|
||||
await self.lock.withLock { [self] context in
|
||||
await handleMachineExit(id: id, code: code, context: context)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleMachineExit(id: String, code: ExitStatus?, context: AsyncLock.Context) async {
|
||||
self.log.info("container exited for container machine \(id)")
|
||||
guard var state = self.machines[id] else {
|
||||
return
|
||||
}
|
||||
state.snapshot.status = .stopped
|
||||
state.snapshot.startedDate = nil
|
||||
state.snapshot.containerId = nil
|
||||
state.snapshot.ipAddress = nil
|
||||
|
||||
state.logger?.cancel()
|
||||
await state.logger?.value
|
||||
state.logger = nil
|
||||
|
||||
await self.exitMonitor.stopTracking(id: id)
|
||||
await self.setMachineState(id, state, context: context)
|
||||
}
|
||||
|
||||
public func inspect(id: String) async throws -> MachineSnapshot {
|
||||
self.log.debug("\(#function)")
|
||||
var snapshot = try self._getMachineState(id: id).snapshot
|
||||
let path = try self.bundlePath(id: id)
|
||||
let bundle = MachineBundle(path: path)
|
||||
snapshot.initialized = bundle.initialized
|
||||
snapshot.diskSize = bundle.diskSize
|
||||
if snapshot.status == .running, let cid = snapshot.containerId {
|
||||
do {
|
||||
let container = try await self.client.get(id: cid)
|
||||
snapshot.ipAddress = container.networks.first?.ipv4Address.address.description
|
||||
} catch {
|
||||
self.log.warning("failed to fetch container address for \(cid): \(error)")
|
||||
}
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
// Get the logs for the container machine
|
||||
public func logs(id: String) async throws -> [FileHandle] {
|
||||
self.log.debug("\(#function)")
|
||||
|
||||
do {
|
||||
_ = try _getMachineState(id: id)
|
||||
let path = try self.bundlePath(id: id)
|
||||
let bundle = MachineBundle(path: path)
|
||||
return [
|
||||
try FileHandle(forReadingFrom: URL(filePath: bundle.stdioLog.string)),
|
||||
try FileHandle(forReadingFrom: URL(filePath: bundle.bootLog.string)),
|
||||
]
|
||||
} catch {
|
||||
throw ContainerizationError(
|
||||
.internalError,
|
||||
message: "failed to open container machine logs: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MachinesService {
|
||||
fileprivate struct ServiceState: Codable, Sendable {
|
||||
private var path: FilePath?
|
||||
|
||||
public var defaultMachine: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case defaultMachine
|
||||
}
|
||||
|
||||
public static func from(_ path: FilePath) throws -> ServiceState {
|
||||
var state: ServiceState
|
||||
|
||||
let url = URL(filePath: path.string)
|
||||
do {
|
||||
let data = try Data(contentsOf: url)
|
||||
state = try JSONDecoder().decode(Self.self, from: data)
|
||||
} catch {
|
||||
state = ServiceState(defaultMachine: nil)
|
||||
try JSONEncoder().encode(state).write(to: url)
|
||||
}
|
||||
|
||||
state.path = path
|
||||
return state
|
||||
}
|
||||
|
||||
public mutating func setDefault(id: String?) throws {
|
||||
guard let path else {
|
||||
throw ContainerizationError(
|
||||
.internalError,
|
||||
message: "service state path is not set"
|
||||
)
|
||||
}
|
||||
|
||||
self.defaultMachine = id
|
||||
let data = try JSONEncoder().encode(self)
|
||||
try data.write(to: URL(filePath: path.string), options: .atomic)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MachineBundle {
|
||||
func createLogFiles() throws {
|
||||
let bootLogFd = Darwin.open(self.bootLog.string, O_CREAT | O_RDONLY, 0o644)
|
||||
guard bootLogFd > 0 else {
|
||||
throw POSIXError(.init(rawValue: errno)!)
|
||||
}
|
||||
|
||||
close(bootLogFd)
|
||||
|
||||
let stdioLogFd = Darwin.open(self.stdioLog.string, O_CREAT | O_RDONLY, 0o644)
|
||||
guard stdioLogFd > 0 else {
|
||||
throw POSIXError(.init(rawValue: errno)!)
|
||||
}
|
||||
close(stdioLogFd)
|
||||
}
|
||||
}
|
||||
|
||||
extension MachineConfiguration {
|
||||
fileprivate func toContainerConfig(
|
||||
cid: String,
|
||||
sbin: FilePath,
|
||||
initializedFile: FilePath,
|
||||
homeMountOption: MachineConfig.HomeMountOption,
|
||||
) async throws -> ContainerConfiguration {
|
||||
var config = ContainerConfiguration(
|
||||
id: cid,
|
||||
image: image,
|
||||
process: ProcessConfiguration(
|
||||
executable: "/\(MachineBundle.sbinDirectory)/\(MachineBundle.initFile)",
|
||||
arguments: [],
|
||||
environment: processEnvironment,
|
||||
workingDirectory: "/",
|
||||
terminal: true,
|
||||
user: .id(uid: 0, gid: 0)
|
||||
)
|
||||
)
|
||||
|
||||
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
||||
config.mounts = [
|
||||
.virtiofs(
|
||||
source: sbin.string,
|
||||
destination: "/\(MachineBundle.sbinDirectory)",
|
||||
options: ["ro"]),
|
||||
.virtiofs(
|
||||
source: initializedFile.string,
|
||||
destination: "/etc/.\(MachineBundle.initializedFile)",
|
||||
options: ["rw"]),
|
||||
]
|
||||
if homeMountOption != .none {
|
||||
config.mounts.append(
|
||||
.virtiofs(
|
||||
source: home,
|
||||
destination: home,
|
||||
options: [homeMountOption.rawValue]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
config.platform = platform
|
||||
config.labels = [
|
||||
ResourceLabelKeys.plugin: "machine"
|
||||
]
|
||||
let domain = Self.defaultDNSDomain
|
||||
config.dns = ContainerConfiguration.DNSConfiguration(
|
||||
nameservers: [],
|
||||
domain: domain,
|
||||
searchDomains: [domain],
|
||||
)
|
||||
guard let defaultNetwork = try await NetworkClient().builtin else {
|
||||
throw ContainerizationError(.invalidState, message: "default network is not present")
|
||||
}
|
||||
config.networks = [
|
||||
AttachmentConfiguration(
|
||||
network: defaultNetwork.id,
|
||||
options: AttachmentOptions(hostname: dnsHostname)
|
||||
)
|
||||
]
|
||||
|
||||
config.capAdd = ["ALL"]
|
||||
config.ssh = true
|
||||
|
||||
config.rosetta = platform.architecture == "amd64" && Arch.hostArchitecture() == .arm64
|
||||
|
||||
// Default to nil if image is not found, which defaults to send SIGTERM on stop
|
||||
let imageConfig = try? await ClientImage(description: image).config(for: platform).config
|
||||
config.stopSignal = imageConfig?.stopSignal
|
||||
|
||||
return config
|
||||
}
|
||||
}
|
||||
@@ -513,16 +513,20 @@ public actor RuntimeService {
|
||||
self.log.debug("enter", metadata: ["func": "\(#function)"])
|
||||
defer { self.log.debug("exit", metadata: ["func": "\(#function)"]) }
|
||||
|
||||
let stopOptions = try message.stopOptions()
|
||||
let signal = try Signal(stopOptions.signal ?? "SIGTERM")
|
||||
let timeout: Duration = .seconds(stopOptions.timeoutInSeconds)
|
||||
|
||||
return try await self.lock.withLock { _ in
|
||||
switch await self.state {
|
||||
case .running, .booted:
|
||||
await self.setState(.stopping)
|
||||
|
||||
let ctr = try await self.getContainer()
|
||||
let stopOptions = try message.stopOptions()
|
||||
let exitStatus = try await self.gracefulStopContainer(
|
||||
ctr.container,
|
||||
stopOpts: stopOptions
|
||||
signal: signal,
|
||||
timeout: timeout
|
||||
)
|
||||
|
||||
do {
|
||||
@@ -980,6 +984,7 @@ public actor RuntimeService {
|
||||
log: Logger? = nil,
|
||||
) throws {
|
||||
czConfig.cpus = config.resources.cpus
|
||||
czConfig.cpuOverhead = config.resources.cpuOverhead
|
||||
czConfig.memoryInBytes = config.resources.memoryInBytes
|
||||
czConfig.sysctl = config.sysctls.reduce(into: [String: String]()) {
|
||||
$0[$1.key] = $1.value
|
||||
@@ -1034,11 +1039,11 @@ public actor RuntimeService {
|
||||
czConfig.sockets.append(socketConfig)
|
||||
}
|
||||
|
||||
let containerId = config.id
|
||||
let hostnameSource = config.networks.first?.options.hostname ?? config.id
|
||||
czConfig.hostname =
|
||||
containerId.split(separator: ".", maxSplits: 1, omittingEmptySubsequences: true)
|
||||
hostnameSource.split(separator: ".", maxSplits: 1, omittingEmptySubsequences: true)
|
||||
.first
|
||||
.map { String($0) } ?? containerId
|
||||
.map { String($0) } ?? config.id
|
||||
|
||||
if let dns = config.dns {
|
||||
czConfig.dns = DNS(
|
||||
@@ -1206,7 +1211,7 @@ public actor RuntimeService {
|
||||
return container
|
||||
}
|
||||
|
||||
private func gracefulStopContainer(_ lc: LinuxContainer, stopOpts: ContainerStopOptions) async throws -> ExitStatus {
|
||||
private func gracefulStopContainer(_ lc: LinuxContainer, signal: Signal, timeout: Duration) async throws -> ExitStatus {
|
||||
// Try and gracefully shut down the process. Even if this succeeds we need to power off
|
||||
// the vm, but we should try this first always.
|
||||
var code = ExitStatus(exitCode: 255)
|
||||
@@ -1216,9 +1221,8 @@ public actor RuntimeService {
|
||||
try await lc.wait()
|
||||
}
|
||||
group.addTask {
|
||||
let signal = try Signal(stopOpts.signal ?? "SIGTERM")
|
||||
try await lc.kill(signal)
|
||||
try await Task.sleep(for: .seconds(stopOpts.timeoutInSeconds))
|
||||
try await Task.sleep(for: timeout)
|
||||
try await lc.kill(.kill)
|
||||
|
||||
return ExitStatus(exitCode: 137)
|
||||
|
||||
Reference in New Issue
Block a user