From b2994ac369a01bc29e99f1096dde58ff095ff613 Mon Sep 17 00:00:00 2001 From: Raj Date: Mon, 8 Jun 2026 11:38:49 -0700 Subject: [PATCH] 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 Co-authored-by: Jaewon Hur Co-authored-by: John Logan Co-authored-by: Michael Crosby Co-authored-by: Eric Ernst Co-authored-by: Danny Canter --- Makefile | 13 +- Package.swift | 51 + Sources/APIServer/APIServer+Start.swift | 7 +- Sources/ContainerCommands/Application.swift | 6 + .../Container/ContainerDelete.swift | 3 +- .../Container/ContainerKill.swift | 3 +- .../Container/ContainerList.swift | 3 +- .../Container/ContainerPrune.swift | 4 +- .../Container/ContainerStop.swift | 3 +- .../ContainerCommands/Image/ImagePrune.swift | 2 +- .../Machine/MachineCommand.swift | 69 + .../Machine/MachineCreate.swift | 140 ++ .../Machine/MachineDelete.swift | 66 + .../Machine/MachineHelpers.swift | 106 ++ .../Machine/MachineInspect.swift | 85 ++ .../Machine/MachineList.swift | 137 ++ .../Machine/MachineLogs.swift | 152 +++ .../Machine/MachineRun.swift | 164 +++ .../Machine/MachineSet.swift | 81 ++ .../Machine/MachineSetDefault.swift | 41 + .../Machine/MachineStop.swift | 61 + .../System/SystemStart.swift | 11 + .../ContainerSystemConfig.swift | 5 + .../ContainerPersistence/MachineConfig.swift | 149 +++ Sources/ContainerPersistence/MemorySize.swift | 6 + Sources/ContainerPlugin/PluginStateRoot.swift | 36 + .../Container/ContainerConfiguration.swift | 2 + .../Container/ContainerListFilters.swift | 17 +- .../MachineAPIServer+Start.swift | 94 ++ .../MachineAPIServer/MachineAPIServer.swift | 28 + .../MachineAPIServer/Resources/create-user.sh | 46 + .../Plugins/MachineAPIServer/Resources/init | 75 ++ Sources/Plugins/MachineAPIServer/config.toml | 11 + .../ContainerAPIService/Client/Parser.swift | 6 + .../Server/Containers/ContainersService.swift | 40 +- .../MachineAPIService/Client/Flags.swift | 33 + .../Client/MachineBundle.swift | 259 ++++ .../Client/MachineClient.swift | 399 ++++++ .../Client/MachineConfiguration.swift | 128 ++ .../Client/MachineKeys.swift | 34 + .../Client/MachineRoutes.swift | 38 + .../Client/MachineSnapshot.swift | 84 ++ .../Server/MachinesHarness.swift | 169 +++ .../Server/MachinesService.swift | 697 ++++++++++ .../RuntimeLinux/Server/RuntimeService.swift | 20 +- .../Subcommands/Machine/TestCLIMachine.swift | 1178 +++++++++++++++++ Tests/CLITests/TestCLINoParallelCases.swift | 2 + Tests/CLITests/Utilities/CLITest.swift | 19 +- .../MemorySizeTests.swift | 18 + docs/command-reference.md | 244 ++++ docs/how-to.md | 116 ++ 51 files changed, 5125 insertions(+), 36 deletions(-) create mode 100644 Sources/ContainerCommands/Machine/MachineCommand.swift create mode 100644 Sources/ContainerCommands/Machine/MachineCreate.swift create mode 100644 Sources/ContainerCommands/Machine/MachineDelete.swift create mode 100644 Sources/ContainerCommands/Machine/MachineHelpers.swift create mode 100644 Sources/ContainerCommands/Machine/MachineInspect.swift create mode 100644 Sources/ContainerCommands/Machine/MachineList.swift create mode 100644 Sources/ContainerCommands/Machine/MachineLogs.swift create mode 100644 Sources/ContainerCommands/Machine/MachineRun.swift create mode 100644 Sources/ContainerCommands/Machine/MachineSet.swift create mode 100644 Sources/ContainerCommands/Machine/MachineSetDefault.swift create mode 100644 Sources/ContainerCommands/Machine/MachineStop.swift create mode 100644 Sources/ContainerPersistence/MachineConfig.swift create mode 100644 Sources/ContainerPlugin/PluginStateRoot.swift create mode 100644 Sources/Plugins/MachineAPIServer/MachineAPIServer+Start.swift create mode 100644 Sources/Plugins/MachineAPIServer/MachineAPIServer.swift create mode 100755 Sources/Plugins/MachineAPIServer/Resources/create-user.sh create mode 100755 Sources/Plugins/MachineAPIServer/Resources/init create mode 100644 Sources/Plugins/MachineAPIServer/config.toml create mode 100644 Sources/Services/MachineAPIService/Client/Flags.swift create mode 100644 Sources/Services/MachineAPIService/Client/MachineBundle.swift create mode 100644 Sources/Services/MachineAPIService/Client/MachineClient.swift create mode 100644 Sources/Services/MachineAPIService/Client/MachineConfiguration.swift create mode 100644 Sources/Services/MachineAPIService/Client/MachineKeys.swift create mode 100644 Sources/Services/MachineAPIService/Client/MachineRoutes.swift create mode 100644 Sources/Services/MachineAPIService/Client/MachineSnapshot.swift create mode 100644 Sources/Services/MachineAPIService/Server/MachinesHarness.swift create mode 100644 Sources/Services/MachineAPIService/Server/MachinesService.swift create mode 100644 Tests/CLITests/Subcommands/Machine/TestCLIMachine.swift diff --git a/Makefile b/Makefile index f91bdee9..23a26877 100644 --- a/Makefile +++ b/Makefile @@ -100,6 +100,8 @@ $(STAGING_DIR): @mkdir -p "$(join $(STAGING_DIR), libexec/container/plugins/container-runtime-linux/bin)" @mkdir -p "$(join $(STAGING_DIR), libexec/container/plugins/container-network-vmnet/bin)" @mkdir -p "$(join $(STAGING_DIR), libexec/container/plugins/container-core-images/bin)" + @mkdir -p "$(join $(STAGING_DIR), libexec/container/plugins/machine-apiserver/bin)" + @mkdir -p "$(join $(STAGING_DIR), libexec/container/plugins/machine-apiserver/resources)" @install "$(BUILD_BIN_DIR)/container" "$(join $(STAGING_DIR), bin/container)" @install "$(BUILD_BIN_DIR)/container-apiserver" "$(join $(STAGING_DIR), bin/container-apiserver)" @@ -109,6 +111,10 @@ $(STAGING_DIR): @install Sources/Plugins/NetworkVmnet/config.toml "$(join $(STAGING_DIR), libexec/container/plugins/container-network-vmnet/config.toml)" @install "$(BUILD_BIN_DIR)/container-core-images" "$(join $(STAGING_DIR), libexec/container/plugins/container-core-images/bin/container-core-images)" @install Sources/Plugins/CoreImages/config.toml "$(join $(STAGING_DIR), libexec/container/plugins/container-core-images/config.toml)" + @install "$(BUILD_BIN_DIR)/machine-apiserver" "$(join $(STAGING_DIR), libexec/container/plugins/machine-apiserver/bin/machine-apiserver)" + @install Sources/Plugins/MachineAPIServer/config.toml "$(join $(STAGING_DIR), libexec/container/plugins/machine-apiserver/config.toml)" + @install Sources/Plugins/MachineAPIServer/Resources/init "$(join $(STAGING_DIR), libexec/container/plugins/machine-apiserver/resources/init)" + @install Sources/Plugins/MachineAPIServer/Resources/create-user.sh "$(join $(STAGING_DIR), libexec/container/plugins/machine-apiserver/resources/create-user.sh)" @echo Install update script @install scripts/update-container.sh "$(join $(STAGING_DIR), bin/update-container.sh)" @@ -123,6 +129,7 @@ installer-pkg: $(STAGING_DIR) @codesign $(CODESIGN_OPTS) --prefix=com.apple.container. "$(join $(STAGING_DIR), libexec/container/plugins/container-core-images/bin/container-core-images)" @codesign $(CODESIGN_OPTS) --prefix=com.apple.container. --entitlements=signing/container-runtime-linux.entitlements "$(join $(STAGING_DIR), libexec/container/plugins/container-runtime-linux/bin/container-runtime-linux)" @codesign $(CODESIGN_OPTS) --prefix=com.apple.container. --entitlements=signing/container-network-vmnet.entitlements "$(join $(STAGING_DIR), libexec/container/plugins/container-network-vmnet/bin/container-network-vmnet)" + @codesign $(CODESIGN_OPTS) --prefix=com.apple.container. "$(join $(STAGING_DIR), libexec/container/plugins/machine-apiserver/bin/machine-apiserver)" @echo Creating application installer @pkgbuild --root "$(STAGING_DIR)" --identifier com.apple.container-installer --install-location /usr/local --version ${RELEASE_VERSION} $(PKG_PATH) @@ -211,8 +218,10 @@ INTEGRATION_TEST_SUITES ?= \ TestCLIKernelSet \ TestCLIAnonymousVolumes \ TestCLINotFound \ - TestCLINoParallelCases \ - TestCLISystemDF + TestCLISystemDF \ + TestCLIMachineCommand \ + TestCLIMachineRuntime \ + TestCLINoParallelCases empty := space := $(empty) $(empty) diff --git a/Package.swift b/Package.swift index 16187923..e2371d5e 100644 --- a/Package.swift +++ b/Package.swift @@ -49,6 +49,8 @@ let package = Package( .library(name: "ContainerOS", targets: ["ContainerOS"]), .library(name: "SocketForwarder", targets: ["SocketForwarder"]), .library(name: "TerminalProgress", targets: ["TerminalProgress"]), + .library(name: "MachineAPIClient", targets: ["MachineAPIClient"]), + .library(name: "MachineAPIService", targets: ["MachineAPIService"]), ], dependencies: [ .package(url: "https://github.com/apple/containerization.git", exact: Version(stringLiteral: scVersion)), @@ -116,6 +118,7 @@ let package = Package( "ContainerVersion", .product(name: "SystemPackage", package: "swift-system"), "ContainerXPC", + "MachineAPIClient", "TerminalProgress", "Yams", ], @@ -562,5 +565,53 @@ let package = Package( .product(name: "SystemPackage", package: "swift-system") ] ), + .target( + name: "MachineAPIClient", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "ContainerizationOCI", package: "containerization"), + .product(name: "Logging", package: "swift-log"), + "ContainerAPIClient", + "ContainerPersistence", + "ContainerResource", + "ContainerXPC", + "TerminalProgress", + ], + path: "Sources/Services/MachineAPIService/Client" + ), + .target( + name: "MachineAPIService", + dependencies: [ + .product(name: "Containerization", package: "containerization"), + .product(name: "ContainerizationEXT4", package: "containerization"), + .product(name: "ContainerizationExtras", package: "containerization"), + .product(name: "ContainerizationOCI", package: "containerization"), + .product(name: "Logging", package: "swift-log"), + .product(name: "SystemPackage", package: "swift-system"), + "ContainerAPIClient", + "ContainerResource", + "ContainerRuntimeClient", + "ContainerXPC", + "MachineAPIClient", + ], + path: "Sources/Services/MachineAPIService/Server" + ), + .executableTarget( + name: "machine-apiserver", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "Logging", package: "swift-log"), + "ContainerAPIClient", + "ContainerLog", + "ContainerPersistence", + "ContainerPlugin", + "ContainerVersion", + "ContainerXPC", + "MachineAPIClient", + "MachineAPIService", + ], + path: "Sources/Plugins/MachineAPIServer", + exclude: ["config.toml", "Resources"] + ), ] ) diff --git a/Sources/APIServer/APIServer+Start.swift b/Sources/APIServer/APIServer+Start.swift index 21a0dcbe..2ac2e77f 100644 --- a/Sources/APIServer/APIServer+Start.swift +++ b/Sources/APIServer/APIServer+Start.swift @@ -63,7 +63,7 @@ extension APIServer { var routes = [XPCRoute: XPCServer.RouteHandler]() let pluginLoader = try initializePluginLoader(log: log) - try await initializePlugins(pluginLoader: pluginLoader, log: log, routes: &routes) + try await initializePlugins(pluginLoader: pluginLoader, log: log, routes: &routes, debug: debug) let containersService = try initializeContainersService( pluginLoader: pluginLoader, containerSystemConfig: containerSystemConfig, @@ -227,14 +227,15 @@ extension APIServer { private func initializePlugins( pluginLoader: PluginLoader, log: Logger, - routes: inout [XPCRoute: XPCServer.RouteHandler] + routes: inout [XPCRoute: XPCServer.RouteHandler], + debug: Bool = false ) async throws { log.info("initializing plugins") let bootPlugins = pluginLoader.findPlugins().filter { $0.shouldBoot } let service = PluginsService(pluginLoader: pluginLoader, log: log) - try await service.loadAll(bootPlugins) + try await service.loadAll(bootPlugins, debug: debug) let harness = PluginsHarness(service: service, log: log) routes[XPCRoute.pluginGet] = XPCServer.route(harness.get) diff --git a/Sources/ContainerCommands/Application.swift b/Sources/ContainerCommands/Application.swift index fc702db7..c77f51a5 100644 --- a/Sources/ContainerCommands/Application.swift +++ b/Sources/ContainerCommands/Application.swift @@ -78,6 +78,12 @@ public struct Application: AsyncLoggableCommand { RegistryCommand.self, ] ), + CommandGroup( + name: "Machine", + subcommands: [ + MachineCommand.self + ] + ), CommandGroup( name: "Volume", subcommands: [ diff --git a/Sources/ContainerCommands/Container/ContainerDelete.swift b/Sources/ContainerCommands/Container/ContainerDelete.swift index 9a4eb866..1eddc6b8 100644 --- a/Sources/ContainerCommands/Container/ContainerDelete.swift +++ b/Sources/ContainerCommands/Container/ContainerDelete.swift @@ -59,7 +59,8 @@ extension Application { let containers: [String] if all { - containers = try await client.list().compactMap { c in + let filters = ContainerListFilters().withoutMachines() + containers = try await client.list(filters: filters).compactMap { c in // Skip running containers when using --all without --force if c.status == .running && !force { return nil diff --git a/Sources/ContainerCommands/Container/ContainerKill.swift b/Sources/ContainerCommands/Container/ContainerKill.swift index b223b9ff..dbaa4f09 100644 --- a/Sources/ContainerCommands/Container/ContainerKill.swift +++ b/Sources/ContainerCommands/Container/ContainerKill.swift @@ -56,7 +56,8 @@ extension Application { let containers: [String] if self.all { - containers = try await client.list(filters: ContainerListFilters(status: .running)).map { $0.id } + let filters = ContainerListFilters(status: .running).withoutMachines() + containers = try await client.list(filters: filters).map { $0.id } } else { containers = containerIds } diff --git a/Sources/ContainerCommands/Container/ContainerList.swift b/Sources/ContainerCommands/Container/ContainerList.swift index a7090c89..a61cedc0 100644 --- a/Sources/ContainerCommands/Container/ContainerList.swift +++ b/Sources/ContainerCommands/Container/ContainerList.swift @@ -44,7 +44,8 @@ extension Application { public func run() async throws { let client = ContainerClient() - let filters = self.all ? ContainerListFilters.all : ContainerListFilters(status: .running) + + let filters = ContainerListFilters(status: self.all ? nil : .running).withoutMachines() let containers = try await client.list(filters: filters) let items = containers.map { ManagedContainer($0) } try Output.render(payload: items, display: items, format: format, quiet: quiet) diff --git a/Sources/ContainerCommands/Container/ContainerPrune.swift b/Sources/ContainerCommands/Container/ContainerPrune.swift index 1614c44e..13bfbe7f 100644 --- a/Sources/ContainerCommands/Container/ContainerPrune.swift +++ b/Sources/ContainerCommands/Container/ContainerPrune.swift @@ -16,6 +16,7 @@ import ArgumentParser import ContainerAPIClient +import ContainerResource import ContainerizationError import Foundation @@ -33,7 +34,8 @@ extension Application { public func run() async throws { let client = ContainerClient() - let containersToPrune = try await client.list().filter { $0.status == .stopped } + let filters = ContainerListFilters(status: .stopped).withoutMachines() + let containersToPrune = try await client.list(filters: filters) var prunedContainerIds = [String]() var totalSize: UInt64 = 0 diff --git a/Sources/ContainerCommands/Container/ContainerStop.swift b/Sources/ContainerCommands/Container/ContainerStop.swift index 823a6f30..442b327d 100644 --- a/Sources/ContainerCommands/Container/ContainerStop.swift +++ b/Sources/ContainerCommands/Container/ContainerStop.swift @@ -60,7 +60,8 @@ extension Application { let containers: [String] if self.all { - containers = try await client.list().map { $0.id } + let filters = ContainerListFilters().withoutMachines() + containers = try await client.list(filters: filters).map { $0.id } } else { containers = containerIds } diff --git a/Sources/ContainerCommands/Image/ImagePrune.swift b/Sources/ContainerCommands/Image/ImagePrune.swift index d2c7cdb5..420a4eec 100644 --- a/Sources/ContainerCommands/Image/ImagePrune.swift +++ b/Sources/ContainerCommands/Image/ImagePrune.swift @@ -24,7 +24,7 @@ extension Application { public init() {} public static let configuration = CommandConfiguration( commandName: "prune", - abstract: "Remove all dangling images. If -a is specified, also remove all images not referenced by any container.") + abstract: "Remove unused or all images") @OptionGroup public var logOptions: Flags.Logging diff --git a/Sources/ContainerCommands/Machine/MachineCommand.swift b/Sources/ContainerCommands/Machine/MachineCommand.swift new file mode 100644 index 00000000..fe09bcee --- /dev/null +++ b/Sources/ContainerCommands/Machine/MachineCommand.swift @@ -0,0 +1,69 @@ +//===----------------------------------------------------------------------===// +// 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 Application { + public struct MachineCommand: AsyncLoggableCommand { + public static let configuration = CommandConfiguration( + commandName: "machine", + abstract: "Manage container machines", + discussion: """ + EXAMPLES: + List available images and create a container machine: + $ container machine create alpine:3.22 --name my-machine + + Run commands in the container machine: + $ container machine run -n my-machine uname + $ container machine run -n my-machine -- cat /proc/cpuinfo + + Change the container machine configuration (takes effect after restart): + $ container machine set -n my-machine cpus=4 memory=8G home-mount=ro + $ container machine stop my-machine + $ container machine run -n my-machine -- nproc + + Stop and delete the container machine: + $ container machine stop my-machine + $ container machine delete my-machine + """, + subcommands: [ + MachineCreate.self, + MachineDelete.self, + MachineInspect.self, + MachineList.self, + MachineLogs.self, + MachineRun.self, + MachineSet.self, + MachineSetDefault.self, + MachineStop.self, + ], + aliases: ["m"] + ) + + public init() {} + + @OptionGroup + public var logOptions: Flags.Logging + } +} + +extension Application.MachineCommand { + public enum ListFormat: String, CaseIterable, ExpressibleByArgument { + case json + case table + } +} diff --git a/Sources/ContainerCommands/Machine/MachineCreate.swift b/Sources/ContainerCommands/Machine/MachineCreate.swift new file mode 100644 index 00000000..12881a7b --- /dev/null +++ b/Sources/ContainerCommands/Machine/MachineCreate.swift @@ -0,0 +1,140 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerPersistence +import ContainerizationError +import ContainerizationOCI +import Foundation +import MachineAPIClient +import TerminalProgress + +extension Application { + public struct MachineCreate: AsyncLoggableCommand { + public init() {} + + public static let configuration = CommandConfiguration( + commandName: "create", + abstract: "Create a new container machine and boot it") + + @OptionGroup(title: "Management options") + var managementFlags: Flags.MachineManagement + + @OptionGroup(title: "Registry options") + var registryFlags: Flags.Registry + + @OptionGroup(title: "Progress options") + var progressFlags: Flags.Progress + + @OptionGroup(title: "Image fetch options") + var imageFetchFlags: Flags.ImageFetch + + @OptionGroup + public var logOptions: Flags.Logging + + @Option(name: [.short, .long], help: "Name for the container machine") + public var name: String? + + @Flag(name: .long, help: "Set this container machine as the default") + public var setDefault: Bool = false + + @Flag(name: .long, help: "Create the container machine without booting it") + public var noBoot: Bool = false + + @Option(name: .long, help: "Number of virtual CPUs") + public var cpus: Int? + + @Option(name: .long, help: "Memory allocation (e.g., 2G, 8G). Default: half of system memory") + public var memory: String? + + @Option(name: .long, help: "User's home directory mount option (ro, rw, none). Default: rw") + public var homeMount: String? + + @Argument(help: "Container image reference (e.g., alpine:3.22)") + var image: String + + public func run() async throws { + let progressConfig = try self.progressFlags.makeConfig( + showTasks: true, + showItems: true, + ignoreSmallSize: true, + totalTasks: 3 + ) + let progress = ProgressBar(config: progressConfig) + defer { + progress.finish() + } + progress.start() + + let containerSystemConfig: ContainerSystemConfig = try await ConfigurationLoader.load() + let defaultConfig = containerSystemConfig.machine + + let bootConfig = try defaultConfig.with( + [ + "cpus": cpus.map { "\($0)" }, + "memory": memory, + "home-mount": homeMount, + ].compactMapValues { $0 } + ) + + let id: String + if let name { + id = name + } else { + let reference = try Reference.parse(image) + reference.normalize() + let imageName = reference.name.components(separatedBy: "/").last! + let suffix = reference.tag ?? reference.digest ?? "latest" + id = "\(imageName)-\(suffix)" + } + + try Utility.validEntityName(id) + + let client = MachineClient() + let (config, resources) = try await MachineClient.machineConfigFromFlags( + id: id, + image: image, + management: managementFlags, + registry: registryFlags, + imageFetch: imageFetchFlags, + containerSystemConfig: containerSystemConfig, + progressUpdate: progress.handler + ) + + do { + try await client.create(configuration: config, resources: resources, bootConfig: bootConfig) + progress.finish() // Finish before subsequent output to avoid mangling + } catch let error as ContainerizationError { + if let cause = error.cause as? ContainerizationError, cause.isCode(.exists) { + let append = name == nil ? " (missing '-n/--name' flag)" : "" + throw ContainerizationError(.exists, message: cause.message + append) + } + throw error + } + + if setDefault { + try await client.setDefault(id: id) + } + + if !noBoot { + try await bootMachine(id: id, client: client, log: log, interactive: false) + } + + print(id) + } + } +} diff --git a/Sources/ContainerCommands/Machine/MachineDelete.swift b/Sources/ContainerCommands/Machine/MachineDelete.swift new file mode 100644 index 00000000..f47723c2 --- /dev/null +++ b/Sources/ContainerCommands/Machine/MachineDelete.swift @@ -0,0 +1,66 @@ +//===----------------------------------------------------------------------===// +// 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 MachineAPIClient +import TerminalProgress + +extension Application { + public struct MachineDelete: AsyncLoggableCommand { + public init() {} + + public static let configuration = CommandConfiguration( + commandName: "delete", + abstract: "Delete a container machine", + aliases: ["rm"] + ) + + @OptionGroup + public var logOptions: Flags.Logging + + @OptionGroup(visibility: .hidden) + var progressFlags: Flags.Progress + + @Argument(help: "Container machine ID") + var id: String + + public func run() async throws { + let client = MachineClient() + + let wasDefault = try await client.getDefault() == id + + let progressConfig = try self.progressFlags.makeConfig( + description: "Deleting container machine" + ) + let progress = ProgressBar(config: progressConfig) + defer { + progress.finish() + } + progress.start() + + try? await client.stop(id: id) + try await client.delete(id: id) + + progress.finish() + print(id) + + if wasDefault { + log.info("Deleted default container '\(id)'. Set a new default with 'container machine set-default '.") + } + } + } +} diff --git a/Sources/ContainerCommands/Machine/MachineHelpers.swift b/Sources/ContainerCommands/Machine/MachineHelpers.swift new file mode 100644 index 00000000..3241a3a8 --- /dev/null +++ b/Sources/ContainerCommands/Machine/MachineHelpers.swift @@ -0,0 +1,106 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerResource +import ContainerizationError +import Foundation +import Logging +import MachineAPIClient + +/// Resolves a container machine ID from an optional argument, falling back to the default machine. +func resolveMachineId(_ id: String?, client: MachineClient) async throws -> String { + if let id { + return id + } + guard let defaultId = try await client.getDefault() else { + throw ContainerizationError( + .invalidArgument, + message: "no container machine specified and no default set" + ) + } + return defaultId +} + +/// Boots a container machine and, on first ever boot, runs the in-VM init script +/// to set up the host user. Returns the resulting snapshot. +/// +/// When `interactive` is true the init script is wired to the host's terminal +/// (used by `machine run`); otherwise it runs detached so non-TTY callers like +/// `machine create` don't require a TTY or pollute host stdout. +/// +/// On any failure during user setup the machine is stopped to leave it in a clean state. +@discardableResult +func bootMachine( + id: String?, + client: MachineClient, + log: Logger, + interactive: Bool +) async throws -> MachineSnapshot { + var dynamicEnv: [String: String] = [:] + if let sshAuthSock = ProcessInfo.processInfo.environment["SSH_AUTH_SOCK"] { + dynamicEnv["SSH_AUTH_SOCK"] = sshAuthSock + } + let snapshot = try await client.boot(id: id, dynamicEnv: dynamicEnv) + + guard !snapshot.initialized else { + return snapshot + } + + do { + guard let containerId = snapshot.containerId else { + throw ContainerizationError( + .invalidState, + message: "container machine is running but has no container ID" + ) + } + + let io = try ProcessIO.create( + tty: interactive, + interactive: interactive, + detach: !interactive + ) + defer { + try? io.close() + } + + let processConfig = ProcessConfiguration( + executable: "/\(MachineBundle.sbinDirectory)/\(MachineBundle.initFile)", + arguments: ["-u"], + environment: snapshot.configuration.processEnvironment, + terminal: interactive + ) + + let process = try await ContainerClient().createProcess( + containerId: containerId, + processId: UUID().uuidString.lowercased(), + configuration: processConfig, + stdio: io.stdio) + + let exitCode = try await io.handleProcess(process: process, log: log) + guard exitCode == 0 else { + throw ContainerizationError( + .invalidState, + message: "container machine failed to create user" + ) + } + } catch { + try? await client.stop(id: snapshot.id) + throw error + } + + return snapshot +} diff --git a/Sources/ContainerCommands/Machine/MachineInspect.swift b/Sources/ContainerCommands/Machine/MachineInspect.swift new file mode 100644 index 00000000..49053c7e --- /dev/null +++ b/Sources/ContainerCommands/Machine/MachineInspect.swift @@ -0,0 +1,85 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerPersistence +import ContainerResource +import ContainerizationOCI +import Foundation +import MachineAPIClient + +extension Application { + public struct MachineInspect: AsyncLoggableCommand { + public init() {} + + public static let configuration = CommandConfiguration( + commandName: "inspect", + abstract: "Display detailed information about a container machine" + ) + + @OptionGroup + public var logOptions: Flags.Logging + + @Argument(help: "Container machine ID (uses default if not specified)") + var id: String? + + public func run() async throws { + let client = MachineClient() + let machineId = try await resolveMachineId(id, client: client) + let snapshot = try await client.inspect(id: machineId) + + let output = InspectOutput(snapshot) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode([output]) + print(String(decoding: data, as: UTF8.self)) + } + } +} + +private struct InspectOutput: Codable { + let id: String + let image: ImageDescription + let platform: ContainerizationOCI.Platform + let userSetup: UserSetup + let status: RuntimeStatus + let startedDate: Date? + let createdDate: Date? + let containerId: String? + let cpus: Int + let memory: UInt64 + let homeMount: MachineConfig.HomeMountOption + let diskSize: UInt64? + let ipAddress: String? + + init(_ snapshot: MachineSnapshot) { + self.id = snapshot.id + self.image = snapshot.configuration.image + self.platform = snapshot.platform + self.userSetup = snapshot.configuration.userSetup + self.status = snapshot.status + self.startedDate = snapshot.startedDate + self.createdDate = snapshot.createdDate + self.containerId = snapshot.containerId + self.cpus = snapshot.bootConfig.cpus + self.memory = snapshot.bootConfig.memory.toUInt64(unit: .bytes) + self.homeMount = snapshot.bootConfig.homeMount + self.diskSize = snapshot.diskSize + self.ipAddress = snapshot.ipAddress + } +} diff --git a/Sources/ContainerCommands/Machine/MachineList.swift b/Sources/ContainerCommands/Machine/MachineList.swift new file mode 100644 index 00000000..36770644 --- /dev/null +++ b/Sources/ContainerCommands/Machine/MachineList.swift @@ -0,0 +1,137 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import MachineAPIClient + +extension Application { + public struct MachineList: AsyncLoggableCommand { + public init() {} + + public static let configuration = CommandConfiguration( + commandName: "list", + abstract: "List container machines", + aliases: ["ls"] + ) + + @Option(name: .long, help: "Format of the output") + var format: MachineCommand.ListFormat = .table + + @Flag(name: .shortAndLong, help: "Only output the container machine ID") + var quiet = false + + @OptionGroup + public var logOptions: Flags.Logging + + public func run() async throws { + let client = MachineClient() + let machines = try await client.list() + + if self.quiet { + machines.forEach { print($0.id) } + return + } + + let defaultMachine = try await client.getDefault() + try printMachines(machines: machines, format: format, defaultMachine: defaultMachine) + } + + private func printMachines( + machines: [MachineSnapshot], + format: MachineCommand.ListFormat, + defaultMachine: String? + ) throws { + if format == .json { + let printables = machines.map { + PrintableMachine($0, isDefault: $0.id == defaultMachine) + } + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode(printables) + print(String(decoding: data, as: UTF8.self)) + return + } + + var rows: [[String]] = [["NAME", "CREATED", "IP", "CPUS", "MEMORY", "DISK", "STATE", "DEFAULT"]] + for machine in machines { + rows.append([ + machine.id, + machine.createdDate.map { formatDate($0) } ?? "-", + machine.ipAddress ?? "-", + "\(machine.bootConfig.cpus)", + formatMemory(machine.bootConfig.memory.toUInt64(unit: .bytes)), + machine.diskSize.map { formatMemory($0) } ?? "-", + machine.status.rawValue, + machine.id == defaultMachine ? "*" : "", + ]) + } + + let formatter = TableOutput(rows: rows) + print(formatter.format()) + } + } +} + +private func formatMemory(_ bytes: UInt64) -> String { + let gib: UInt64 = 1024 * 1024 * 1024 + if bytes >= gib { + if bytes % gib == 0 { + return "\(bytes / gib)G" + } + let formatted = String(format: "%.1fG", Double(bytes) / Double(gib)) + if formatted.hasSuffix(".0G") { + return "\(bytes / gib)G" + } + return formatted + } + return "\(bytes / (1024 * 1024))M" +} + +private let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + return formatter +}() + +private func formatDate(_ date: Date) -> String { + dateFormatter.string(from: date) +} + +private struct PrintableMachine: Codable { + let id: String + let status: RuntimeStatus + let `default`: Bool + let ipAddress: String? + let cpus: Int + let memory: UInt64 + let diskSize: UInt64? + let createdDate: Date? + + init(_ machine: MachineSnapshot, isDefault: Bool) { + self.id = machine.id + self.status = machine.status + self.default = isDefault + self.ipAddress = machine.ipAddress + self.cpus = machine.bootConfig.cpus + self.memory = machine.bootConfig.memory.toUInt64(unit: .bytes) + self.diskSize = machine.diskSize + self.createdDate = machine.createdDate + } +} diff --git a/Sources/ContainerCommands/Machine/MachineLogs.swift b/Sources/ContainerCommands/Machine/MachineLogs.swift new file mode 100644 index 00000000..b76cebe9 --- /dev/null +++ b/Sources/ContainerCommands/Machine/MachineLogs.swift @@ -0,0 +1,152 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationError +import ContainerizationOS +import Foundation +import MachineAPIClient + +extension Application { + public struct MachineLogs: AsyncLoggableCommand { + public init() {} + + public static let configuration = CommandConfiguration( + commandName: "logs", + abstract: "Fetch container machine logs" + ) + + @Flag(name: .long, help: "Display the boot log for the container machine instead of stdio") + var boot: Bool = false + + @Flag(name: .shortAndLong, help: "Follow log output") + var follow: Bool = false + + @Option(name: .short, help: "Number of lines to show from the end of the logs. If not provided this will print all of the logs") + var numLines: Int? + + @OptionGroup + public var logOptions: Flags.Logging + + @Argument(help: "Machine VM ID (uses default if not specified)") + var id: String? + + public func run() async throws { + let sigHandler = AsyncSignalHandler.create(notify: [SIGINT, SIGTERM]) + + Task { + for await _ in sigHandler.signals { + Darwin.exit(0) + } + } + + let client = MachineClient() + let id = try await resolveMachineId(id, client: client) + + let fhs = try await client.logs(id: id) + let fileHandle = boot ? fhs[1] : fhs[0] + + try await Self.tail( + fh: fileHandle, + n: numLines, + follow: follow, + ) + } + + private static func tail( + fh: FileHandle, + n: Int?, + follow: Bool + ) async throws { + if let n { + var buffer = Data() + let size = try fh.seekToEnd() + var offset = size + var lines: [String] = [] + + while offset > 0, lines.count < n { + let readSize = min(1024, offset) + offset -= readSize + try fh.seek(toOffset: offset) + + let data = fh.readData(ofLength: Int(readSize)) + buffer.insert(contentsOf: data, at: 0) + + if let chunk = String(data: buffer, encoding: .utf8) { + lines = chunk.components(separatedBy: .newlines) + lines = lines.filter { !$0.isEmpty } + } + } + + lines = Array(lines.suffix(n)) + for line in lines { + print(line) + } + } else { + // Fast path if all they want is the full file. + guard let data = try fh.readToEnd() else { + // Seems you get nil if it's a zero byte read, or you + // try and read from dev/null. + return + } + guard let str = String(data: data, encoding: .utf8) else { + throw ContainerizationError( + .internalError, + message: "failed to convert container logs to utf8" + ) + } + print(str.trimmingCharacters(in: .newlines)) + } + + fflush(stdout) + if follow { + setbuf(stdout, nil) + try await Self.followFile(fh: fh) + } + } + + private static func followFile(fh: FileHandle) async throws { + _ = try fh.seekToEnd() + let stream = AsyncStream { cont in + fh.readabilityHandler = { handle in + let data = handle.availableData + if data.isEmpty { + // Triggers on container restart - can exit here as well + do { + _ = try fh.seekToEnd() // To continue streaming existing truncated log files + } catch { + fh.readabilityHandler = nil + cont.finish() + return + } + } + if let str = String(data: data, encoding: .utf8), !str.isEmpty { + var lines = str.components(separatedBy: .newlines) + lines = lines.filter { !$0.isEmpty } + for line in lines { + cont.yield(line) + } + } + } + } + + for await line in stream { + print(line) + } + } + } +} diff --git a/Sources/ContainerCommands/Machine/MachineRun.swift b/Sources/ContainerCommands/Machine/MachineRun.swift new file mode 100644 index 00000000..dc62132c --- /dev/null +++ b/Sources/ContainerCommands/Machine/MachineRun.swift @@ -0,0 +1,164 @@ +//===----------------------------------------------------------------------===// +// 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 + } + } +} diff --git a/Sources/ContainerCommands/Machine/MachineSet.swift b/Sources/ContainerCommands/Machine/MachineSet.swift new file mode 100644 index 00000000..ea9cd048 --- /dev/null +++ b/Sources/ContainerCommands/Machine/MachineSet.swift @@ -0,0 +1,81 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerPersistence +import ContainerizationError +import Foundation +import MachineAPIClient + +extension Application { + public struct MachineSet: AsyncLoggableCommand { + public init() {} + + public static let configuration = CommandConfiguration( + commandName: "set", + abstract: "Set container machine configuration values" + ) + + @OptionGroup + public var logOptions: Flags.Logging + + @Option(name: [.short, .long], help: "Container machine ID (uses default if not specified)") + public var name: String? + + @Argument( + parsing: .remaining, + help: ArgumentHelp( + "Configuration values", + discussion: MachineConfig.helpText(), + valueName: "setting" + ) + ) + public var rawArgs: [String] = [] + + public func run() async throws { + guard !rawArgs.isEmpty else { + throw ContainerizationError(.invalidArgument, message: "expected at least one configuration value (e.g., machine set cpus=4)") + } + + let client = MachineClient() + let resolvedName = try await resolveMachineId(name, client: client) + let snapshot = try await client.inspect(id: resolvedName) + + let kwargs = Dictionary( + try rawArgs.map { arg in + let parts = arg.split(separator: "=", maxSplits: 1) + guard parts.count == 2 else { + throw ContainerizationError(.invalidArgument, message: "invalid argument format '\(arg)'. Expected 'key=value'") + } + + return (String(parts[0]), String(parts[1])) + }, + uniquingKeysWith: { _, last in last } + ) + let newConfig = try snapshot.bootConfig.with(kwargs) + + try await client.setConfig(id: resolvedName, bootConfig: newConfig) + + if snapshot.status == .running { + FileHandle.standardError.write( + Data("Note: Changes will take effect after stopping and restarting '\(resolvedName)'.\n".utf8)) + } + + print(resolvedName) + } + } +} diff --git a/Sources/ContainerCommands/Machine/MachineSetDefault.swift b/Sources/ContainerCommands/Machine/MachineSetDefault.swift new file mode 100644 index 00000000..a73f9c75 --- /dev/null +++ b/Sources/ContainerCommands/Machine/MachineSetDefault.swift @@ -0,0 +1,41 @@ +//===----------------------------------------------------------------------===// +// 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 MachineAPIClient + +extension Application { + public struct MachineSetDefault: AsyncLoggableCommand { + public init() {} + + public static let configuration = CommandConfiguration( + commandName: "set-default", + abstract: "Set the default container machine") + + @OptionGroup + public var logOptions: Flags.Logging + + @Argument(help: "Container machine ID") + public var id: String + + public func run() async throws { + let client = MachineClient() + try await client.setDefault(id: id) + print(id) + } + } +} diff --git a/Sources/ContainerCommands/Machine/MachineStop.swift b/Sources/ContainerCommands/Machine/MachineStop.swift new file mode 100644 index 00000000..3a0dedd8 --- /dev/null +++ b/Sources/ContainerCommands/Machine/MachineStop.swift @@ -0,0 +1,61 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationError +import MachineAPIClient +import TerminalProgress + +extension Application { + public struct MachineStop: AsyncLoggableCommand { + public init() {} + + public static let configuration = CommandConfiguration( + commandName: "stop", + abstract: "Stop a running container machine" + ) + + @OptionGroup + public var logOptions: Flags.Logging + + @OptionGroup(visibility: .hidden) + var progressFlags: Flags.Progress + + @Argument(help: "container machine ID (uses default if not specified)") + var id: String? + + public func run() async throws { + let client = MachineClient() + + let machineId = try await resolveMachineId(id, client: client) + + let progressConfig = try self.progressFlags.makeConfig( + description: "Stopping container machine" + ) + let progress = ProgressBar(config: progressConfig) + defer { + progress.finish() + } + progress.start() + + try await client.stop(id: machineId) + + progress.finish() + print(machineId) + } + } +} diff --git a/Sources/ContainerCommands/System/SystemStart.swift b/Sources/ContainerCommands/System/SystemStart.swift index fc3db094..4b3a9624 100644 --- a/Sources/ContainerCommands/System/SystemStart.swift +++ b/Sources/ContainerCommands/System/SystemStart.swift @@ -21,6 +21,7 @@ import ContainerPlugin import ContainerXPC import ContainerizationError import Foundation +import MachineAPIClient import SystemPackage import TerminalProgress @@ -139,6 +140,16 @@ extension Application { ) } + do { + print("Verifying machine API server is running...") + _ = try await MachineClient().list() + } catch { + throw ContainerizationError( + .internalError, + message: "failed to get a response from machine API server: \(error)" + ) + } + if await !initImageExists(containerSystemConfig: containerSystemConfig) { try? await installInitialFilesystem(initImage: containerSystemConfig.vminit.image) } diff --git a/Sources/ContainerPersistence/ContainerSystemConfig.swift b/Sources/ContainerPersistence/ContainerSystemConfig.swift index 1b7ee0f4..f0b9f36f 100644 --- a/Sources/ContainerPersistence/ContainerSystemConfig.swift +++ b/Sources/ContainerPersistence/ContainerSystemConfig.swift @@ -28,6 +28,7 @@ public final class ContainerSystemConfig: Codable, Sendable, Initable { public let container: ContainerConfig public let dns: DNSConfig public let kernel: KernelConfig + public let machine: MachineConfig public let network: NetworkConfig public let registry: RegistryConfig public let vminit: VminitConfig @@ -37,6 +38,7 @@ public final class ContainerSystemConfig: Codable, Sendable, Initable { container: ContainerConfig = .init(), dns: DNSConfig = .init(), kernel: KernelConfig = .init(), + machine: MachineConfig = MachineConfig.default, network: NetworkConfig = .init(), registry: RegistryConfig = .init(), vminit: VminitConfig = .init() @@ -45,6 +47,7 @@ public final class ContainerSystemConfig: Codable, Sendable, Initable { self.container = container self.dns = dns self.kernel = kernel + self.machine = machine self.network = network self.registry = registry self.vminit = vminit @@ -55,6 +58,7 @@ public final class ContainerSystemConfig: Codable, Sendable, Initable { self.container = .init() self.dns = .init() self.kernel = .init() + self.machine = MachineConfig.default self.network = .init() self.registry = .init() self.vminit = .init() @@ -66,6 +70,7 @@ public final class ContainerSystemConfig: Codable, Sendable, Initable { self.container = try container.decodeIfPresent(ContainerConfig.self, forKey: .container) ?? .init() self.dns = try container.decodeIfPresent(DNSConfig.self, forKey: .dns) ?? .init() self.kernel = try container.decodeIfPresent(KernelConfig.self, forKey: .kernel) ?? .init() + self.machine = try container.decodeIfPresent(MachineConfig.self, forKey: .machine) ?? .init(cpus: nil, memory: nil, homeMount: nil) self.network = try container.decodeIfPresent(NetworkConfig.self, forKey: .network) ?? .init() self.registry = try container.decodeIfPresent(RegistryConfig.self, forKey: .registry) ?? .init() self.vminit = try container.decodeIfPresent(VminitConfig.self, forKey: .vminit) ?? .init() diff --git a/Sources/ContainerPersistence/MachineConfig.swift b/Sources/ContainerPersistence/MachineConfig.swift new file mode 100644 index 00000000..62878fc1 --- /dev/null +++ b/Sources/ContainerPersistence/MachineConfig.swift @@ -0,0 +1,149 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationError +import Foundation + +/// Boot-time configuration for a container machine. +/// +/// These values can be modified without recreating the container machine. +/// Changes take effect on the next boot. `nil` values mean +/// "use the container runtime default." +public struct MachineConfig: Codable, Sendable { + public static let `default`: MachineConfig = try! .init(cpus: nil, memory: nil, homeMount: nil) + + public static var defaultCPUs: Int { + max(ProcessInfo.processInfo.processorCount / 2, 4) + } + + public static var defaultMemory: MemorySize { + let bytes = max(ProcessInfo.processInfo.physicalMemory / 2, 1024 * 1024 * 1024) + let gb = bytes / (1024 * 1024 * 1024) + return try! MemorySize("\(gb)gb") + } + + public static let defaultHomeMount: HomeMountOption = .rw + + /// Home mount option for the /Users/ directory. + public enum HomeMountOption: String, Sendable, Codable { + case ro + case rw + case none + } + + /// Number of virtual CPUs. + public let cpus: Int + /// Memory in bytes. + public let memory: MemorySize + /// Home mount configuration. nil = system default. + public let homeMount: HomeMountOption + + /// Settable keys and their descriptions, for CLI help text generation. + public static let settableKeys: [(key: String, valueName: String, description: String)] = [ + ("cpus", "", "Number of virtual CPUs"), + ("memory", "", "Memory allocation (e.g., 2G, 1G). Default: half of system memory"), + ("home-mount", "", "User home directory mount option (ro, rw, none). Default: rw"), + ] + + public init(cpus: Int?, memory: MemorySize?, homeMount: HomeMountOption?) throws { + self.cpus = cpus ?? Self.defaultCPUs + self.memory = memory ?? Self.defaultMemory + self.homeMount = homeMount ?? Self.defaultHomeMount + + try self.validate() + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let cpus = try container.decodeIfPresent(Int.self, forKey: .cpus) + let memory = try container.decodeIfPresent(MemorySize.self, forKey: .memory) + let homeMount = try container.decodeIfPresent(HomeMountOption.self, forKey: .homeMount) + + try self.init(cpus: cpus, memory: memory, homeMount: homeMount) + } + + private func validate() throws { + guard self.cpus > 0 else { + throw ContainerizationError( + .invalidArgument, + message: "invalid CPU count '\(self.cpus)'. Must be a positive integer (e.g., 4)." + ) + } + + guard self.memory.toUInt64(unit: .bytes) >= 1024 * 1024 * 1024 else { + throw ContainerizationError( + .invalidArgument, + message: "invalid memory value '\(self.memory)'. Must be greater than 1gb." + ) + } + } +} + +extension MachineConfig { + /// Generate a help discussion string listing all settable keys. + public static func helpText() -> String { + settableKeys.map { entry in + let label = "\(entry.key)=\(entry.valueName)" + let padding = String(repeating: " ", count: max(1, 24 - label.count)) + return "\(label)\(padding)\(entry.description)" + }.joined(separator: "\n") + } + + /// Create a new MachineConfig from `self`, applying fields defined in `kwargs` + /// This function is used in both `machine create` and `machine set` + public func with(_ kwargs: [String: String]) throws -> MachineConfig { + let validKeys = Set(Self.settableKeys.map(\.key)) + let unknownKeys = Set(kwargs.keys).subtracting(validKeys) + guard unknownKeys.isEmpty else { + throw ContainerizationError( + .invalidArgument, + message: "unknown fields '\(unknownKeys.joined(separator: ", "))'. Valid: \(validKeys.joined(separator: ", "))") + } + + let cpus = try kwargs["cpus"].map { try Self.parseInt($0, for: "cpus") } + let memory = try kwargs["memory"].map { try MemorySize($0) } + let homeMount = try kwargs["home-mount"].map { try Self.parseHomeMount($0) } + + return try .init( + cpus: cpus ?? self.cpus, + memory: memory ?? self.memory, + homeMount: homeMount ?? self.homeMount + ) + } + + /// Parse and validate a CPU count from user input. + private static func parseInt(_ value: String, for key: String) throws -> Int { + guard let num = Int(value) else { + throw ContainerizationError( + .invalidArgument, + message: "failed to parse \(value) for \(key)" + ) + } + return num + } + + /// Parse and validate a home mount option from user input. + private static func parseHomeMount(_ value: String) throws -> MachineConfig.HomeMountOption { + guard let opt = MachineConfig.HomeMountOption(rawValue: value) else { + throw ContainerizationError( + .invalidArgument, + message: "invalid home mount option '\(value)'. Valid options: ro, rw, none" + ) + } + return opt + } +} diff --git a/Sources/ContainerPersistence/MemorySize.swift b/Sources/ContainerPersistence/MemorySize.swift index 176b76d3..e0762b37 100644 --- a/Sources/ContainerPersistence/MemorySize.swift +++ b/Sources/ContainerPersistence/MemorySize.swift @@ -54,3 +54,9 @@ public struct MemorySize: Codable, Sendable, Equatable, CustomStringConvertible return "\(value)\(label)" } } + +extension MemorySize { + public func toUInt64(unit: UnitInformationStorage) -> UInt64 { + UInt64(self.measurement.converted(to: unit).value.rounded()) + } +} diff --git a/Sources/ContainerPlugin/PluginStateRoot.swift b/Sources/ContainerPlugin/PluginStateRoot.swift new file mode 100644 index 00000000..bcb81312 --- /dev/null +++ b/Sources/ContainerPlugin/PluginStateRoot.swift @@ -0,0 +1,36 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationError +import Foundation +import SystemPackage + +public struct PluginStateRoot { + private let plugin: FilePath.Component + + public init(plugin: String) throws { + guard let plugin = FilePath.Component(plugin) else { + throw ContainerizationError(.invalidArgument, message: "invalid plugin name \(plugin)") + } + self.plugin = plugin + } + + public var path: FilePath { + ApplicationRoot.path + .appending(FilePath.Component("plugin-state")) + .appending(plugin) + } +} diff --git a/Sources/ContainerResource/Container/ContainerConfiguration.swift b/Sources/ContainerResource/Container/ContainerConfiguration.swift index 3c0a3256..aa6a7e8e 100644 --- a/Sources/ContainerResource/Container/ContainerConfiguration.swift +++ b/Sources/ContainerResource/Container/ContainerConfiguration.swift @@ -156,6 +156,8 @@ public struct ContainerConfiguration: Sendable, Codable { public var memoryInBytes: UInt64 = 1024.mib() /// Storage quota/size in bytes. public var storage: UInt64? + /// Additional CPU cores allocated for VM overhead (guest agent, etc). + public var cpuOverhead: Int = 1 public init() {} } diff --git a/Sources/ContainerResource/Container/ContainerListFilters.swift b/Sources/ContainerResource/Container/ContainerListFilters.swift index 038b76d7..eee3518a 100644 --- a/Sources/ContainerResource/Container/ContainerListFilters.swift +++ b/Sources/ContainerResource/Container/ContainerListFilters.swift @@ -18,11 +18,19 @@ import Foundation /// Filters for listing containers. public struct ContainerListFilters: Sendable, Codable { + public static func exclude(_ str: String) -> String { + "^(?!\(str)$)" + } + /// Filter by container IDs. If non-empty, only containers with matching IDs are returned. public var ids: [String] /// Filter by container status. public var status: RuntimeStatus? - /// Filter by labels. All specified labels must match. + /// Filter by labels. All specified labels must match. Values are treated as regular expressions + /// matched against the container's label value. If a container does not have the specified key, + /// the value is treated as an empty string. This means a positive pattern (e.g. ``^b$``) will + /// exclude containers without the label, while a negation pattern (e.g. ``^(?!b$)``) will + /// include them. public var labels: [String: String] /// No filters applied. Will return all containers. @@ -38,3 +46,10 @@ public struct ContainerListFilters: Sendable, Codable { self.labels = labels } } + +extension ContainerListFilters { + public func withoutMachines() -> ContainerListFilters { + let labels = self.labels.merging([ResourceLabelKeys.plugin: Self.exclude("machine")]) { _, new in new } + return ContainerListFilters(ids: self.ids, status: self.status, labels: labels) + } +} diff --git a/Sources/Plugins/MachineAPIServer/MachineAPIServer+Start.swift b/Sources/Plugins/MachineAPIServer/MachineAPIServer+Start.swift new file mode 100644 index 00000000..6e3b8900 --- /dev/null +++ b/Sources/Plugins/MachineAPIServer/MachineAPIServer+Start.swift @@ -0,0 +1,94 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerLog +import ContainerPlugin +import ContainerXPC +import Foundation +import Logging +import MachineAPIClient +import MachineAPIService +import SystemPackage + +extension MachineAPIServer { + struct Start: AsyncParsableCommand { + private static let commandName = "container-machine-apiserver" + private static let logFile = FilePath.Component("container-machine-apiserver.log") + + static let configuration = CommandConfiguration( + commandName: "start", + abstract: "Start helper for the API server" + ) + + @Flag(name: .long, help: "Enable debug logging") + var debug = false + + @Option(help: "Path to the resources directory") + var resources: String + + var logRoot = LogRoot.path + + var pluginStateRoot: FilePath { + get throws { try PluginStateRoot(plugin: "machine-apiserver").path } + } + + func run() async throws { + let debug = debug || (ProcessInfo.processInfo.environment["CONTAINER_DEBUG"] != nil) + + let logPath = logRoot.map { $0.appending(Self.logFile) } + let log = ServiceLogger.bootstrap(category: "MachineAPIServer", debug: debug, logPath: logPath) + log.info("starting helper", metadata: ["name": "\(Self.commandName)"]) + defer { + log.info("stopping helper", metadata: ["name": "\(Self.commandName)"]) + } + + do { + log.info("configuring XPC server") + + let resourceRoot = FilePath(resources) + let service = try MachinesService(appRoot: pluginStateRoot, resourceRoot: resourceRoot, log: log) + let harness = MachinesHarness(service: service) + + let server = XPCServer( + identifier: MachineClient.serviceIdentifier, + routes: [ + MachineRoutes.listMachine.rawValue: XPCServer.route(harness.list), + MachineRoutes.createMachine.rawValue: XPCServer.route(harness.create), + MachineRoutes.deleteMachine.rawValue: XPCServer.route(harness.delete), + MachineRoutes.setDefault.rawValue: XPCServer.route(harness.setDefault), + MachineRoutes.getDefault.rawValue: XPCServer.route(harness.getDefault), + MachineRoutes.bootMachine.rawValue: XPCServer.route(harness.boot), + MachineRoutes.stopMachine.rawValue: XPCServer.route(harness.stop), + MachineRoutes.inspectMachine.rawValue: XPCServer.route(harness.inspect), + MachineRoutes.setConfig.rawValue: XPCServer.route(harness.setConfig), + MachineRoutes.logsMachine.rawValue: XPCServer.route(harness.logs), + ], log: log) + + log.info("starting XPC server") + try await server.listen() + } catch { + log.error( + "helper failed", + metadata: [ + "name": "\(Self.commandName)", + "error": "\(error)", + ]) + MachineAPIServer.exit(withError: error) + } + } + } +} diff --git a/Sources/Plugins/MachineAPIServer/MachineAPIServer.swift b/Sources/Plugins/MachineAPIServer/MachineAPIServer.swift new file mode 100644 index 00000000..84283769 --- /dev/null +++ b/Sources/Plugins/MachineAPIServer/MachineAPIServer.swift @@ -0,0 +1,28 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerVersion + +@main +struct MachineAPIServer: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "machine-apiserver", + abstract: "Container machine management API server", + version: ReleaseVersion.singleLine(appName: "machine-apiserver"), + subcommands: [Start.self], + ) +} diff --git a/Sources/Plugins/MachineAPIServer/Resources/create-user.sh b/Sources/Plugins/MachineAPIServer/Resources/create-user.sh new file mode 100755 index 00000000..0a4a4bca --- /dev/null +++ b/Sources/Plugins/MachineAPIServer/Resources/create-user.sh @@ -0,0 +1,46 @@ +#!/bin/sh +# 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. + +# +# First-time container user setup. Intended to be container machine-agnostic +# by directly manipulating /etc/group, /etc/passwd, and /etc/shadow rather +# than relying on image-specific tools (useradd, adduser, etc.). Also +# populates the home directory from /etc/skel and grants passwordless sudo +# access. +# +# Expects CONTAINER_USER, CONTAINER_UID, CONTAINER_GID, and CONTAINER_HOME to +# be set in the environment. +# + +set -e + +if ! getent group "${CONTAINER_GID}" >/dev/null 2>&1; then + echo "${CONTAINER_USER}:x:${CONTAINER_GID}:" >> /etc/group +fi + +if ! getent passwd "${CONTAINER_UID}" >/dev/null 2>&1; then + echo "${CONTAINER_USER}:x:${CONTAINER_UID}:${CONTAINER_GID}::${CONTAINER_HOME}:${CONTAINER_SHELL}" >> /etc/passwd + echo "${CONTAINER_USER}:!:19000:0:99999:7:::" >> /etc/shadow +fi + +mkdir -p "${CONTAINER_HOME}" +if [ -d /etc/skel ]; then + cp -a /etc/skel/. "${CONTAINER_HOME}" +fi +chown -R "${CONTAINER_UID}:${CONTAINER_GID}" "${CONTAINER_HOME}" + +mkdir -p /etc/sudoers.d +echo "${CONTAINER_USER} ALL=(ALL) NOPASSWD:ALL" > "/etc/sudoers.d/${CONTAINER_USER}" +chmod 440 "/etc/sudoers.d/${CONTAINER_USER}" diff --git a/Sources/Plugins/MachineAPIServer/Resources/init b/Sources/Plugins/MachineAPIServer/Resources/init new file mode 100755 index 00000000..3cf45115 --- /dev/null +++ b/Sources/Plugins/MachineAPIServer/Resources/init @@ -0,0 +1,75 @@ +#!/bin/sh +# +# Container machine init script — replaces /sbin/init as the first process (PID 1) +# executed when a container machine boots. Performs container machine-specific setup +# before handing off to the real system init: +# +# 1. Resolve the system default shell. +# Debian/Ubuntu systems use DSHELL from /etc/adduser.conf; other images +# use SHELL from /etc/default/useradd. Falls back to /bin/bash or /bin/sh. +# +# 2. Open a user shell when invoked with the "-s" flag. +# Looks up the current user's shell from /etc/passwd and execs into it, +# falling back to the system default shell. If additional arguments follow +# "-s", runs them as a command via " -c " instead of dropping +# into an interactive session. +# +# 3. Run first-time user setup when invoked with the "-u" flag (only once). +# If /etc/.machine.initialized does not exist, runs the create-user script +# to create the container user and apply one-time configuration. A custom +# script at /etc/machine/create-user.sh takes precedence over the built-in +# one at /sbin.machine/create-user.sh. Marks initialization complete by +# touching /etc/.machine.initialized. +# +# 4. Boot the system when invoked with no flags. +# Sets the hostname to CONTAINER_MACHINE_ID and execs /sbin/init to hand +# off to the real system init. +# + +set -e + +MACHINE_INITIALIZED=/etc/.machine.initialized +CUSTOM_SETUP=/etc/machine/create-user.sh +DEFAULT_SETUP=/sbin.machine/create-user.sh + +. /etc/os-release 2>/dev/null +case "${ID:-}" in + ubuntu|debian) + SHELL=$(unset DSHELL; . /etc/adduser.conf 2>/dev/null \ + && [ -n "${DSHELL:-}" ] \ + && echo "${DSHELL}") || SHELL=/bin/bash ;; + *) + SHELL=$(unset SHELL; . /etc/default/useradd 2>/dev/null \ + && [ -n "${SHELL:-}" ] \ + && echo "${SHELL}") || SHELL=/bin/sh ;; +esac +export CONTAINER_SHELL=${SHELL} + +if [ "$1" = "-s" ]; then + shift + USER_SHELL=$(grep "^$(id -un):" /etc/passwd 2>/dev/null | cut -d: -f7) + if [ $# -gt 0 ]; then + exec "${USER_SHELL:-${SHELL}}" -c "$*" + else + exec "${USER_SHELL:-${SHELL}}" + fi +elif [ "$1" = "-u" ]; then + # DEPRECATED 0.11.0.0 - use `id` instead of checking `${MACHINE_INITIALIZED}` for backward compatibility, remove in 0.13.0.0 + if ! id "${CONTAINER_USER}" >/dev/null 2>&1; then + if [ -f ${CUSTOM_SETUP} ]; then + ${CUSTOM_SETUP} + else + ${DEFAULT_SETUP} + fi + fi + + echo 1 > ${MACHINE_INITIALIZED} +else + echo "${CONTAINER_MACHINE_ID}" > /etc/hostname + + if [ -S ${SSH_AUTH_SOCK} ]; then + chown ${CONTAINER_UID}:${CONTAINER_GID} ${SSH_AUTH_SOCK} + fi + + exec /sbin/init +fi diff --git a/Sources/Plugins/MachineAPIServer/config.toml b/Sources/Plugins/MachineAPIServer/config.toml new file mode 100644 index 00000000..c23c27cc --- /dev/null +++ b/Sources/Plugins/MachineAPIServer/config.toml @@ -0,0 +1,11 @@ +abstract = "Container machine management API plugin" +author = "Apple" + +[servicesConfig] +loadAtBoot = true +runAtLoad = false +defaultArguments = [] + +[[servicesConfig.services]] +type = "core" +description = "Provide an XPC interface to interact with the container machine API server." diff --git a/Sources/Services/ContainerAPIService/Client/Parser.swift b/Sources/Services/ContainerAPIService/Client/Parser.swift index 61779498..2a670805 100644 --- a/Sources/Services/ContainerAPIService/Client/Parser.swift +++ b/Sources/Services/ContainerAPIService/Client/Parser.swift @@ -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) diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift index 834b31bb..a37ad691 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift @@ -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)] = 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 } } diff --git a/Sources/Services/MachineAPIService/Client/Flags.swift b/Sources/Services/MachineAPIService/Client/Flags.swift new file mode 100644 index 00000000..12e4649e --- /dev/null +++ b/Sources/Services/MachineAPIService/Client/Flags.swift @@ -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? + } +} diff --git a/Sources/Services/MachineAPIService/Client/MachineBundle.swift b/Sources/Services/MachineAPIService/Client/MachineBundle.swift new file mode 100644 index 00000000..93cf2279 --- /dev/null +++ b/Sources/Services/MachineAPIService/Client/MachineBundle.swift @@ -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(filename: FilePath.Component) throws -> T where T: Decodable { + try load(path: self.path.appending(filename)) + } + + private func load(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? +} diff --git a/Sources/Services/MachineAPIService/Client/MachineClient.swift b/Sources/Services/MachineAPIService/Client/MachineClient.swift new file mode 100644 index 00000000..7f3167ff --- /dev/null +++ b/Sources/Services/MachineAPIService/Client/MachineClient.swift @@ -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 + } +} diff --git a/Sources/Services/MachineAPIService/Client/MachineConfiguration.swift b/Sources/Services/MachineAPIService/Client/MachineConfiguration.swift new file mode 100644 index 00000000..ea375ebd --- /dev/null +++ b/Sources/Services/MachineAPIService/Client/MachineConfiguration.swift @@ -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" + ) + } + } +} diff --git a/Sources/Services/MachineAPIService/Client/MachineKeys.swift b/Sources/Services/MachineAPIService/Client/MachineKeys.swift new file mode 100644 index 00000000..f699b07e --- /dev/null +++ b/Sources/Services/MachineAPIService/Client/MachineKeys.swift @@ -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 +} diff --git a/Sources/Services/MachineAPIService/Client/MachineRoutes.swift b/Sources/Services/MachineAPIService/Client/MachineRoutes.swift new file mode 100644 index 00000000..631997c7 --- /dev/null +++ b/Sources/Services/MachineAPIService/Client/MachineRoutes.swift @@ -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 +} diff --git a/Sources/Services/MachineAPIService/Client/MachineSnapshot.swift b/Sources/Services/MachineAPIService/Client/MachineSnapshot.swift new file mode 100644 index 00000000..fd79cc8e --- /dev/null +++ b/Sources/Services/MachineAPIService/Client/MachineSnapshot.swift @@ -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 + } +} diff --git a/Sources/Services/MachineAPIService/Server/MachinesHarness.swift b/Sources/Services/MachineAPIService/Server/MachinesHarness.swift new file mode 100644 index 00000000..96421395 --- /dev/null +++ b/Sources/Services/MachineAPIService/Server/MachinesHarness.swift @@ -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 + } +} diff --git a/Sources/Services/MachineAPIService/Server/MachinesService.swift b/Sources/Services/MachineAPIService/Server/MachinesService.swift new file mode 100644 index 00000000..a80cc769 --- /dev/null +++ b/Sources/Services/MachineAPIService/Server/MachinesService.swift @@ -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? + } + + 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 { 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 { [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.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 + } +} diff --git a/Sources/Services/RuntimeLinux/Server/RuntimeService.swift b/Sources/Services/RuntimeLinux/Server/RuntimeService.swift index b320d581..e51ad14b 100644 --- a/Sources/Services/RuntimeLinux/Server/RuntimeService.swift +++ b/Sources/Services/RuntimeLinux/Server/RuntimeService.swift @@ -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) diff --git a/Tests/CLITests/Subcommands/Machine/TestCLIMachine.swift b/Tests/CLITests/Subcommands/Machine/TestCLIMachine.swift new file mode 100644 index 00000000..7fc205b7 --- /dev/null +++ b/Tests/CLITests/Subcommands/Machine/TestCLIMachine.swift @@ -0,0 +1,1178 @@ +//===----------------------------------------------------------------------===// +// 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 Containerization +import Darwin +import Foundation +import MachineAPIClient +import Testing + +@Suite(.serialSuites) +class TestCLIMachineCommand: CLITest { + private func getTestName() -> String { + Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased() + } + + func runMachine(arguments: [String], stdin: Data? = nil) throws -> (outputData: Data, output: String, error: String, status: Int32) { + try run(arguments: ["machine"] + arguments, stdin: stdin) + } + + private func doCreate( + name: String, + image: String? = nil + ) throws { + let image = image ?? alpine + + var args = ["create", "--no-boot", "--name", name] + + args += [image] + + let (_, _, error, status) = try runMachine(arguments: args) + if status != 0 { + throw CLIError.executionFailed("command failed: \(error)") + } + } + + func doMachineDelete(name: String) throws { + let args = ["rm", name] + + let (_, _, error, status) = try runMachine(arguments: args) + if status != 0 { + throw CLIError.executionFailed("command failed: \(error)") + } + } + + @Test func testCreate() throws { + let name = getTestName() + + #expect(throws: Never.self, "expected container machine create to succeed") { + try doCreate(name: name) + try doMachineDelete(name: name) + } + } + + @Test func testCreateRejectsDots() throws { + let (_, _, error, status) = try runMachine(arguments: ["create", "--name", "my.bad.name", "alpine:latest"]) + #expect(status != 0, "create should reject names with dots") + #expect(error.contains("must start and end"), "error should explain the constraint") + } +} + +/// Integration tests for container machine runtime commands: stop, inspect, run, set. +/// Tests are serialized since container machine operations share system resources and +/// concurrent VM operations could interfere with each other. +@Suite(.serialSuites, .serialized) +class TestCLIMachineRuntime: CLITest { + let machineImage = "ghcr.io/linuxcontainers/alpine:3.20" + + private func getTestName() -> String { + Test.current!.name.trimmingCharacters(in: ["(", ")"]).lowercased() + } + + func runMachine(arguments: [String], env: [String: String]) throws -> (outputData: Data, output: String, error: String, status: Int32) { + try run(arguments: ["machine"] + arguments, tty: true, env: env) + } + + func runMachine(arguments: [String]) throws -> (outputData: Data, output: String, error: String, status: Int32) { + try run(arguments: ["machine"] + arguments, tty: true) + } + + private func doMachineCreate(name: String, image: String? = nil) throws { + cleanupMachine(name) + let img = image ?? machineImage + let (_, _, error, status) = try runMachine(arguments: ["create", "--no-boot", "--name", name, img]) + if status != 0 { + throw CLIError.executionFailed("container machine create failed: \(error)") + } + } + + private func doMachineBoot(name: String? = nil) throws -> String { + // Boot by running a trivial command (run auto-boots) + var args = ["run", "--root"] + if let name { args.append(contentsOf: ["-n", name]) } + args.append(contentsOf: ["true"]) + let (_, _, error, status) = try runMachine(arguments: args) + if status != 0 { + throw CLIError.executionFailed("container machine boot (via run) failed: \(error)") + } + // Return the container machine name for compatibility with existing tests + return name ?? "" + } + + private func doMachineStop(name: String? = nil) throws -> String { + var args = ["stop"] + if let name { args.append(name) } + let (_, output, error, status) = try runMachine(arguments: args) + if status != 0 { + throw CLIError.executionFailed("container machine stop failed: \(error)") + } + return output.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func doMachineInspect(name: String? = nil) throws -> MachineInspectOutput { + var args = ["inspect"] + if let name { args.append(name) } + let (outputData, _, error, status) = try runMachine(arguments: args) + if status != 0 { + throw CLIError.executionFailed("container machine inspect failed: \(error)") + } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let results = try decoder.decode([MachineInspectOutput].self, from: outputData) + guard let result = results.first else { + throw CLIError.executionFailed("container machine inspect returned empty array") + } + return result + } + + private func doMachineRun( + name: String? = nil, + root: Bool = false, + env: [String] = [], + cwd: String? = nil, + command: [String] + ) throws -> String { + var args = ["run"] + if let name { args.append(contentsOf: ["-n", name]) } + if root { args.append("--root") } + if let cwd { args.append(contentsOf: ["--cwd", cwd]) } + for e in env { args.append(contentsOf: ["-e", e]) } + args.append(contentsOf: command) + let (_, output, error, status) = try runMachine(arguments: args) + if status != 0 { + throw CLIError.executionFailed("container machine run failed: \(error)") + } + return output + } + + private func doMachineRemove(name: String) throws { + let args = ["rm", name] + let (_, _, error, status) = try runMachine(arguments: args) + if status != 0 { + throw CLIError.executionFailed("container machine rm failed: \(error)") + } + } + + private func waitForMachineStatus(_ name: String, status: String, maxAttempts: Int = 30) throws { + for _ in 0.. 0, "should have resolved cpus") + #expect(snapshot.memory > 0, "should have resolved memory") + } + + @Test func testInspectRunningMachine() throws { + let name = getTestName() + try doMachineCreate(name: name, image: machineImage) + defer { cleanupMachine(name) } + + _ = try doMachineBoot(name: name) + try waitForMachineStatus(name, status: "running") + + let snapshot = try doMachineInspect(name: name) + #expect(snapshot.status == "running", "booted container machine should be running") + #expect(snapshot.startedDate != nil, "running container machine should have startedDate") + #expect(snapshot.id == name, "configuration should have correct ID") + #expect(snapshot.platform.os == "linux", "platform OS should be linux") + #expect(snapshot.ipAddress != nil, "running machine should have IP address") + } + + @Test func testInspectNonExistentMachine() throws { + let name = "nonexistent-machine-\(UUID().uuidString.lowercased())" + + let (_, _, error, status) = try runMachine(arguments: ["inspect", name]) + #expect(status != 0, "inspect should fail for non-existent container machine") + #expect(error.contains("not found"), "error should mention 'not found'") + } + + @Test func testRunSimpleCommand() throws { + let name = getTestName() + try doMachineCreate(name: name) + defer { cleanupMachine(name) } + + _ = try doMachineBoot(name: name) + try waitForMachineStatus(name, status: "running") + + let output = try doMachineRun(name: name, root: true, command: ["echo", "hello"]) + #expect( + output.trimmingCharacters(in: .whitespacesAndNewlines) == "hello", + "run should execute command and return output" + ) + } + + /// Verifies that running a command on a stopped container machine automatically boots it. + @Test func testRunDefaultHostsEntries() throws { + let name = getTestName() + try doMachineCreate(name: name) + defer { cleanupMachine(name) } + + _ = try doMachineBoot(name: name) + try waitForMachineStatus(name, status: "running") + + let inspect = try doMachineInspect(name: name) + let ip = try #require(inspect.ipAddress, "running machine should have an IP address") + + let output = try doMachineRun(name: name, root: true, command: ["cat", "/etc/hosts"]) + let lines = output.split(separator: "\n") + + let expectedEntries = [("127.0.0.1", "localhost"), (ip, name)] + + for (i, line) in lines.enumerated() { + let words = line.split(separator: " ").map { String($0) } + #expect(words.count >= 2, "expected /etc/hosts entry to have 2 or more entries") + let expected = expectedEntries[i] + #expect(expected.0 == words[0], "expected /etc/hosts entry IP to be \(expected.0), got \(words[0])") + #expect(expected.1 == words[1], "expected /etc/hosts entry hostname to be \(expected.1), got \(words[1])") + } + } + + @Test func testRunAutoBoots() throws { + let name = getTestName() + try doMachineCreate(name: name) + defer { cleanupMachine(name) } + + let beforeSnapshot = try doMachineInspect(name: name) + #expect(beforeSnapshot.status == "stopped", "container machine should start stopped") + + let output = try doMachineRun(name: name, root: true, command: ["echo", "autoboot"]) + #expect( + output.trimmingCharacters(in: .whitespacesAndNewlines) == "autoboot", + "run should auto-boot and execute command" + ) + + let afterSnapshot = try doMachineInspect(name: name) + #expect(afterSnapshot.status == "running", "container machine should be running after auto-boot") + } + + @Test func testRunAsRoot() throws { + let name = getTestName() + try doMachineCreate(name: name) + defer { cleanupMachine(name) } + + _ = try doMachineBoot(name: name) + try waitForMachineStatus(name, status: "running") + + let output = try doMachineRun(name: name, root: true, command: ["id", "-u"]) + let uid = output.trimmingCharacters(in: .whitespacesAndNewlines) + #expect(uid == "0", "running with --root should execute as uid 0") + } + + /// Verifies that the default run mode creates a user matching the host UID/GID. + @Test func testRunAsHostUser() throws { + let name = getTestName() + try doMachineCreate(name: name) + defer { cleanupMachine(name) } + + _ = try doMachineBoot(name: name) + try waitForMachineStatus(name, status: "running") + + let hostUid = getuid() + let output = try doMachineRun(name: name, command: ["id", "-u"]) + let actualUid = output.trimmingCharacters(in: .whitespacesAndNewlines) + #expect(actualUid == "\(hostUid)", "default run should use host user's UID") + } + + /// Verifies that first-boot bootstrap creates the NOPASSWD sudoers entry + /// for the host user, even on minimal images that ship without /etc/sudoers.d. + @Test func testFirstBootCreatesSudoersEntry() throws { + let name = getTestName() + try doMachineCreate(name: name) + defer { cleanupMachine(name) } + + _ = try doMachineBoot(name: name) + try waitForMachineStatus(name, status: "running") + + let username = NSUserName() + let output = try doMachineRun( + name: name, + root: true, + command: ["cat", "/etc/sudoers.d/\(username)"] + ) + let content = output.trimmingCharacters(in: .whitespacesAndNewlines) + #expect( + content == "\(username) ALL=(ALL) NOPASSWD:ALL", + "first-boot bootstrap should create NOPASSWD sudoers entry for host user" + ) + } + + @Test func testRunWithEnvironment() throws { + let name = getTestName() + try doMachineCreate(name: name) + defer { cleanupMachine(name) } + + _ = try doMachineBoot(name: name) + try waitForMachineStatus(name, status: "running") + + let output = try doMachineRun( + name: name, + root: true, + env: ["MY_VAR=hello_world"], + command: ["echo", "$MY_VAR"] + ) + #expect( + output.trimmingCharacters(in: .whitespacesAndNewlines) == "hello_world", + "run should set environment variables" + ) + } + + @Test func testRunWithCwd() throws { + let name = getTestName() + try doMachineCreate(name: name) + defer { cleanupMachine(name) } + + _ = try doMachineBoot(name: name) + try waitForMachineStatus(name, status: "running") + + let output = try doMachineRun(name: name, root: true, cwd: "/tmp", command: ["pwd"]) + #expect( + output.trimmingCharacters(in: .whitespacesAndNewlines) == "/tmp", + "run should use specified working directory" + ) + } + + @Test func testRunNonExistentMachine() throws { + let name = "nonexistent-machine-\(UUID().uuidString.lowercased())" + + let (_, _, error, status) = try runMachine(arguments: ["run", "-n", name, "echo", "test"]) + #expect(status != 0, "run should fail for non-existent container machine") + #expect(error.contains("not found"), "error should mention 'not found'") + } + + /// End-to-end test covering the full container machine lifecycle including re-boot after stop. + @Test func testFullLifecycle() throws { + let name = getTestName() + + try doMachineCreate(name: name) + defer { cleanupMachine(name) } + + var snapshot = try doMachineInspect(name: name) + #expect(snapshot.status == "stopped", "new container machine should be stopped") + + _ = try doMachineBoot(name: name) + try waitForMachineStatus(name, status: "running") + + snapshot = try doMachineInspect(name: name) + #expect(snapshot.status == "running", "container machine should be running after boot") + + let output = try doMachineRun(name: name, root: true, command: ["hostname"]) + #expect(!output.isEmpty, "should be able to run commands") + + _ = try doMachineStop(name: name) + + snapshot = try doMachineInspect(name: name) + #expect(snapshot.status == "stopped", "container machine should be stopped after stop") + + _ = try doMachineBoot(name: name) + try waitForMachineStatus(name, status: "running") + + snapshot = try doMachineInspect(name: name) + #expect(snapshot.status == "running", "container machine should be running after re-boot") + } + + /// Verifies that user setup is idempotent - running multiple commands doesn't fail. + @Test func testUserSetupIdempotent() throws { + let name = getTestName() + try doMachineCreate(name: name) + defer { cleanupMachine(name) } + + _ = try doMachineBoot(name: name) + try waitForMachineStatus(name, status: "running") + + let output1 = try doMachineRun(name: name, command: ["id", "-u"]) + let output2 = try doMachineRun(name: name, command: ["id", "-u"]) + + let uid1 = output1.trimmingCharacters(in: .whitespacesAndNewlines) + let uid2 = output2.trimmingCharacters(in: .whitespacesAndNewlines) + + #expect(uid1 == uid2, "user setup should be idempotent") + #expect(uid1 == "\(getuid())", "should run as host user") + } + + /// Verifies that the HOME environment variable is correctly set for the host user. + @Test func testHostUserHasCorrectHome() throws { + let name = getTestName() + try doMachineCreate(name: name) + defer { cleanupMachine(name) } + + _ = try doMachineBoot(name: name) + try waitForMachineStatus(name, status: "running") + + let hostUsername = NSUserName() + + let output = try doMachineRun(name: name, command: ["echo", "$HOME"]) + let home = output.trimmingCharacters(in: .whitespacesAndNewlines) + #expect(home == "/home/\(hostUsername)", "HOME should be set to /home/") + } + + @Test func testListRunningMachines() throws { + let name = getTestName() + try doMachineCreate(name: name) + defer { cleanupMachine(name) } + + _ = try doMachineBoot(name: name) + try waitForMachineStatus(name, status: "running") + + let (_, output, _, status) = try runMachine(arguments: ["ls"]) + #expect(status == 0, "list should succeed") + #expect(output.contains(name), "list should show running container machine") + } + + @Test func testListAllMachines() throws { + let name = getTestName() + try doMachineCreate(name: name) + defer { cleanupMachine(name) } + + let (_, output, _, status) = try runMachine(arguments: ["ls"]) + #expect(status == 0, "list should succeed") + #expect(output.contains(name), "stopped container machine should appear in list") + } + + @Test func testListQuietMode() throws { + let name = getTestName() + try doMachineCreate(name: name) + defer { cleanupMachine(name) } + + _ = try doMachineBoot(name: name) + try waitForMachineStatus(name, status: "running") + + let (_, output, _, status) = try runMachine(arguments: ["ls", "-q"]) + #expect(status == 0, "list -q should succeed") + #expect(output.trimmingCharacters(in: .whitespacesAndNewlines) == name, "quiet mode should output only ID") + } + + @Test func testListJsonFormat() throws { + let name = getTestName() + try doMachineCreate(name: name) + defer { cleanupMachine(name) } + + _ = try doMachineBoot(name: name) + try waitForMachineStatus(name, status: "running") + + let (outputData, _, _, status) = try runMachine(arguments: ["ls", "--format", "json"]) + #expect(status == 0, "list --format json should succeed") + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let items = try decoder.decode([MachineListItem].self, from: outputData) + let item = items.first { $0.id == name } + #expect(item != nil, "JSON output should contain the machine") + #expect(item?.status == "running") + #expect(item?.ipAddress != nil, "running machine should have an address") + #expect(item?.cpus ?? 0 > 0, "cpus should be resolved") + #expect(item?.memory ?? 0 > 0, "memory should be resolved") + #expect(item?.createdDate != nil, "createdDate should be set") + } + + @Test func testListEmpty() throws { + // List with no running container machines + let (_, output, _, status) = try runMachine(arguments: ["ls"]) + #expect(status == 0, "list should succeed even with no running container machines") + // Output should just be header or empty + #expect(!output.contains("error"), "should not contain error") + } + + @Test func testSetDefault() throws { + let name = getTestName() + try doMachineCreate(name: name) + defer { cleanupMachine(name) } + + let (_, output, _, status) = try runMachine(arguments: ["set-default", name]) + #expect(status == 0, "set default should succeed") + #expect(output.trimmingCharacters(in: .whitespacesAndNewlines) == name, "should output the container machine ID") + + // Verify it's now the default by listing + let (_, listOutput, _, _) = try runMachine(arguments: ["ls"]) + #expect(listOutput.contains("*"), "container machine should be marked as default in list") + } + + @Test func testSetDefaultNonExistent() throws { + let name = "nonexistent-machine-\(UUID().uuidString.lowercased())" + + let (_, _, error, status) = try runMachine(arguments: ["set-default", name]) + #expect(status != 0, "set default should fail for non-existent container machine") + #expect(error.contains("not found"), "error should mention 'not found'") + } + + @Test func testSetDefaultSwitching() throws { + let name1 = "\(getTestName())-1" + let name2 = "\(getTestName())-2" + try doMachineCreate(name: name1) + try doMachineCreate(name: name2) + defer { + cleanupMachine(name1) + cleanupMachine(name2) + } + + // Set first as default + _ = try runMachine(arguments: ["set-default", name1]) + + // Set second as default + let (_, output, _, status) = try runMachine(arguments: ["set-default", name2]) + #expect(status == 0, "switching default should succeed") + #expect(output.trimmingCharacters(in: .whitespacesAndNewlines) == name2) + + // Verify by inspecting without specifying ID (uses default) + let snapshot = try doMachineInspect() + #expect(snapshot.id == name2, "inspect without ID should use new default") + } + + @Test func testInspectUsesDefault() throws { + let name = getTestName() + try doMachineCreate(name: name) + defer { cleanupMachine(name) } + + _ = try runMachine(arguments: ["set-default", name]) + + // Inspect without specifying name + let snapshot = try doMachineInspect() + #expect(snapshot.id == name, "inspect should use default container machine") + } + + @Test func testRunUsesDefault() throws { + let name = getTestName() + try doMachineCreate(name: name) + defer { cleanupMachine(name) } + + _ = try runMachine(arguments: ["set-default", name]) + _ = try doMachineBoot(name: name) + try waitForMachineStatus(name, status: "running") + + // Run without specifying -n + let args = ["run", "--root", "echo", "default-test"] + let (_, output, _, status) = try runMachine(arguments: args) + #expect(status == 0, "run should succeed using default") + #expect(output.trimmingCharacters(in: .whitespacesAndNewlines) == "default-test") + } + + @Test func testRunWithUid() throws { + let name = getTestName() + try doMachineCreate(name: name) + defer { cleanupMachine(name) } + + _ = try doMachineBoot(name: name) + try waitForMachineStatus(name, status: "running") + + let (_, output, _, status) = try runMachine(arguments: ["run", "-n", name, "--uid", "1000", "id", "-u"]) + #expect(status == 0, "run with --uid should succeed") + #expect(output.trimmingCharacters(in: .whitespacesAndNewlines) == "1000", "should run as specified UID") + } + + @Test func testRunWithGid() throws { + let name = getTestName() + try doMachineCreate(name: name) + defer { cleanupMachine(name) } + + _ = try doMachineBoot(name: name) + try waitForMachineStatus(name, status: "running") + + let (_, output, _, status) = try runMachine(arguments: ["run", "-n", name, "--root", "--gid", "1000", "id", "-G"]) + #expect(status == 0, "run with --gid should succeed") + #expect(output.trimmingCharacters(in: .whitespacesAndNewlines) == "0 1000", "should run with specified GID") + } + + @Test func testRunWithEnvFile() throws { + let name = getTestName() + try doMachineCreate(name: name) + defer { cleanupMachine(name) } + + _ = try doMachineBoot(name: name) + try waitForMachineStatus(name, status: "running") + + // Create a temp env file + let tempDir = FileManager.default.temporaryDirectory + let envFile = tempDir.appendingPathComponent("test-\(name).env") + try "TEST_VAR=from_file\nANOTHER_VAR=value2\n".write(to: envFile, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: envFile) } + + let (_, output, _, status) = try runMachine(arguments: [ + "run", "-n", name, "--root", + "--env-file", envFile.path, + "echo", "$TEST_VAR", + ]) + #expect(status == 0, "run with --env-file should succeed") + #expect(output.trimmingCharacters(in: .whitespacesAndNewlines) == "from_file", "should load env from file") + } + + /// Verifies that machine run executes commands through a shell, enabling variable expansion. + @Test func testRunCommandInShell() throws { + let name = getTestName() + try doMachineCreate(name: name) + defer { cleanupMachine(name) } + + _ = try doMachineBoot(name: name) + try waitForMachineStatus(name, status: "running") + + // $0 is passed as a literal string by the test; if the command is run + // through a shell, the shell expands it to a real path (e.g. /bin/bash). + let output = try doMachineRun(name: name, root: true, command: ["echo", "$0"]) + let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines) + #expect(!trimmed.isEmpty, "should produce output") + #expect(trimmed == "/bin/sh", "alpine shell should expand $0 to /bin/sh") + } + + @Test func testRunCommandExitCode() throws { + let name = getTestName() + try doMachineCreate(name: name) + defer { cleanupMachine(name) } + + _ = try doMachineBoot(name: name) + try waitForMachineStatus(name, status: "running") + + // Run a command that exits with non-zero + let (_, _, _, status) = try runMachine(arguments: ["run", "-n", name, "--root", "exit", "42"]) + #expect(status == 42, "exit code should propagate from command") + } + + @Test func testRunMultipleEnvVars() throws { + let name = getTestName() + try doMachineCreate(name: name) + defer { cleanupMachine(name) } + + _ = try doMachineBoot(name: name) + try waitForMachineStatus(name, status: "running") + + let (_, output, _, status) = try runMachine(arguments: [ + "run", "-n", name, "--root", + "-e", "VAR1=one", + "-e", "VAR2=two", + "-e", "VAR3=three", + "echo", "$VAR1-$VAR2-$VAR3", + ]) + #expect(status == 0, "run with multiple -e flags should succeed") + #expect(output.trimmingCharacters(in: .whitespacesAndNewlines) == "one-two-three") + } + + @Test func testRunWithUserFlag() throws { + let name = getTestName() + try doMachineCreate(name: name) + defer { cleanupMachine(name) } + + _ = try doMachineBoot(name: name) + try waitForMachineStatus(name, status: "running") + + // Test --user with uid:gid format + let (_, output, _, status) = try runMachine(arguments: [ + "run", "-n", name, + "--user", "1000:1000", + "echo", "$(id -u):$(id -g)", + ]) + #expect(status == 0, "run with --user uid:gid should succeed") + #expect(output.trimmingCharacters(in: .whitespacesAndNewlines) == "1000:1000") + } + + /// Verifies that killing a container machine's backing container updates the container machine state to stopped. + @Test func testContainerExitState() throws { + let name = getTestName() + try doMachineCreate(name: name) + defer { cleanupMachine(name) } + + _ = try doMachineBoot(name: name) + try waitForMachineStatus(name, status: "running") + + let snapshot = try doMachineInspect(name: name) + guard let containerId = snapshot.containerId else { + throw CLIError.executionFailed("running container machine has no containerId") + } + + try doStop(name: containerId) + try waitForMachineStatus(name, status: "stopped") + + let after = try doMachineInspect(name: name) + #expect(after.status == "stopped", "container machine should be stopped after container is killed") + #expect(after.containerId == nil, "stopped container machine should have no containerId") + #expect(after.startedDate == nil, "stopped container machine should have no startedDate") + } + + // MARK: - SSH forwarding tests + + @Test func testSSHForwarding() throws { + let name = getTestName() + + let socketDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: socketDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: socketDir) } + + let socketPath = socketDir.appendingPathComponent("ssh-auth.sock").path + + let serverFd = socket(AF_UNIX, SOCK_STREAM, 0) + precondition(serverFd >= 0, "socket() failed") + defer { Darwin.close(serverFd) } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + withUnsafeMutableBytes(of: &addr.sun_path) { bytes in + socketPath.withCString { cStr in + bytes.copyMemory(from: UnsafeRawBufferPointer(start: cStr, count: socketPath.utf8.count + 1)) + } + } + let bindResult = withUnsafePointer(to: addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + bind(serverFd, sockaddrPtr, socklen_t(MemoryLayout.size)) + } + } + precondition(bindResult == 0, "bind() failed: \(errno)") + precondition(listen(serverFd, 5) == 0, "listen() failed") + + let acceptThread = Thread { + while true { + let clientFd = accept(serverFd, nil, nil) + if clientFd < 0 { break } + Darwin.close(clientFd) + } + } + acceptThread.start() + + sleep(1) + + try doMachineCreate(name: name) + defer { cleanupMachine(name) } + + // Boot the container machine with SSH_AUTH_SOCK set in the process environment. + let (_, _, bootError, bootStatus) = try runMachine( + arguments: ["run", "--root", "-n", name, "true"], + env: ["SSH_AUTH_SOCK": socketPath] + ) + if bootStatus != 0 { + throw CLIError.executionFailed("container machine boot with SSH_AUTH_SOCK failed: \(bootError)") + } + try waitForMachineStatus(name, status: "running") + + let sshSockValue = try doMachineRun(name: name, root: true, command: ["echo", "$SSH_AUTH_SOCK"]) + #expect( + sshSockValue.trimmingCharacters(in: .whitespacesAndNewlines) == "/var/host-services/ssh-auth.sock", + "expected SSH_AUTH_SOCK to point to guest socket path" + ) + + let socketCheck = try doMachineRun( + name: name, + root: true, + command: ["[ -S /var/host-services/ssh-auth.sock ]", "&&", "echo", "exists", "||", "echo", "missing"] + ) + #expect( + socketCheck.trimmingCharacters(in: .whitespacesAndNewlines) == "exists", + "expected forwarded SSH socket to exist in container machine" + ) + } + + // MARK: - Set tests + + @Test func testSetCpus() throws { + let name = getTestName() + try doMachineCreate(name: name) + defer { cleanupMachine(name) } + + let (_, _, error, status) = try runMachine(arguments: ["set", "--name", name, "cpus=4"]) + #expect(status == 0, "set cpus should succeed: \(error)") + + let snapshot = try doMachineInspect(name: name) + #expect(snapshot.cpus == 4, "should have cpus=4") + } + + @Test func testSetMemory() throws { + let name = getTestName() + try doMachineCreate(name: name) + defer { cleanupMachine(name) } + + let (_, _, error, status) = try runMachine(arguments: ["set", "--name", name, "memory=8G"]) + #expect(status == 0, "set memory should succeed: \(error)") + + let snapshot = try doMachineInspect(name: name) + #expect(snapshot.memory == UInt64(8 * 1024 * 1024 * 1024), "should have 8G memory") + } + + @Test func testSetMultiple() throws { + let name = getTestName() + try doMachineCreate(name: name) + defer { cleanupMachine(name) } + + let (_, _, error, status) = try runMachine(arguments: ["set", "--name", name, "cpus=2", "memory=4G"]) + #expect(status == 0, "set multiple should succeed: \(error)") + + let snapshot = try doMachineInspect(name: name) + #expect(snapshot.cpus == 2, "should have cpus=2") + #expect(snapshot.memory == UInt64(4 * 1024 * 1024 * 1024), "should have 4G memory") + } + + @Test func testSetInvalidKey() throws { + let name = getTestName() + try doMachineCreate(name: name) + defer { cleanupMachine(name) } + + let (_, _, _, status) = try runMachine(arguments: ["set", "--name", name, "bogus=value"]) + #expect(status != 0, "set with unknown key should fail") + } + + @Test func testSetRunningWarning() throws { + let name = getTestName() + try doMachineCreate(name: name) + defer { cleanupMachine(name) } + + _ = try doMachineBoot(name: name) + try waitForMachineStatus(name, status: "running") + + let (_, _, error, status) = try runMachine(arguments: ["set", "--name", name, "cpus=2"]) + #expect(status == 0, "set on running VM should succeed") + #expect(error.contains("will take effect"), "should warn about restart needed") + } + + // MARK: - Create with config flags + + @Test func testCreateWithCpus() throws { + let name = getTestName() + cleanupMachine(name) + let (_, _, error, status) = try runMachine(arguments: ["create", "--no-boot", "--name", name, "--cpus", "2", machineImage]) + defer { cleanupMachine(name) } + #expect(status == 0, "create with --cpus should succeed: \(error)") + + let snapshot = try doMachineInspect(name: name) + #expect(snapshot.cpus == 2, "should have cpus=2") + } + + @Test func testGuestCpuCountMatchesRequested() throws { + let name = getTestName() + cleanupMachine(name) + let cpus = 2 + let (_, _, error, status) = try runMachine(arguments: ["create", "--no-boot", "--name", name, "--cpus", "\(cpus)", machineImage]) + defer { cleanupMachine(name) } + #expect(status == 0, "create with --cpus should succeed: \(error)") + + _ = try doMachineBoot(name: name) + try waitForMachineStatus(name, status: "running") + + let output = try doMachineRun(name: name, root: true, command: ["nproc"]) + let guestCpus = output.trimmingCharacters(in: .whitespacesAndNewlines) + #expect(guestCpus == "\(cpus)", "guest should see exactly \(cpus) CPUs, got \(guestCpus)") + } + + @Test func testCreateWithMemory() throws { + let name = getTestName() + cleanupMachine(name) + let (_, _, error, status) = try runMachine(arguments: ["create", "--no-boot", "--name", name, "--memory", "2G", machineImage]) + defer { cleanupMachine(name) } + #expect(status == 0, "create with --memory should succeed: \(error)") + + let snapshot = try doMachineInspect(name: name) + #expect(snapshot.memory == UInt64(2 * 1024 * 1024 * 1024), "should have 2G memory") + } + + @Test func testCreateWithHomeMount() throws { + let name = getTestName() + cleanupMachine(name) + let (_, _, error, status) = try runMachine(arguments: ["create", "--no-boot", "--name", name, "--home-mount", "none", machineImage]) + defer { cleanupMachine(name) } + #expect(status == 0, "create with --home-mount should succeed: \(error)") + + let snapshot = try doMachineInspect(name: name) + #expect(snapshot.homeMount == "none", "should have homeMount=none") + } + + /// Verifies that `container machine create` boots the machine by default. + /// The `--no-boot` path is covered by `testInspectStoppedMachine` via the + /// `doMachineCreate` helper, which passes `--no-boot`. + @Test func testCreateAutoBoots() throws { + let name = getTestName() + cleanupMachine(name) + let (_, _, error, status) = try runMachine(arguments: ["create", "--name", name, machineImage]) + defer { cleanupMachine(name) } + #expect(status == 0, "create without --no-boot should succeed: \(error)") + + let snapshot = try doMachineInspect(name: name) + #expect(snapshot.status == "running", "default create should leave machine running") + #expect(snapshot.startedDate != nil, "auto-booted machine should have startedDate") + } + + @Test func testAmd64PlatformSupported() throws { + let name = getTestName() + cleanupMachine(name) + let (_, _, error, status) = try runMachine(arguments: ["create", "--no-boot", "--name", name, "--platform", "linux/amd64", "alpine:3.22"]) + defer { cleanupMachine(name) } + #expect(status == 0, "create with --platform linux/amd64 should succeed: \(error)") + + _ = try doMachineBoot(name: name) + try waitForMachineStatus(name, status: "running") + + let output = try doMachineRun(name: name, root: true, command: ["uname", "-m"]) + #expect( + output.trimmingCharacters(in: .whitespacesAndNewlines) == "x86_64", + "amd64 machine should report x86_64 architecture" + ) + } + + @Test func testSetHomeMount() throws { + let name = getTestName() + try doMachineCreate(name: name) + defer { cleanupMachine(name) } + + let (_, _, error, status) = try runMachine(arguments: ["set", "--name", name, "home-mount=ro"]) + #expect(status == 0, "set home-mount should succeed: \(error)") + + let snapshot = try doMachineInspect(name: name) + #expect(snapshot.homeMount == "ro", "should have homeMount=ro") + } + + @Test func testSetHomeMountInvalid() throws { + let name = getTestName() + try doMachineCreate(name: name) + defer { cleanupMachine(name) } + + let (_, _, _, status) = try runMachine(arguments: ["set", "--name", name, "home-mount=badvalue"]) + #expect(status != 0, "set with invalid home-mount value should fail") + } + + // MARK: - Logs tests + + @Test func testLogs() throws { + let name = getTestName() + try doMachineCreate(name: name) + defer { cleanupMachine(name) } + + _ = try doMachineBoot(name: name) + try waitForMachineStatus(name, status: "running") + + _ = try doMachineStop(name: name) + + let (_, bootOutput, _, bootStatus) = try runMachine(arguments: ["logs", "--boot", name]) + #expect(bootStatus == 0, "logs --boot should succeed for stopped container machine") + #expect( + !bootOutput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + "boot log should have content after VM boot" + ) + + let (_, _, _, stdioStatus) = try runMachine(arguments: ["logs", name]) + #expect(stdioStatus == 0, "logs should succeed for stopped container machine") + } + + @Test func testLogsWhileRunning() throws { + let name = getTestName() + try doMachineCreate(name: name) + defer { cleanupMachine(name) } + + _ = try doMachineBoot(name: name) + try waitForMachineStatus(name, status: "running") + + let (_, bootOutput, _, bootStatus) = try runMachine(arguments: ["logs", "--boot", name]) + #expect(bootStatus == 0, "logs --boot should succeed while container machine is running") + #expect( + !bootOutput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + "boot log should have content while running" + ) + + let (_, _, _, stdioStatus) = try runMachine(arguments: ["logs", name]) + #expect(stdioStatus == 0, "logs should succeed while container machine is running") + } + + @Test func testLogsNonExistentMachine() throws { + let name = "nonexistent-machine-\(UUID().uuidString.lowercased())" + + let (_, _, error, status) = try runMachine(arguments: ["logs", name]) + #expect(status != 0, "logs should fail for non-existent machine") + #expect(error.contains("not found"), "error should mention 'not found'") + } + + @Test func testMachineNotInContainerList() throws { + let name = getTestName() + try doMachineCreate(name: name) + defer { cleanupMachine(name) } + + _ = try doMachineBoot(name: name) + try waitForMachineStatus(name, status: "running") + + let inspect = try doMachineInspect(name: name) + let containerId = try #require(inspect.containerId, "machine should have an underlying container ID") + + let (_, runningOutput, _, runningStatus) = try run(arguments: ["ls", "-q"]) + #expect(runningStatus == 0, "container ls failed") + #expect(!runningOutput.contains(containerId), "machine container should not appear in 'container ls'") + + let (_, allOutput, _, allStatus) = try run(arguments: ["ls", "-a", "-q"]) + #expect(allStatus == 0, "container ls -a failed") + #expect(!allOutput.contains(containerId), "machine container should not appear in 'container ls -a'") + } + + @Test func testMachineNotDeletedByRmAll() throws { + let name = getTestName() + try doMachineCreate(name: name) + defer { cleanupMachine(name) } + + _ = try doMachineBoot(name: name) + try waitForMachineStatus(name, status: "running") + + let inspect = try doMachineInspect(name: name) + let containerId = try #require(inspect.containerId, "running machine should have an underlying container ID") + + let (_, _, deleteError, deleteStatus) = try run(arguments: ["delete", "--all"]) + #expect(deleteStatus == 0, "container delete --all failed: \(deleteError)") + + let containerInspect = try inspectContainer(containerId) + #expect(containerInspect.status.state == "running", "machine container should still be running after 'container delete --all'") + } + + @Test func testMachineNotKilledByKillAll() throws { + let name = getTestName() + try doMachineCreate(name: name) + defer { cleanupMachine(name) } + + _ = try doMachineBoot(name: name) + try waitForMachineStatus(name, status: "running") + + let inspect = try doMachineInspect(name: name) + let containerId = try #require(inspect.containerId, "running machine should have an underlying container ID") + + let (_, _, killError, killStatus) = try run(arguments: ["kill", "--all"]) + #expect(killStatus == 0, "container kill --all failed: \(killError)") + + let containerInspect = try inspectContainer(containerId) + #expect(containerInspect.status.state == "running", "machine container should still be running after 'container kill --all'") + } + + @Test func testMachineNotStoppedByStopAll() throws { + let name = getTestName() + try doMachineCreate(name: name) + defer { cleanupMachine(name) } + + _ = try doMachineBoot(name: name) + try waitForMachineStatus(name, status: "running") + + let inspect = try doMachineInspect(name: name) + let containerId = try #require(inspect.containerId, "running machine should have an underlying container ID") + + let (_, _, stopError, stopStatus) = try run(arguments: ["stop", "--all"]) + #expect(stopStatus == 0, "container stop --all failed: \(stopError)") + + let containerInspect = try inspectContainer(containerId) + #expect(containerInspect.status.state == "running", "machine container should still be running after 'container stop --all'") + } +} + +struct MachineListItem: Codable { + let id: String + let status: String + let `default`: Bool + let ipAddress: String? + let cpus: Int + let memory: UInt64 + let diskSize: UInt64? + let createdDate: Date? +} + +struct MachineInspectOutput: Codable { + let id: String + let image: ImageDescription + let platform: Platform + let status: String + let startedDate: Date? + let createdDate: Date? + let containerId: String? + let cpus: Int + let memory: UInt64 + let homeMount: String? + let diskSize: UInt64? + let ipAddress: String? + + struct ImageDescription: Codable { + let reference: String + } + + struct Platform: Codable { + let os: String + let architecture: String + } +} diff --git a/Tests/CLITests/TestCLINoParallelCases.swift b/Tests/CLITests/TestCLINoParallelCases.swift index 02d6c757..f38092f6 100644 --- a/Tests/CLITests/TestCLINoParallelCases.swift +++ b/Tests/CLITests/TestCLINoParallelCases.swift @@ -71,6 +71,8 @@ class TestCLINoParallelCases: CLITest { @Test func testImagePruneUnusedImages() throws { // 1. Pull the images + _ = try? run(arguments: ["rm", "--all", "--force"]) + defer { _ = try? run(arguments: ["rm", "--all", "--force"]) } _ = try? run(arguments: ["image", "rm", "--all"]) defer { _ = try? run(arguments: ["image", "rm", "--all"]) } try doPull(imageName: alpine) diff --git a/Tests/CLITests/Utilities/CLITest.swift b/Tests/CLITests/Utilities/CLITest.swift index 4cef36b3..d4b79f74 100644 --- a/Tests/CLITests/Utilities/CLITest.swift +++ b/Tests/CLITests/Utilities/CLITest.swift @@ -118,7 +118,7 @@ class CLITest { } } - func run(arguments: [String], stdin: Data? = nil, currentDirectory: URL? = nil, env: [String: String] = [:]) throws -> ( + func run(arguments: [String], stdin: Data? = nil, currentDirectory: URL? = nil, tty: Bool = false, env: [String: String] = [:]) throws -> ( outputData: Data, output: String, error: String, status: Int32 ) { let seq = CLITest.commandSeq.withLock { counter in @@ -147,8 +147,15 @@ class CLITest { process.environment = processEnv } - let inputPipe = Pipe() - process.standardInput = inputPipe + var inputPipe: Pipe? + if tty { + let terminal = try Terminal.create() + process.standardInput = terminal.child.handle + } else { + let pipe = Pipe() + process.standardInput = pipe + inputPipe = pipe + } let outputData: Data let errorData: Data @@ -175,10 +182,10 @@ class CLITest { process.standardError = stderrHandle try process.run() - if let data = stdin { - inputPipe.fileHandleForWriting.write(data) + if let data = stdin, let pipe = inputPipe { + pipe.fileHandleForWriting.write(data) } - inputPipe.fileHandleForWriting.closeFile() + inputPipe?.fileHandleForWriting.closeFile() process.waitUntilExit() outputData = try Data(contentsOf: stdoutURL) diff --git a/Tests/ContainerAPIClientTests/MemorySizeTests.swift b/Tests/ContainerAPIClientTests/MemorySizeTests.swift index 1ae4e13a..1d518db7 100644 --- a/Tests/ContainerAPIClientTests/MemorySizeTests.swift +++ b/Tests/ContainerAPIClientTests/MemorySizeTests.swift @@ -72,4 +72,22 @@ struct MemorySizeTests { _ = try MemorySize("notasize") } } + + @Test( + arguments: [ + ("1gb", UInt64(1 * 1024 * 1024 * 1024)), + ("2048mb", UInt64(2048 * 1024 * 1024)), + ("512kb", UInt64(512 * 1024)), + ("1024b", UInt64(1024)), + ("4tb", UInt64(4) * 1024 * 1024 * 1024 * 1024), + ] as [(String, UInt64)]) + func testToUInt64Bytes(input: String, expected: UInt64) throws { + let size = try MemorySize(input) + #expect(size.toUInt64(unit: .bytes) == expected) + } + + @Test func testToUInt64SameUnit() throws { + let size = try MemorySize("2048mb") + #expect(size.toUInt64(unit: .mebibytes) == 2048) + } } diff --git a/docs/command-reference.md b/docs/command-reference.md index 6f588a71..1492c7f2 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -1058,6 +1058,250 @@ container registry list [--format ] [--quiet] [--debug] * `--format `: Format of the output (values: json, table, yaml, toml; default: table) * `-q, --quiet`: Only output the registry hostname +## Container Machine Management + +`m` is an alias for `container machine`. + +### `container machine create` + +Creates a container machine from an image and boots it. Use `--cpus`, `--memory`, and `--home-mount` to configure it, or `--no-boot` to create it without booting. + +**Usage** + +```bash +container machine create [] +``` + +**Arguments** + +* ``: Container image reference (e.g., alpine:3.22) + +**Options** + +* `-n, --name `: Name for the container machine +* `--set-default`: Set this container machine as the default +* `--no-boot`: Create the container machine without booting it +* `--cpus `: Number of virtual CPUs +* `--memory `: Memory allocation (e.g., 2G, 8G). Default: half of system memory +* `--home-mount `: User's home directory mount option (ro, rw, none). Default: rw + +**Management Options** + +* `-a, --arch `: Set arch if image can target multiple architectures (default: host architecture) +* `--os `: Set OS if image can target multiple operating systems (default: linux) +* `--platform `: Platform for the image if it's multi-platform. This takes precedence over --os and --arch + +**Registry Options** + +* `--scheme `: Scheme to use when connecting to the container registry. One of (http, https, auto) (default: auto) + +**Progress Options** + +* `--progress `: Progress type (format: auto|none|ansi|plain|color) (default: auto) + +**Image Fetch Options** + +* `--max-concurrent-downloads `: Maximum number of concurrent downloads (default: 3) + +**Examples** + +```bash +# create and boot a container machine named my-machine +container machine create alpine:3.22 --name my-machine + +# create a container machine with custom resources and set it as the default +container machine create --cpus 4 --memory 8G --set-default alpine:3.22 + +# create a container machine without booting it +container machine create --no-boot alpine:3.22 +``` + +### `container machine run` + +Runs a command in a container machine, booting it first if needed. With no command, it opens an interactive login shell. By default the command runs as a user matching the host user. + +**Usage** + +```bash +container machine run [] [] [ ...] +``` + +**Arguments** + +* ``: Command to run (default: login shell) +* ``: Command arguments + +**Options** + +* `-n, --name `: Container machine ID (uses default if not specified) +* `-d, --detach`: Run a process in a container machine and detach from it +* `--root`: Run as root instead of matching host user + +**Process Options** + +* `-e, --env `: Set environment variables (format: key=value, or just key to inherit from host) +* `--env-file `: Read in a file of environment variables (key=value format, ignores # comments and blank lines) +* `--gid `: Set the group ID for the process +* `-i, --interactive`: Keep the standard input open even if not attached +* `-t, --tty`: Open a TTY with the process +* `-u, --user `: Set the user for the process (format: name|uid[:gid]) +* `--uid `: Set the user ID for the process +* `-w, --workdir, --cwd `: Set the initial working directory inside the container + +**Examples** + +```bash +# open an interactive shell in the default container machine +container machine run + +# run a command in a named container machine +container machine run -n my-machine uname -a + +# pass arguments to the command after -- +container machine run -n my-machine -- cat /proc/cpuinfo +``` + +### `container machine list (ls)` + +Lists container machines. The default container machine is marked in the `DEFAULT` column. + +**Usage** + +```bash +container machine list [--format ] [--quiet] [--debug] +``` + +**Options** + +* `--format `: Format of the output (values: json, table; default: table) +* `-q, --quiet`: Only output the container machine ID + +### `container machine inspect` + +Displays detailed information about a container machine in JSON. Uses the default container machine if no ID is given. + +**Usage** + +```bash +container machine inspect [--debug] [] +``` + +**Arguments** + +* ``: Container machine ID (uses default if not specified) + +**Options** + +No options. + +### `container machine set` + +Sets configuration values on a container machine. Changes take effect after the container machine is stopped and restarted. Uses the default container machine if no ID is given. + +**Usage** + +```bash +container machine set [--name ] [--debug] ... +``` + +**Arguments** + +* ``: Configuration values (format: key=value) + +**Settings** + +* `cpus=`: Number of virtual CPUs +* `memory=`: Memory allocation (e.g., 2G, 1G). Default: half of system memory +* `home-mount=`: User home directory mount option (ro, rw, none). Default: rw + +**Options** + +* `-n, --name `: Container machine ID (uses default if not specified) + +**Examples** + +```bash +# set CPUs and memory on the default container machine +container machine set cpus=4 memory=8G + +# update the home mount on a named container machine +container machine set -n my-machine home-mount=ro +``` + +### `container machine set-default` + +Sets the default container machine. Commands that take an optional container machine ID use the default when you don't provide one. + +**Usage** + +```bash +container machine set-default [--debug] +``` + +**Arguments** + +* ``: Container machine ID + +**Options** + +No options. + +### `container machine logs` + +Fetches logs from a container machine. You can follow output, limit the number of lines, or view the boot log. Uses the default container machine if no ID is given. + +**Usage** + +```bash +container machine logs [--boot] [--follow] [-n ] [--debug] [] +``` + +**Arguments** + +* ``: Container machine ID (uses default if not specified) + +**Options** + +* `--boot`: Display the boot log for the container machine instead of stdio +* `-f, --follow`: Follow log output +* `-n `: Number of lines to show from the end of the logs. If not provided this will print all of the logs + +### `container machine stop` + +Stops a running container machine. Uses the default container machine if no ID is given. + +**Usage** + +```bash +container machine stop [--debug] [] +``` + +**Arguments** + +* ``: Container machine ID (uses default if not specified) + +**Options** + +No options. + +### `container machine delete (rm)` + +Deletes a container machine, stopping it first if it is running. If it was the default, set a new one with `container machine set-default`. + +**Usage** + +```bash +container machine delete [--debug] +``` + +**Arguments** + +* ``: Container machine ID + +**Options** + +No options. + ## System Management System commands manage the container apiserver, logs, DNS settings and kernel. These are only available on macOS hosts. diff --git a/docs/how-to.md b/docs/how-to.md index eaabcf3b..7c54f293 100644 --- a/docs/how-to.md +++ b/docs/how-to.md @@ -633,6 +633,122 @@ Check the VM boot logs to confirm your custom init code executed: [ 0.129230] custom-init: === CUSTOM INIT IMAGE RUNNING === ``` +## Use container machines + +A container machine provides a lightweight, persistent, and integrated Linux environment that feels like an extension of your Mac. A container machine is created from standard OCI images with a familiar UX. + +### Create a container machine + +Create a container machine from any Linux image that includes `/sbin/init`: + +```bash +container machine create alpine:3.22 --name my-machine +``` + +On first boot, `container` provisions a user that matches your host account, grants it passwordless `sudo`, and selects a login shell. Later boots skip this step. Your host home directory is mounted read-write by default; pass `--home-mount ro` or `--home-mount none` to change that. + +### Run commands in a container machine + +Pass a command to run it in the container machine, or omit it to open an interactive shell: + +```bash +container machine run -n my-machine uname +container machine run -n my-machine +``` + +`run` boots the container machine first if it is stopped. + +### Set a default container machine + +Set a default container machine so you can leave off `-n`/`--name`: + +```bash +container machine set-default my-machine +container machine run +``` + +### Manage container machines + +List your container machines: + +```bash +container machine ls +``` + +Show a container machine's configuration and status: + +```bash +container machine inspect my-machine +``` + +Stop a running container machine: + +```bash +container machine stop my-machine +``` + +Delete a container machine, including its persistent storage. `container` stops it first if it is running: + +```bash +container machine rm my-machine +``` + +Change a container machine's CPUs and memory. The new values apply after you restart the container machine: + +```bash +container machine set -n my-machine cpus=4 memory=8G +container machine stop my-machine +container machine run -n my-machine -- nproc +``` + +### Build your own container machine image + +Any Linux image that includes `/sbin/init` works as a container machine. For example, this Dockerfile builds an Ubuntu 24.04 container machine image with `systemd`, SSH, and common command-line tools: + +```dockerfile +FROM ubuntu:24.04 + +ENV container container + +RUN apt-get update && \ + apt-get install -y \ + dbus systemd openssh-server net-tools iproute2 iputils-ping curl wget vim-tiny man sudo && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* && \ + yes | unminimize + +RUN >/etc/machine-id +RUN >/var/lib/dbus/machine-id + +RUN systemctl set-default multi-user.target +RUN systemctl mask \ + dev-hugepages.mount \ + sys-fs-fuse-connections.mount \ + systemd-update-utmp.service \ + systemd-tmpfiles-setup.service \ + console-getty.service \ + systemd-binfmt.service +RUN systemctl disable \ + networkd-dispatcher.service + +RUN sed -i -e 's/^AcceptEnv LANG LC_\*$/#AcceptEnv LANG LC_*/' /etc/ssh/sshd_config +``` + +Build the image and create a container machine from it: + +```bash +container build -t local/ubuntu-machine:latest . +container machine create local/ubuntu-machine:latest --name ubuntu +``` + +By default, `container` runs a built-in setup script on first boot to provision the user described above. To use your own setup instead, add an executable script at `/etc/machine/create-user.sh` to the image. It runs once, as root, on first boot, with these variables set: + +- `CONTAINER_GID` +- `CONTAINER_HOME` +- `CONTAINER_MACHINE_ID` +- `CONTAINER_UID` +- `CONTAINER_USER` + ## Configure system properties The `container system property` subcommand manages the configuration settings for the `container` CLI and services. You can customize various aspects of container behavior, including build settings, default images, and network configuration.