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:
Raj
2026-06-08 11:38:49 -07:00
committed by GitHub
co-authored by Jaewon Hur John Logan Michael Crosby Eric Ernst Danny Canter
parent 1b5576312f
commit b2994ac369
51 changed files with 5125 additions and 36 deletions
@@ -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)