mirror of
https://github.com/apple/container.git
synced 2026-07-12 12:37:02 +00:00
b2994ac369
## 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>
165 lines
5.9 KiB
Swift
165 lines
5.9 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 ArgumentParser
|
|
import ContainerAPIClient
|
|
import ContainerResource
|
|
import ContainerizationError
|
|
import ContainerizationOS
|
|
import Foundation
|
|
import MachineAPIClient
|
|
import SystemPackage
|
|
|
|
extension Application {
|
|
public struct MachineRun: AsyncLoggableCommand {
|
|
public init() {}
|
|
|
|
public static let configuration = CommandConfiguration(
|
|
commandName: "run",
|
|
abstract: "Run a command or interactive shell in a container machine, booting the container machine if necessary"
|
|
)
|
|
|
|
@OptionGroup
|
|
public var logOptions: Flags.Logging
|
|
|
|
@OptionGroup(title: "Process options")
|
|
var processFlags: Flags.Process
|
|
|
|
@Option(name: [.short, .long], help: "Container machine ID (uses default if not specified)")
|
|
var name: String?
|
|
|
|
@Flag(name: .shortAndLong, help: "Run a process in a container machine and detach from it")
|
|
public var detach = false
|
|
|
|
@Flag(name: .long, help: "Run as root instead of matching host user")
|
|
var root: Bool = false
|
|
|
|
@Argument(help: "Command to run (default: login shell)")
|
|
var executable: String?
|
|
|
|
@Argument(parsing: .captureForPassthrough, help: "Command arguments")
|
|
var arguments: [String] = []
|
|
|
|
public func run() async throws {
|
|
let client = MachineClient()
|
|
let containerClient = ContainerClient()
|
|
|
|
let snapshot = try await bootMachine(id: name, client: client, log: log, interactive: true)
|
|
|
|
guard let containerId = snapshot.containerId else {
|
|
throw ContainerizationError(
|
|
.invalidState,
|
|
message: "container machine is running but has no container ID"
|
|
)
|
|
}
|
|
// Default runs `/sbin.machine/init -s` to find the shell for user
|
|
let executablePath = FilePath("/\(MachineBundle.sbinDirectory)").appending(MachineBundle.initFile).string
|
|
|
|
let args: [String]
|
|
let tty: Bool
|
|
let interactive: Bool
|
|
|
|
if let executable {
|
|
args = ["-s", executable] + arguments
|
|
tty = processFlags.tty
|
|
interactive = processFlags.interactive
|
|
} else {
|
|
args = ["-s"]
|
|
tty = true
|
|
interactive = true
|
|
}
|
|
|
|
// If not root user, get default user from machine configuration
|
|
let defaultUser: ProcessConfiguration.User = {
|
|
if root || getuid() == 0 {
|
|
return .id(uid: 0, gid: 0)
|
|
}
|
|
|
|
return snapshot.configuration.user
|
|
}()
|
|
|
|
let (user, additionalGroups) = Parser.user(
|
|
user: processFlags.user, uid: processFlags.uid,
|
|
gid: processFlags.gid, defaultUser: defaultUser)
|
|
|
|
let cwd = getWorkingDirectory(snapshot, user: user)
|
|
|
|
// Build environment with HOME set correctly
|
|
let envVars = try Parser.allEnv(
|
|
imageEnvs: ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],
|
|
envFiles: processFlags.envFile,
|
|
envs: processFlags.env
|
|
)
|
|
|
|
let processConfig = ProcessConfiguration(
|
|
executable: executablePath,
|
|
arguments: args,
|
|
environment: envVars,
|
|
workingDirectory: cwd,
|
|
terminal: tty,
|
|
user: user,
|
|
supplementalGroups: additionalGroups
|
|
)
|
|
|
|
let io = try ProcessIO.create(tty: tty, interactive: interactive, detach: detach)
|
|
defer {
|
|
try? io.close()
|
|
}
|
|
|
|
let process = try await containerClient.createProcess(
|
|
containerId: containerId,
|
|
processId: UUID().uuidString.lowercased(),
|
|
configuration: processConfig,
|
|
stdio: io.stdio
|
|
)
|
|
|
|
if !tty {
|
|
var handler = SignalThreshold(threshold: 3, signals: [SIGINT, SIGTERM])
|
|
handler.start {
|
|
print("Received 3 SIGINT/SIGTERM's, forcefully exiting.")
|
|
Darwin.exit(1)
|
|
}
|
|
}
|
|
|
|
if detach {
|
|
try await process.start()
|
|
try io.closeAfterStart()
|
|
print(snapshot.id)
|
|
return
|
|
}
|
|
|
|
let exitCode = try await io.handleProcess(process: process, log: log)
|
|
throw ArgumentParser.ExitCode(exitCode)
|
|
}
|
|
|
|
func getWorkingDirectory(_ snapshot: MachineSnapshot, user: ProcessConfiguration.User) -> String {
|
|
if let cwd = processFlags.cwd {
|
|
return cwd
|
|
}
|
|
let fallback = user == snapshot.configuration.user ? snapshot.configuration.home : "/"
|
|
if snapshot.bootConfig.homeMount == .none {
|
|
return fallback
|
|
}
|
|
let home = FilePath(FileManager.default.homeDirectoryForCurrentUser.path)
|
|
let cwd = FilePath(FileManager.default.currentDirectoryPath)
|
|
guard cwd.starts(with: home) else {
|
|
return fallback
|
|
}
|
|
return cwd.string
|
|
}
|
|
}
|
|
}
|