Files
container/Sources/Services/MachineAPIService/Client/MachineBundle.swift
T
Raj b2994ac369 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>
2026-06-08 11:38:49 -07:00

260 lines
10 KiB
Swift

//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import 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?
}