diff --git a/Makefile b/Makefile index 1855256d2..094eb91cd 100644 --- a/Makefile +++ b/Makefile @@ -135,6 +135,8 @@ $(STAGING_DIR): @mkdir -p "$(join $(STAGING_DIR), libexec/container/plugins/machine-apiserver/resources)" @mkdir -p "$(join $(STAGING_DIR), libexec/container/plugins/k8s/bin)" @mkdir -p "$(join $(STAGING_DIR), libexec/container/plugins/k8s/resources)" + @mkdir -p "$(join $(STAGING_DIR), libexec/container/plugins/compose/bin)" + @mkdir -p "$(join $(STAGING_DIR), libexec/container/plugins/compose/resources)" @install "$(BUILD_BIN_DIR)/container" "$(join $(STAGING_DIR), bin/container)" @install "$(BUILD_BIN_DIR)/container-apiserver" "$(join $(STAGING_DIR), bin/container-apiserver)" @@ -151,6 +153,11 @@ $(STAGING_DIR): @install "$(BUILD_BIN_DIR)/k8s" "$(join $(STAGING_DIR), libexec/container/plugins/k8s/bin/k8s)" @install Sources/Plugins/K8s/config.toml "$(join $(STAGING_DIR), libexec/container/plugins/k8s/config.toml)" @install Sources/Plugins/K8s/Resources/kindnet.yaml "$(join $(STAGING_DIR), libexec/container/plugins/k8s/resources/kindnet.yaml)" + @install "$(BUILD_BIN_DIR)/compose" "$(join $(STAGING_DIR), libexec/container/plugins/compose/bin/compose)" + @install Sources/Plugins/Compose/config.toml "$(join $(STAGING_DIR), libexec/container/plugins/compose/config.toml)" + @install -m 0644 Sources/ContainerCompose/Resources/Containerfile "$(join $(STAGING_DIR), libexec/container/plugins/compose/resources/Containerfile)" + @install -m 0755 Sources/ContainerCompose/Resources/container-compose-idle-shutdown "$(join $(STAGING_DIR), libexec/container/plugins/compose/resources/container-compose-idle-shutdown)" + @install -m 0644 Sources/ContainerCompose/Resources/container-compose-idle-shutdown.service "$(join $(STAGING_DIR), libexec/container/plugins/compose/resources/container-compose-idle-shutdown.service)" @echo Install update script @install scripts/update-container.sh "$(join $(STAGING_DIR), bin/update-container.sh)" @@ -167,6 +174,7 @@ installer-pkg: $(STAGING_DIR) @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)" @codesign $(CODESIGN_OPTS) --prefix=com.apple.container. "$(join $(STAGING_DIR), libexec/container/plugins/k8s/bin/k8s)" + @codesign $(CODESIGN_OPTS) --prefix=com.apple.container. "$(join $(STAGING_DIR), libexec/container/plugins/compose/bin/compose)" @echo Creating application installer @pkgbuild --root "$(STAGING_DIR)" --identifier com.apple.container-installer --install-location /usr/local --version ${RELEASE_VERSION} $(PKG_PATH) @@ -182,6 +190,7 @@ dsym: @cp -a "$(BUILD_BIN_DIR)/container-core-images.dSYM" "$(DSYM_DIR)" @cp -a "$(BUILD_BIN_DIR)/container-apiserver.dSYM" "$(DSYM_DIR)" @cp -a "$(BUILD_BIN_DIR)/container.dSYM" "$(DSYM_DIR)" + @cp -a "$(BUILD_BIN_DIR)/compose.dSYM" "$(DSYM_DIR)" @echo Packaging the debug symbols... @(cd "$(dir $(DSYM_DIR))" ; zip -r $(notdir $(DSYM_PATH)) $(notdir $(DSYM_DIR))) @@ -212,7 +221,8 @@ COV_BINARIES := \ $(BUILD_BIN_DIR)/container-runtime-linux \ $(BUILD_BIN_DIR)/container-network-vmnet \ $(BUILD_BIN_DIR)/container-core-images \ - $(BUILD_BIN_DIR)/machine-apiserver + $(BUILD_BIN_DIR)/machine-apiserver \ + $(BUILD_BIN_DIR)/compose COV_OBJECT_FLAGS := $(patsubst %,-object %,$(COV_BINARIES)) # Set of files we do not want to get caught in the coverage generation LLVM_COV_IGNORE := \ diff --git a/Package.swift b/Package.swift index 2491f939a..6477135c1 100644 --- a/Package.swift +++ b/Package.swift @@ -53,6 +53,7 @@ let package = Package( .library(name: "MachineAPIClient", targets: ["MachineAPIClient"]), .library(name: "MachineAPIService", targets: ["MachineAPIService"]), .library(name: "ContainerK8s", targets: ["ContainerK8s"]), + .library(name: "ContainerCompose", targets: ["ContainerCompose"]), ], dependencies: [ .package(url: "https://github.com/apple/containerization.git", exact: Version(stringLiteral: scVersion)), @@ -174,6 +175,21 @@ let package = Package( ], path: "Tests/K8sPluginTests" ), + .testTarget( + name: "ComposePluginTests", + dependencies: [ + .product(name: "Containerization", package: "containerization"), + .product(name: "ContainerizationOCI", package: "containerization"), + .product(name: "ContainerizationOS", package: "containerization"), + .product(name: "Logging", package: "swift-log"), + .product(name: "SystemPackage", package: "swift-system"), + "ContainerAPIClient", + "ContainerCompose", + "ContainerResource", + "MachineAPIClient", + ], + path: "Tests/ComposePluginTests" + ), .target( name: "ContainerK8s", dependencies: [ @@ -193,12 +209,37 @@ let package = Package( "Yams", ] ), + .target( + name: "ContainerCompose", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "Containerization", package: "containerization"), + .product(name: "ContainerizationExtras", package: "containerization"), + .product(name: "Logging", package: "swift-log"), + .product(name: "SystemPackage", package: "swift-system"), + "ContainerAPIClient", + "ContainerPersistence", + "ContainerResource", + "ContainerVersion", + "MachineAPIClient", + "TerminalProgress", + ], + resources: [ + .copy("Resources") + ] + ), .executableTarget( name: "k8s", dependencies: ["ContainerK8s"], path: "Sources/Plugins/K8s", exclude: ["config.toml", "Resources"] ), + .executableTarget( + name: "compose", + dependencies: ["ContainerCompose"], + path: "Sources/Plugins/Compose", + exclude: ["config.toml"] + ), .executableTarget( name: "container-apiserver", dependencies: [ @@ -281,10 +322,18 @@ let package = Package( ], path: "Sources/Services/ContainerAPIService/Client" ), + .testTarget( + name: "ContainerXPCTests", + dependencies: [ + .product(name: "Containerization", package: "containerization"), + "ContainerXPC", + ] + ), .testTarget( name: "ContainerAPIClientTests", dependencies: [ .product(name: "Containerization", package: "containerization"), + .product(name: "ContainerizationOS", package: "containerization"), .product(name: "SystemPackage", package: "swift-system"), "ContainerAPIClient", "ContainerPersistence", @@ -656,6 +705,14 @@ let package = Package( ], path: "Sources/Services/MachineAPIService/Server" ), + .testTarget( + name: "MachineAPIServiceTests", + dependencies: [ + .product(name: "Logging", package: "swift-log"), + .product(name: "SystemPackage", package: "swift-system"), + "MachineAPIService", + ] + ), .executableTarget( name: "machine-apiserver", dependencies: [ diff --git a/Sources/APIServer/ContainerDNSHandler.swift b/Sources/APIServer/ContainerDNSHandler.swift index 78a207467..95af315f5 100644 --- a/Sources/APIServer/ContainerDNSHandler.swift +++ b/Sources/APIServer/ContainerDNSHandler.swift @@ -14,12 +14,16 @@ // limitations under the License. //===----------------------------------------------------------------------===// +import ContainerAPIClient import ContainerAPIService +import ContainerResource import ContainerizationExtras import DNSServer /// Handler that uses table lookup to resolve hostnames. struct ContainerDNSHandler: DNSHandler { + private static let composeMachineHostname = "compose.machine" + private let networkService: NetworksService private let ttl: UInt32 @@ -76,7 +80,7 @@ struct ContainerDNSHandler: DNSHandler { } private func answerHost(question: Question) async throws -> ResourceRecord? { - guard let ipAllocation = try await networkService.lookup(hostname: question.name) else { + guard let ipAllocation = try await lookup(hostname: question.name) else { return nil } let ipv4 = ipAllocation.ipv4Address.address.description @@ -88,7 +92,7 @@ struct ContainerDNSHandler: DNSHandler { } private func answerHost6(question: Question) async throws -> (record: ResourceRecord?, hostnameExists: Bool) { - guard let ipAllocation = try await networkService.lookup(hostname: question.name) else { + guard let ipAllocation = try await lookup(hostname: question.name) else { return (nil, false) } guard let ipv6Address = ipAllocation.ipv6Address else { @@ -101,4 +105,21 @@ struct ContainerDNSHandler: DNSHandler { return (HostRecord(name: question.name, ttl: ttl, ip: ip), true) } + + private func lookup(hostname: String) async throws -> Attachment? { + if let attachment = try await networkService.lookup(hostname: hostname) { + return attachment + } + + guard + let baseHostname = HostDNSResolver.wildcardBaseHostname( + for: hostname, + baseHostname: Self.composeMachineHostname + ) + else { + return nil + } + + return try await networkService.lookup(hostname: baseHostname) + } } diff --git a/Sources/ContainerCommands/Machine/MachineCommand.swift b/Sources/ContainerCommands/Machine/MachineCommand.swift index fe09bceed..8bcf2617e 100644 --- a/Sources/ContainerCommands/Machine/MachineCommand.swift +++ b/Sources/ContainerCommands/Machine/MachineCommand.swift @@ -34,6 +34,7 @@ extension Application { 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 start my-machine $ container machine run -n my-machine -- nproc Stop and delete the container machine: @@ -49,6 +50,7 @@ extension Application { MachineRun.self, MachineSet.self, MachineSetDefault.self, + MachineStart.self, MachineStop.self, ], aliases: ["m"] diff --git a/Sources/ContainerCommands/Machine/MachineHelpers.swift b/Sources/ContainerCommands/Machine/MachineHelpers.swift index 3241a3a86..c896e9e72 100644 --- a/Sources/ContainerCommands/Machine/MachineHelpers.swift +++ b/Sources/ContainerCommands/Machine/MachineHelpers.swift @@ -14,10 +14,7 @@ // limitations under the License. //===----------------------------------------------------------------------===// -import ContainerAPIClient -import ContainerResource import ContainerizationError -import Foundation import Logging import MachineAPIClient @@ -36,13 +33,7 @@ func resolveMachineId(_ id: String?, client: MachineClient) async throws -> Stri } /// 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. +/// to set up the host user. The lifecycle implementation is shared with plugins. @discardableResult func bootMachine( id: String?, @@ -50,57 +41,5 @@ func bootMachine( 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 + try await client.bootAndInitialize(id: id, log: log, interactive: interactive) } diff --git a/Sources/ContainerCommands/Machine/MachineStart.swift b/Sources/ContainerCommands/Machine/MachineStart.swift new file mode 100644 index 000000000..38aa2a03b --- /dev/null +++ b/Sources/ContainerCommands/Machine/MachineStart.swift @@ -0,0 +1,43 @@ +//===----------------------------------------------------------------------===// +// 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 MachineStart: AsyncLoggableCommand { + public init() {} + + public static let configuration = CommandConfiguration( + commandName: "start", + abstract: "Start a stopped 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) + _ = try await bootMachine(id: machineId, client: client, log: log, interactive: false) + print(machineId) + } + } +} diff --git a/Sources/ContainerCompose/ComposeCommand.swift b/Sources/ContainerCompose/ComposeCommand.swift new file mode 100644 index 000000000..3f65f7100 --- /dev/null +++ b/Sources/ContainerCompose/ComposeCommand.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 ArgumentParser +import ContainerPersistence +import ContainerVersion +import ContainerizationError +import Foundation +import Logging +import SystemPackage + +public struct ComposeCommand: AsyncParsableCommand { + public static let configuration = CommandConfiguration( + commandName: "compose", + abstract: "Run Docker Compose inside a persistent container machine", + usage: "container compose [--completions | --socket-path | ...]", + discussion: """ + Compose arguments are forwarded to the Docker Compose CLI inside the + shared machine. The machine remains running after `compose down`. + + EXAMPLES: + container compose up -d + container compose -f compose.yaml config + container compose exec api sh + container compose version + export DOCKER_HOST="$(container compose --socket-path)" + """, + version: ReleaseVersion.singleLine(appName: "compose") + ) + + @Option( + name: .long, + help: ArgumentHelp( + "Print a completion script for bash, zsh, or fish; cannot be combined with Compose arguments or --socket-path", + valueName: "bash|zsh|fish" + ), + completion: .list(ComposeCompletionShell.allCases.map(\.rawValue)) + ) + var completions: ComposeCompletionShell? = nil + + @Flag( + name: .long, + help: "Print the host Docker socket endpoint; cannot be combined with --completions or Compose arguments" + ) + var socketPath = false + + @Argument( + parsing: .captureForPassthrough, + help: ArgumentHelp("Docker Compose subcommand and arguments", valueName: "subcommand") + ) + var arguments: [String] = [] + + public init() {} + + public func run() async throws { + if let completions { + guard !socketPath, arguments.isEmpty else { + throw ValidationError("--completions cannot be combined with Compose arguments or --socket-path") + } + print(ComposeCompletionProvider.script(for: completions), terminator: "") + return + } + + if socketPath { + guard arguments.isEmpty else { + throw ValidationError("--socket-path cannot be combined with Compose arguments") + } + print(ComposeSocketEndpoint().dockerHost) + return + } + + if let reserved = ComposeInvocation.reservedOption(in: arguments) { + throw ValidationError( + "reserved plugin option '\(reserved.rawValue)' cannot be forwarded to Docker Compose" + ) + } + + LoggingSystem.bootstrap { label in StreamLogHandler.standardError(label: label) } + let log = Logger(label: "container.compose") + let processRunner = ComposeProcessRunner() + let manager = ComposeMachineManager(processRunner: processRunner) + let snapshot = try await manager.ensureReady(log: log) + let homeDirectory = FilePath(FileManager.default.homeDirectoryForCurrentUser.path) + let currentDirectory = FilePath(FileManager.default.currentDirectoryPath) + let workingDirectory = try ComposeEnvironment.workingDirectory( + currentDirectory: currentDirectory, + homeDirectory: homeDirectory + ) + let environment = try ComposeEnvironment.make( + hostEnvironment: ProcessInfo.processInfo.environment, + homeDirectory: homeDirectory, + workingDirectory: currentDirectory + ) + if ComposeInvocation.requestsHelp(in: arguments) { + let result = try await processRunner.capture( + snapshot: snapshot, + executable: "/usr/bin/docker", + arguments: ["compose"] + arguments, + environment: environment, + workingDirectory: workingDirectory + ) + print(ComposeHelpOutput.rewrite(result.output), terminator: "") + throw ArgumentParser.ExitCode(result.exitCode) + } + + let exitCode = try await processRunner.run( + snapshot: snapshot, + arguments: arguments, + environment: environment, + workingDirectory: workingDirectory, + log: log + ) + throw ArgumentParser.ExitCode(exitCode) + } +} diff --git a/Sources/ContainerCompose/ComposeCompletionProvider.swift b/Sources/ContainerCompose/ComposeCompletionProvider.swift new file mode 100644 index 000000000..9f91d2a8d --- /dev/null +++ b/Sources/ContainerCompose/ComposeCompletionProvider.swift @@ -0,0 +1,103 @@ +//===----------------------------------------------------------------------===// +// 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 + +enum ComposeCompletionShell: String, CaseIterable, ExpressibleByArgument, Sendable { + case bash + case zsh + case fish +} + +/// Provides a small, host-independent completion surface. The command and +/// option lists are deliberately static; they must be regenerated when the +/// pinned Docker Compose CLI changes. +enum ComposeCompletionProvider { + static func script(for shell: ComposeCompletionShell) -> String { + switch shell { + case .bash: + return bash + case .zsh: + return zsh + case .fish: + return fish + } + } + + static let commands = "config convert cp create down events images kill logs ls pause port ps pull push restart rm run start stop top unpause up version watch" + static let composeOptions = + "--all-resources --ansi --compatibility --dry-run --env-file --file --parallel --profile --progress --project-directory --project-name --quiet-pull --verbose" + static let pluginOptions = "--socket-path --completions" + + private static let bash = """ + # container compose completion (generated for the pinned Docker Compose CLI) + if [[ "${_container_compose_installed:-0}" != 1 ]]; then + _container_compose_previous_spec="$(complete -p container 2>/dev/null)" || true + _container_compose_previous_completion="" + if [[ "$_container_compose_previous_spec" =~ -F[[:space:]]+([^[:space:]]+) ]]; then + _container_compose_previous_completion="${BASH_REMATCH[1]}" + fi + fi + _container_compose_complete() { + local cur="${COMP_WORDS[COMP_CWORD]}" + if [[ "${COMP_WORDS[1]}" != "compose" ]]; then + if [[ -n "$_container_compose_previous_completion" ]]; then + "$_container_compose_previous_completion" "$@" + fi + return + fi + if (( COMP_CWORD <= 2 )); then + COMPREPLY=( $(compgen -W "\(pluginOptions) \(composeOptions) \(commands)" -- "$cur") ) + else + COMPREPLY=( $(compgen -W "\(composeOptions) \(commands)" -- "$cur") ) + fi + } + if [[ -n "$_container_compose_previous_completion" ]]; then + eval "${_container_compose_previous_spec/-F $_container_compose_previous_completion/-F _container_compose_complete}" + else + complete -o bashdefault -o default -F _container_compose_complete container + fi + _container_compose_installed=1 + """ + + private static let zsh = """ + # container compose completion (generated for the pinned Docker Compose CLI) + if (( ! ${+_container_compose_previous_completion} )); then + typeset -g _container_compose_previous_completion="${_comps[container]-}" + fi + _container_compose() { + if [[ "$words[2]" != compose ]]; then + if [[ -n "$_container_compose_previous_completion" ]]; then + "$_container_compose_previous_completion" "$@" + return + fi + return 1 + fi + if (( CURRENT == 3 )); then + compadd -- \(pluginOptions) \(composeOptions) \(commands) + else + compadd -- \(composeOptions) \(commands) + fi + } + compdef _container_compose container + """ + + private static let fish = """ + # container compose completion (generated for the pinned Docker Compose CLI) + complete -c container -n 'test (count (commandline -opc)) -eq 2; and test (commandline -opc)[2] = compose' -a '\(pluginOptions) \(composeOptions) \(commands)' + complete -c container -n 'test (count (commandline -opc)) -gt 2; and test (commandline -opc)[2] = compose' -a '\(composeOptions) \(commands)' + """ +} diff --git a/Sources/ContainerCompose/ComposeConfiguration.swift b/Sources/ContainerCompose/ComposeConfiguration.swift new file mode 100644 index 000000000..1050ec90a --- /dev/null +++ b/Sources/ContainerCompose/ComposeConfiguration.swift @@ -0,0 +1,70 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationOCI +import Foundation + +/// Configuration scoped to the Compose plugin. +struct ComposeConfiguration: LoadablePluginConfiguration { + static let pluginId = "compose" + static let defaultImage = "container-compose-machine:local" + + static func isValidImage(_ image: String) -> Bool { + guard !image.isEmpty else { return false } + return (try? Reference.parse(image)) != nil + } + + var cpus: Int + var memory: MemorySize + var idleShutdownSeconds: Int + + init() { + self.cpus = 4 + self.memory = try! MemorySize("4gb") + self.idleShutdownSeconds = 0 + } + + init( + cpus: Int = 4, + memory: MemorySize = try! MemorySize("4gb"), + idleShutdownSeconds: Int = 0 + ) { + self.cpus = cpus + self.memory = memory + self.idleShutdownSeconds = idleShutdownSeconds + } + + private enum CodingKeys: String, CodingKey { + case cpus + case memory + case idleShutdownSeconds = "idle-shutdown-seconds" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.cpus = try container.decodeIfPresent(Int.self, forKey: .cpus) ?? 4 + self.memory = try container.decodeIfPresent(MemorySize.self, forKey: .memory) ?? (try MemorySize("4gb")) + self.idleShutdownSeconds = try container.decodeIfPresent(Int.self, forKey: .idleShutdownSeconds) ?? 0 + if idleShutdownSeconds < 0 { + throw DecodingError.dataCorruptedError( + forKey: .idleShutdownSeconds, + in: container, + debugDescription: "idle-shutdown-seconds must not be negative" + ) + } + } +} diff --git a/Sources/ContainerCompose/ComposeEnvironment.swift b/Sources/ContainerCompose/ComposeEnvironment.swift new file mode 100644 index 000000000..fbaac43d9 --- /dev/null +++ b/Sources/ContainerCompose/ComposeEnvironment.swift @@ -0,0 +1,89 @@ +//===----------------------------------------------------------------------===// +// 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 + +/// Host-to-machine path and environment policy for the Compose process. +enum ComposeEnvironment { + static let innerDockerHost = "unix:///etc/docker/docker.sock" + private static let linuxPath = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + + static func workingDirectory( + currentDirectory: FilePath, + homeDirectory: FilePath + ) throws -> String { + let home = homeDirectory.lexicallyNormalized() + let current = currentDirectory.lexicallyNormalized() + guard current == home || current.starts(with: home) else { + throw ContainerizationError( + .invalidArgument, + message: "Compose working directory \(current) is outside the mounted host home \(home)" + ) + } + return current.string + } + + static func make( + hostEnvironment: [String: String], + homeDirectory: FilePath, + workingDirectory: FilePath + ) throws -> [String: String] { + let cwd = try self.workingDirectory( + currentDirectory: workingDirectory, + homeDirectory: homeDirectory + ) + + var environment = hostEnvironment + for key in [ + "DOCKER_HOST", + "DOCKER_CONTEXT", + "DOCKER_TLS_VERIFY", + "DOCKER_CERT_PATH", + "DOCKER_CONFIG", + "SSH_AUTH_SOCK", + "GIT_SSH_COMMAND", + "HOME", + "PATH", + "PWD", + "OLDPWD", + "TMPDIR", + "TMP", + "TEMP", + ] { + environment.removeValue(forKey: key) + } + + environment["DOCKER_HOST"] = Self.innerDockerHost + // Do not reuse a host Docker config that names macOS-only credential + // helpers. Users can authenticate explicitly inside the machine, or + // supply DOCKER_AUTH_CONFIG for non-interactive registry access. + environment["DOCKER_CONFIG"] = "/root/.docker" + environment["HOME"] = homeDirectory.lexicallyNormalized().string + // The machine container deliberately runs Compose as root so the + // nested daemon can manage its own containers. Do not pass the host + // SSH agent into that rootful trust domain. + environment.removeValue(forKey: "GIT_SSH_COMMAND") + environment["PATH"] = Self.linuxPath + environment["PWD"] = cwd + environment["TMPDIR"] = "/tmp" + environment["TMP"] = "/tmp" + environment["TEMP"] = "/tmp" + + return environment + } +} diff --git a/Sources/ContainerCompose/ComposeHelpOutput.swift b/Sources/ContainerCompose/ComposeHelpOutput.swift new file mode 100644 index 000000000..35c1ae24f --- /dev/null +++ b/Sources/ContainerCompose/ComposeHelpOutput.swift @@ -0,0 +1,22 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +/// Rewrites Docker Compose help so users see the host command they invoked. +enum ComposeHelpOutput { + static func rewrite(_ output: String) -> String { + output.replacingOccurrences(of: "docker compose", with: "container compose") + } +} diff --git a/Sources/ContainerCompose/ComposeInvocation.swift b/Sources/ContainerCompose/ComposeInvocation.swift new file mode 100644 index 000000000..f256fd10f --- /dev/null +++ b/Sources/ContainerCompose/ComposeInvocation.swift @@ -0,0 +1,58 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +/// Pure classification of options reserved by the Compose plugin itself. +enum ComposeInvocation { + enum ReservedOption: String, Sendable { + case completions = "--completions" + case socketPath = "--socket-path" + } + + /// Returns whether Compose help was requested for the forwarded command. + /// Arguments after `--` belong to Compose and are not inspected. + static func requestsHelp(in arguments: [String]) -> Bool { + for argument in arguments { + guard argument != "--" else { + break + } + if argument == "--help" || argument == "-h" { + return true + } + } + return false + } + + /// Finds a plugin option that would otherwise be hidden in the captured + /// Docker Compose argument list. Arguments after `--` belong to Compose. + static func reservedOption(in arguments: [String]) -> ReservedOption? { + for argument in arguments { + guard argument != "--" else { + break + } + + let name = argument.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) + .first + guard let name else { + continue + } + + if let option = ReservedOption(rawValue: String(name)) { + return option + } + } + return nil + } +} diff --git a/Sources/ContainerCompose/ComposeMachineImageBuilder.swift b/Sources/ContainerCompose/ComposeMachineImageBuilder.swift new file mode 100644 index 000000000..d8cc53140 --- /dev/null +++ b/Sources/ContainerCompose/ComposeMachineImageBuilder.swift @@ -0,0 +1,166 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerVersion +import ContainerizationError +import Darwin +import Dispatch +import Foundation +import Logging +import SystemPackage + +/// Builds the bundled Compose machine image through the host `container` CLI. +struct ComposeMachineImageBuilder: Sendable { + static let defaultImage = ComposeConfiguration.defaultImage + + private let commandRunner: CommandRunner + private let resources: ComposeMachineImageResources + private let executable: URL + + init( + resources: ComposeMachineImageResources, + executable: URL, + commandRunner: CommandRunner = CommandRunner() + ) { + self.commandRunner = commandRunner + self.resources = resources + self.executable = executable + } + + func build(image: String = defaultImage, log: Logger) async throws { + log.info( + "Building the default Compose machine image", + metadata: [ + "image": "\(image)", + "containerfile": "\(resources.containerfile.path)", + ] + ) + try await commandRunner.run( + executable: executable, + arguments: [ + "build", + "--progress", "plain", + "--file", resources.containerfile.path, + "--tag", image, + resources.directory.path, + ], + log: log + ) + } + + struct CommandRunner: Sendable { + private let implementation: @Sendable (URL, [String]) async throws -> Void + + init() { + self.implementation = Self.runProcess + } + + init(implementation: @escaping @Sendable (URL, [String]) async throws -> Void) { + self.implementation = implementation + } + + func run(executable: URL, arguments: [String], log: Logger) async throws { + try await implementation(executable, arguments) + } + + private static func runProcess(executable: URL, arguments: [String]) async throws { + let process = Process() + process.executableURL = executable + process.arguments = arguments + process.standardOutput = FileHandle.standardError + process.standardError = FileHandle.standardError + + do { + try process.run() + } catch { + throw ContainerizationError( + .internalError, + message: "failed to launch host container build", + cause: error + ) + } + + await withTaskCancellationHandler { + await wait(for: process) + } onCancel: { + terminate(process) + } + try Task.checkCancellation() + + guard process.terminationStatus == 0 else { + throw ContainerizationError( + .internalError, + message: "host container build failed with status \(process.terminationStatus)" + ) + } + } + + private static func wait(for process: Process) async { + await withCheckedContinuation { continuation in + DispatchQueue.global().async { + process.waitUntilExit() + continuation.resume() + } + } + } + + private static func terminate(_ process: Process) { + let processID = process.processIdentifier + process.terminate() + DispatchQueue.global().asyncAfter(deadline: .now() + 2) { + if process.isRunning { + _ = Darwin.kill(processID, SIGKILL) + } + } + } + } + + static func make( + health: SystemHealth, + executablePath: FilePath = CommandLine.executablePath, + mainResourceURL: URL? = Bundle.main.resourceURL, + moduleResourceURL: URL? = nil, + fileManager: FileManager = .default + ) throws -> ComposeMachineImageBuilder { + let resources = try ComposeMachineImageResources.locate( + executablePath: executablePath, + mainResourceURL: mainResourceURL, + moduleResourceURL: moduleResourceURL, + fileManager: fileManager + ) + let installedExecutable = health.installRoot + .appendingPathComponent("bin") + .appendingPathComponent("container") + let developmentExecutable = URL(fileURLWithPath: executablePath.string) + .deletingLastPathComponent() + .appendingPathComponent("container") + let executable = [installedExecutable, developmentExecutable] + .first { fileManager.isExecutableFile(atPath: $0.path) } + guard let executable else { + throw ContainerizationError( + .notFound, + message: "host container executable is unavailable; searched: \(installedExecutable.path), \(developmentExecutable.path)" + ) + } + + return ComposeMachineImageBuilder( + resources: resources, + executable: executable + ) + } +} diff --git a/Sources/ContainerCompose/ComposeMachineImageResources.swift b/Sources/ContainerCompose/ComposeMachineImageResources.swift new file mode 100644 index 000000000..7a9a1a24e --- /dev/null +++ b/Sources/ContainerCompose/ComposeMachineImageResources.swift @@ -0,0 +1,89 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerVersion +import ContainerizationError +import Foundation +import SystemPackage + +/// Locates the resources required to build the bundled Compose machine image. +struct ComposeMachineImageResources: Sendable, Equatable { + let directory: URL + let containerfile: URL + + init(directory: URL, containerfile: URL) { + self.directory = directory + self.containerfile = containerfile + } + + static func locate( + executablePath: FilePath = CommandLine.executablePath, + mainResourceURL: URL? = Bundle.main.resourceURL, + moduleResourceURL: URL? = nil, + fileManager: FileManager = .default + ) throws -> ComposeMachineImageResources { + let executable = URL(fileURLWithPath: executablePath.string) + let executableDirectory = executable.deletingLastPathComponent() + let pluginRoot = executableDirectory.deletingLastPathComponent() + let appBundleRoot = pluginRoot.deletingLastPathComponent() + var candidates = [ + pluginRoot.appendingPathComponent("resources"), + appBundleRoot.appendingPathComponent("Contents/Resources/resources"), + mainResourceURL?.appendingPathComponent("plugins/compose/resources"), + mainResourceURL?.appendingPathComponent("plugins/compose.app/Contents/Resources/resources"), + mainResourceURL?.appendingPathComponent("compose/resources"), + mainResourceURL?.appendingPathComponent("resources"), + ].compactMap { $0 } + if let moduleResourceURL { + candidates.append(moduleResourceURL) + } + + var bundleSearchRoot: URL? = executableDirectory + for _ in 0..<6 { + guard let currentRoot = bundleSearchRoot else { break } + candidates.append( + currentRoot.appendingPathComponent("container_ContainerCompose.bundle/Resources") + ) + let parent = currentRoot.deletingLastPathComponent() + guard parent != currentRoot else { break } + bundleSearchRoot = parent + } + + let requiredResources = [ + "Containerfile", + "container-compose-idle-shutdown", + "container-compose-idle-shutdown.service", + ] + for directory in candidates { + let missing = requiredResources.filter { + !fileManager.isReadableFile(atPath: directory.appendingPathComponent($0).path) + } + guard missing.isEmpty else { continue } + return ComposeMachineImageResources( + directory: directory, + containerfile: directory.appendingPathComponent("Containerfile") + ) + } + + throw ContainerizationError( + .notFound, + message: "bundled Compose machine resources are unavailable; searched: " + + candidates.map(\.path).joined(separator: ", ") + + "; required: " + + requiredResources.joined(separator: ", ") + ) + } +} diff --git a/Sources/ContainerCompose/ComposeMachineInitializationLock.swift b/Sources/ContainerCompose/ComposeMachineInitializationLock.swift new file mode 100644 index 000000000..564f81871 --- /dev/null +++ b/Sources/ContainerCompose/ComposeMachineInitializationLock.swift @@ -0,0 +1,79 @@ +//===----------------------------------------------------------------------===// +// 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 Darwin +import Foundation + +/// Serializes first-use Compose machine initialization across plugin processes. +final class ComposeMachineInitializationLock: @unchecked Sendable { + private let descriptor: Int32 + private let stateLock = NSLock() + private var isReleased = false + + private init(descriptor: Int32) { + self.descriptor = descriptor + } + + deinit { + _ = close(descriptor) + } + + static func acquire( + appRoot: URL, + retryInterval: Duration = .milliseconds(100) + ) async throws -> ComposeMachineInitializationLock { + let directory = appRoot.appendingPathComponent("locks", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let path = directory.appendingPathComponent("compose-machine-init.lock") + let descriptor = open(path.path, O_CREAT | O_RDWR | O_CLOEXEC, 0o600) + guard descriptor >= 0 else { + throw ContainerizationError( + .internalError, + message: "failed to open Compose machine initialization lock: \(String(cString: strerror(errno)))" + ) + } + + let lock = ComposeMachineInitializationLock(descriptor: descriptor) + do { + while flock(descriptor, LOCK_EX | LOCK_NB) != 0 { + guard errno == EWOULDBLOCK || errno == EAGAIN else { + throw ContainerizationError( + .internalError, + message: "failed to acquire Compose machine initialization lock: \(String(cString: strerror(errno)))" + ) + } + do { + try await Task.sleep(for: retryInterval) + } catch is CancellationError { + throw CancellationError() + } + } + } catch { + lock.release() + throw error + } + return lock + } + + func release() { + stateLock.lock() + defer { stateLock.unlock() } + guard !isReleased else { return } + isReleased = true + _ = flock(descriptor, LOCK_UN) + } +} diff --git a/Sources/ContainerCompose/ComposeMachineManager.swift b/Sources/ContainerCompose/ComposeMachineManager.swift new file mode 100644 index 000000000..acfd5440f --- /dev/null +++ b/Sources/ContainerCompose/ComposeMachineManager.swift @@ -0,0 +1,353 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationError +import Foundation +import Logging +import MachineAPIClient +import SystemPackage +import TerminalProgress + +/// Creates, validates, boots, and waits for the shared Compose machine. +struct ComposeMachineManager: Sendable { + static let machineID = ComposeSocketEndpoint.machineID + static let owner = "compose" + + private let machineClient: MachineClient + private let processRunner: ComposeProcessRunner + private let socketEndpoint: ComposeSocketEndpoint + + init( + machineClient: MachineClient = MachineClient(), + processRunner: ComposeProcessRunner = ComposeProcessRunner(), + socketEndpoint: ComposeSocketEndpoint = ComposeSocketEndpoint() + ) { + self.machineClient = machineClient + self.processRunner = processRunner + self.socketEndpoint = socketEndpoint + } + + static func configurationFiles(for health: SystemHealth) -> [FilePath] { + [ + ConfigurationLoader.configurationFile( + in: FilePath(health.appRoot.path), + of: .appRoot + ), + ConfigurationLoader.configurationFile( + in: FilePath(health.installRoot.path), + of: .installRoot + ), + ] + } + + func ensureReady(log: Logger) async throws -> MachineSnapshot { + let health = try await ClientHealthCheck.ping() + let configurationFiles = Self.configurationFiles(for: health) + let systemConfiguration = try await ConfigurationLoader.load( + configurationFiles: configurationFiles + ) + let pluginConfiguration: ComposeConfiguration = try await ConfigurationLoader.loadForPlugin( + configurationFiles: configurationFiles + ) + let customImage = ProcessInfo.processInfo.environment["CONTAINER_COMPOSE_MACHINE_IMAGE"] + let image = customImage ?? ComposeConfiguration.defaultImage + guard ComposeConfiguration.isValidImage(image) else { + throw ContainerizationError( + .invalidArgument, + message: "Compose machine image reference is invalid" + ) + } + let homeDirectory = FilePath(FileManager.default.homeDirectoryForCurrentUser.path) + let currentDirectory = FilePath(FileManager.default.currentDirectoryPath) + let workingDirectory = try ComposeEnvironment.workingDirectory( + currentDirectory: currentDirectory, + homeDirectory: homeDirectory + ) + let environment = try ComposeEnvironment.make( + hostEnvironment: ProcessInfo.processInfo.environment, + homeDirectory: homeDirectory, + workingDirectory: currentDirectory + ) + + let initializationLock = try await ComposeMachineInitializationLock.acquire(appRoot: health.appRoot) + defer { initializationLock.release() } + + try await ensureMachine( + image: image, + customImage: customImage != nil, + configuration: pluginConfiguration, + systemConfiguration: systemConfiguration, + health: health, + log: log + ) + + var snapshot = try await machineClient.bootAndInitialize( + id: Self.machineID, + dynamicEnv: [:], + forwardSSHAgent: false, + log: log, + interactive: false + ) + snapshot = try await waitForAddress(snapshot: snapshot) + try await waitForDocker( + snapshot: snapshot, + environment: environment, + workingDirectory: workingDirectory, + log: log + ) + try await configureIdleShutdown( + snapshot: snapshot, + seconds: pluginConfiguration.idleShutdownSeconds + ) + + if let ipAddress = snapshot.ipAddress { + var metadata: Logger.Metadata = ["ip": "\(ipAddress)"] + if HostDNSResolver().listDomains().contains(where: { $0.pqdn == MachineConfiguration.defaultDNSDomain }) { + metadata["hostname"] = "\(snapshot.configuration.dnsName)" + } else { + metadata["dnsSetup"] = "sudo container system dns create machine" + } + log.info("Compose machine is ready", metadata: metadata) + } + + return snapshot + } + + private func ensureMachine( + image: String, + customImage: Bool, + configuration: ComposeConfiguration, + systemConfiguration: ContainerSystemConfig, + health: SystemHealth, + log: Logger + ) async throws { + do { + try validate(try await machineClient.inspect(id: Self.machineID)) + return + } catch { + guard contains(error, code: .notFound) else { + throw error + } + } + + if !customImage { + let imageAvailable = try await isImageAvailable( + image: image, + systemConfiguration: systemConfiguration + ) + if !imageAvailable { + let builder = try ComposeMachineImageBuilder.make(health: health) + try await builder.build(image: image, log: log) + } + } + + var machineConfiguration: MachineConfiguration + let resources: MachineResources? + let noProgress: ProgressUpdateHandler = { _ in } + do { + let management = try Flags.MachineManagement.parse([]) + let registry = try Flags.Registry.parse([]) + let imageFetch = try Flags.ImageFetch.parse([]) + (machineConfiguration, resources) = try await MachineClient.machineConfigFromFlags( + id: Self.machineID, + image: image, + management: management, + registry: registry, + imageFetch: imageFetch, + containerSystemConfig: systemConfiguration, + progressUpdate: noProgress + ) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to prepare Compose machine image \(image)", + cause: error + ) + } + + machineConfiguration.managedBy = Self.owner + let bootConfiguration = try MachineConfig( + cpus: configuration.cpus, + memory: configuration.memory, + homeMount: .rw, + virtualization: false, + kernelPath: nil, + runtimeProfile: .nestedDocker, + dockerSocketPath: socketEndpoint.path + ) + + do { + try await machineClient.create( + configuration: machineConfiguration, + resources: resources, + bootConfig: bootConfiguration, + makeDefaultIfNone: false + ) + log.info("Created Compose machine", metadata: ["id": "\(Self.machineID)"]) + } catch { + guard contains(error, code: .exists) else { + throw error + } + // Another Compose invocation may have created the machine between + // inspect() and create(). The daemon serializes creation; validate the + // winner instead of adding a second machine or deleting anything. + let raced = try await machineClient.inspect(id: Self.machineID) + try validate(raced) + } + } + + private func isImageAvailable( + image: String, + systemConfiguration: ContainerSystemConfig + ) async throws -> Bool { + do { + _ = try await ClientImage.get( + reference: image, + containerSystemConfig: systemConfiguration + ) + return true + } catch let error as ContainerizationError where error.isCode(.notFound) { + return false + } + } + + private func validate(_ snapshot: MachineSnapshot) throws { + guard snapshot.configuration.managedBy == Self.owner else { + throw ContainerizationError( + .exists, + message: "machine '\(Self.machineID)' already exists and is not managed by container compose" + ) + } + + guard snapshot.bootConfig.homeMount == .rw, + snapshot.bootConfig.runtimeProfile == .nestedDocker, + snapshot.bootConfig.dockerSocketPath == socketEndpoint.path + else { + throw ContainerizationError( + .invalidState, + message: "Compose machine '\(Self.machineID)' has incompatible configuration; refusing automatic migration" + ) + } + } + + private func configureIdleShutdown( + snapshot: MachineSnapshot, + seconds: Int + ) async throws { + let desired = "COMPOSE_IDLE_SHUTDOWN_IDLE_SECONDS=\(seconds)" + let script = """ + set -eu + desired='\(desired)' + path=/etc/container/compose-idle-shutdown.env + current=$(/usr/bin/cat "$path" 2>/dev/null || true) + if [ "$current" != "$desired" ]; then + /usr/bin/printf '%s\\n' "$desired" > "$path" + /usr/bin/systemctl restart container-compose-idle-shutdown.service + fi + """ + let result = try await processRunner.capture( + snapshot: snapshot, + executable: "/bin/sh", + arguments: ["-c", script], + environment: [:], + workingDirectory: "/", + timeout: .seconds(10) + ) + guard result.exitCode == 0 else { + throw ContainerizationError( + .invalidState, + message: "failed to configure Compose idle shutdown: \(result.output.trimmingCharacters(in: .whitespacesAndNewlines))" + ) + } + } + + private func waitForAddress(snapshot: MachineSnapshot) async throws -> MachineSnapshot { + var lastSnapshot = snapshot + for _ in 0..<30 { + lastSnapshot = try await machineClient.inspect(id: Self.machineID) + if lastSnapshot.status == .running, lastSnapshot.ipAddress != nil { + return lastSnapshot + } + try await Task.sleep(for: .milliseconds(200)) + } + throw ContainerizationError( + .timeout, + message: "Compose machine did not receive a network address within 6 seconds" + ) + } + + private func waitForDocker( + snapshot: MachineSnapshot, + environment: [String: String], + workingDirectory: String, + log: Logger + ) async throws { + let attempts = 60 + var lastOutput = "" + for attempt in 0..&1; journalctl -u docker --no-pager -n 80 2>&1"], + environment: environment, + workingDirectory: workingDirectory, + timeout: .seconds(5) + ) + let metadata: Logger.Metadata = [ + "probe": "\(lastOutput)", + "diagnostics": "\(diagnostics?.output ?? "unavailable")", + ] + log.error( + "Docker daemon did not become ready", + metadata: metadata + ) + throw ContainerizationError( + .timeout, + message: "Docker daemon inside Compose machine did not become ready" + ) + } + + private func contains(_ error: Error, code: ContainerizationError.Code) -> Bool { + guard let error = error as? ContainerizationError else { + return false + } + return error.isCode(code) || (error.cause.map { contains($0, code: code) } ?? false) + } +} diff --git a/Sources/ContainerCompose/ComposeProcessRunner.swift b/Sources/ContainerCompose/ComposeProcessRunner.swift new file mode 100644 index 000000000..73eadb5f7 --- /dev/null +++ b/Sources/ContainerCompose/ComposeProcessRunner.swift @@ -0,0 +1,236 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationExtras +import Darwin +import Foundation +import Logging +import MachineAPIClient + +final class ComposeProcessCancellation: @unchecked Sendable { + private let process: any ClientProcess + private let lock = NSLock() + private var killStarted = false + + init(process: any ClientProcess) { + self.process = process + } + + func cancel() { + lock.withLock { + guard !killStarted else { return } + killStarted = true + Task { + try? await process.kill(SIGKILL) + } + } + } +} + +struct ComposeCapturedProcess: Sendable, Equatable { + let exitCode: Int32 + let output: String + + init(exitCode: Int32, output: String) { + self.exitCode = exitCode + self.output = output + } +} + +/// Runs Docker Compose as a process inside the persistent machine container. +struct ComposeProcessRunner: Sendable { + private let client: ContainerClient + + init(client: ContainerClient = ContainerClient()) { + self.client = client + } + + func run( + snapshot: MachineSnapshot, + arguments: [String], + environment: [String: String], + workingDirectory: String, + log: Logger + ) async throws -> Int32 { + guard let containerId = snapshot.containerId else { + throw ContainerizationError( + .invalidState, + message: "Compose machine is running but has no backing container ID" + ) + } + + let tty = isatty(STDIN_FILENO) == 1 && isatty(STDOUT_FILENO) == 1 + // Compose must receive piped stdin for commands such as `exec -T` and + // `-f -`, even when the host stdin is not a TTY. + let io = try ProcessIO.create(tty: tty, interactive: true, detach: false) + defer { + try? io.close() + } + + let process = try await client.createProcess( + containerId: containerId, + processId: UUID().uuidString.lowercased(), + configuration: ProcessConfiguration( + executable: "/usr/bin/docker", + arguments: ["compose"] + arguments, + environment: environment.map { "\($0.key)=\($0.value)" }, + workingDirectory: workingDirectory, + terminal: tty, + user: .id(uid: 0, gid: 0) + ), + stdio: io.stdio + ) + + return try await io.handleProcess(process: process, log: log) + } + + /// Runs a short diagnostic command and captures combined stdout/stderr. + /// This is used for daemon readiness checks and diagnostics, not forwarded + /// user commands. + func capture( + snapshot: MachineSnapshot, + executable: String, + arguments: [String], + environment: [String: String], + workingDirectory: String, + timeout: Duration = .seconds(5), + maxOutputBytes: Int = 1024 * 1024 + ) async throws -> ComposeCapturedProcess { + guard let containerId = snapshot.containerId else { + throw ContainerizationError( + .invalidState, + message: "Compose machine is running but has no backing container ID" + ) + } + + let stdout = Pipe() + let stderr = Pipe() + let process = try await client.createProcess( + containerId: containerId, + processId: UUID().uuidString.lowercased(), + configuration: ProcessConfiguration( + executable: executable, + arguments: arguments, + environment: environment.map { "\($0.key)=\($0.value)" }, + workingDirectory: workingDirectory, + terminal: false, + user: .id(uid: 0, gid: 0) + ), + stdio: [nil, stdout.fileHandleForWriting, stderr.fileHandleForWriting] + ) + + return try await Self.capture( + process: process, + stdout: stdout, + stderr: stderr, + timeout: timeout, + maxOutputBytes: maxOutputBytes + ) + } + + static func capture( + process: any ClientProcess, + stdout: Pipe, + stderr: Pipe, + timeout: Duration, + maxOutputBytes: Int + ) async throws -> ComposeCapturedProcess { + let cancellation = ComposeProcessCancellation(process: process) + defer { + try? stdout.fileHandleForReading.close() + try? stderr.fileHandleForReading.close() + } + + let stopCapture: @Sendable () -> Void = { + cancellation.cancel() + try? stdout.fileHandleForReading.close() + try? stderr.fileHandleForReading.close() + } + + do { + return try await Timeout.run(for: timeout) { + try await withTaskCancellationHandler { + do { + try Task.checkCancellation() + try await process.start() + try Task.checkCancellation() + async let output: Data = { + do { + return try Self.readAll( + from: stdout.fileHandleForReading, + maxBytes: maxOutputBytes + ) + } catch { + stopCapture() + throw error + } + }() + async let error: Data = { + do { + return try Self.readAll( + from: stderr.fileHandleForReading, + maxBytes: maxOutputBytes + ) + } catch { + stopCapture() + throw error + } + }() + async let exitCode = process.wait() + let (capturedOutput, capturedError, status) = try await (output, error, exitCode) + var text = String(decoding: capturedOutput, as: UTF8.self) + text.append(String(decoding: capturedError, as: UTF8.self)) + return ComposeCapturedProcess(exitCode: status, output: text) + } catch { + stopCapture() + throw error + } + } onCancel: { + stopCapture() + } + } + } catch is CancellationError { + guard !Task.isCancelled else { + throw CancellationError() + } + throw ContainerizationError( + .timeout, + message: "process did not exit within \(timeout)" + ) + } + } + + static func readAll(from handle: FileHandle, maxBytes: Int) throws -> Data { + guard maxBytes >= 0 else { + throw ContainerizationError(.invalidArgument, message: "captured process output limit must not be negative") + } + var data = Data() + data.reserveCapacity(min(maxBytes, 64 * 1024)) + while let chunk = try handle.read(upToCount: 64 * 1024), !chunk.isEmpty { + guard chunk.count <= maxBytes - data.count else { + throw ContainerizationError( + .internalError, + message: "captured process output exceeded \(maxBytes) bytes" + ) + } + data.append(chunk) + } + return data + } +} diff --git a/Sources/ContainerCompose/ComposeSocketEndpoint.swift b/Sources/ContainerCompose/ComposeSocketEndpoint.swift new file mode 100644 index 000000000..b05a537d2 --- /dev/null +++ b/Sources/ContainerCompose/ComposeSocketEndpoint.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 Foundation +import SystemPackage + +/// The host endpoint exposed by the Compose machine's Docker daemon. +struct ComposeSocketEndpoint: Sendable, Equatable { + static let machineID = "compose" + + let path: FilePath + + init(homeDirectory: FilePath) { + self.path = + homeDirectory + .appending(".local") + .appending("run") + .appending("docker.socket") + } + + init() { + self.init(homeDirectory: FilePath(FileManager.default.homeDirectoryForCurrentUser.path)) + } + + var dockerHost: String { + "unix://\(path.string)" + } +} diff --git a/Sources/ContainerCompose/Resources/Containerfile b/Sources/ContainerCompose/Resources/Containerfile new file mode 100644 index 000000000..8d6cfded7 --- /dev/null +++ b/Sources/ContainerCompose/Resources/Containerfile @@ -0,0 +1,63 @@ +FROM debian:bookworm-slim@sha256:abd67ffcfa541b485a3dff59865ab629aa048a6c613e639d36e7456b0b229241 + +ENV container=docker \ + DEBIAN_FRONTEND=noninteractive + +RUN set -eux; \ + apt-get update; \ + apt-get install --no-install-recommends -y \ + ca-certificates=20230311+deb12u1 \ + curl=7.88.1-10+deb12u15 \ + gnupg=2.2.40-1.1+deb12u2; \ + install -m 0755 -d /etc/apt/keyrings; \ + curl -fsSL https://download.docker.com/linux/debian/gpg \ + -o /etc/apt/keyrings/docker.asc; \ + test "$(sha256sum /etc/apt/keyrings/docker.asc | cut -d' ' -f1)" = \ + 1500c1f56fa9e26b9b8f42452a553675796ade0807cdce11975eb98170b3a570; \ + chmod a+r /etc/apt/keyrings/docker.asc; \ + . /etc/os-release; \ + printf '%s\n' \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian ${VERSION_CODENAME} stable" \ + > /etc/apt/sources.list.d/docker.list; \ + apt-get update; \ + apt-get install --no-install-recommends -y \ + dbus=1.14.10-1~deb12u1 \ + iptables=1.8.9-2 \ + systemd=252.39-1~deb12u2 \ + systemd-sysv=252.39-1~deb12u2 \ + containerd.io=2.3.3-1~debian.12~bookworm \ + docker-ce=5:29.7.2-1~debian.12~bookworm \ + docker-ce-cli=5:29.7.2-1~debian.12~bookworm \ + docker-buildx-plugin=0.36.1-1~debian.12~bookworm \ + docker-compose-plugin=5.4.0-1~debian.12~bookworm; \ + apt-get clean; \ + rm -rf /var/lib/apt/lists/* /var/cache/apt/* /tmp/* /var/tmp/* + +# The machine wrapper starts /sbin/init. Make Docker's systemd dependency +# explicit and mask units that require host-only devices or login sessions. +COPY container-compose-idle-shutdown /usr/local/bin/container-compose-idle-shutdown +COPY container-compose-idle-shutdown.service /etc/systemd/system/container-compose-idle-shutdown.service +RUN set -eux; \ + install -d -m 0755 /etc/container; \ + printf '%s\n' 'COMPOSE_IDLE_SHUTDOWN_IDLE_SECONDS=0' > /etc/container/compose-idle-shutdown.env; \ + chmod 0755 /usr/local/bin/container-compose-idle-shutdown; \ + systemctl enable docker.service container-compose-idle-shutdown.service; \ + for unit in \ + dev-hugepages.mount \ + sys-fs-fuse-connections.mount \ + systemd-logind.service \ + getty.target \ + console-getty.target; \ + do \ + systemctl mask "$unit"; \ + done; \ + mkdir -p /etc/docker /etc/systemd/system/docker.socket.d /etc/systemd/system/docker.service.d; \ + printf '%s\n' '{"features":{"buildkit":true}}' > /etc/docker/daemon.json; \ + printf '%s\n' '[Socket]' 'ListenStream=' 'ListenStream=/etc/docker/docker.sock' > /etc/systemd/system/docker.socket.d/compose.conf; \ + printf '%s\n' '[Service]' 'ExecStartPre=/bin/ln -sf /etc/docker/docker.sock /run/docker.sock' > /etc/systemd/system/docker.service.d/compose.conf; \ + truncate -s 0 /etc/machine-id; \ + rm -f /var/lib/dbus/machine-id + +# Do not declare VOLUME /var/lib/docker: the Apple machine root filesystem is +# persistent across machine stop/boot and owns Docker's data directly. +STOPSIGNAL SIGRTMIN+3 diff --git a/Sources/ContainerCompose/Resources/container-compose-idle-shutdown b/Sources/ContainerCompose/Resources/container-compose-idle-shutdown new file mode 100755 index 000000000..1c13f0efe --- /dev/null +++ b/Sources/ContainerCompose/Resources/container-compose-idle-shutdown @@ -0,0 +1,131 @@ +#!/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. +#===----------------------------------------------------------------------===// +# +# Automatically powers off the Compose machine after Docker has had no running +# containers for the configured idle period. Docker's persistent data remains +# on the machine filesystem and is available again on the next boot. + +set -u + +DOCKER_BIN=${COMPOSE_IDLE_SHUTDOWN_DOCKER_BIN:-/usr/bin/docker} +DATE_BIN=${COMPOSE_IDLE_SHUTDOWN_DATE_BIN:-/usr/bin/date} +UNIX_SOCKETS_FILE=${COMPOSE_IDLE_SHUTDOWN_UNIX_SOCKETS_FILE:-/proc/net/unix} +SYSTEMCTL_BIN=${COMPOSE_IDLE_SHUTDOWN_SYSTEMCTL_BIN:-/usr/bin/systemctl} +SLEEP_BIN=${COMPOSE_IDLE_SHUTDOWN_SLEEP_BIN:-/bin/sleep} +IDLE_SECONDS=${COMPOSE_IDLE_SHUTDOWN_IDLE_SECONDS:-0} +INTERVAL_SECONDS=${COMPOSE_IDLE_SHUTDOWN_INTERVAL_SECONDS:-60} +RUN_ONCE=${COMPOSE_IDLE_SHUTDOWN_RUN_ONCE:-0} + +case "$IDLE_SECONDS" in + ''|*[!0-9]*) + echo "invalid Compose idle shutdown timeout: $IDLE_SECONDS" >&2 + exit 1 + ;; +esac +case "$INTERVAL_SECONDS" in + ''|*[!0-9]*) + echo "invalid Compose idle shutdown check interval: $INTERVAL_SECONDS" >&2 + exit 1 + ;; +esac + +# A zero timeout is an explicit opt-out. This is useful for an image-specific +# systemd drop-in without requiring a replacement service unit. +if [ "$IDLE_SECONDS" -eq 0 ]; then + exit 0 +fi + +log() { + printf '%s\n' "container-compose-idle-shutdown: $*" >&2 +} + +# A Docker API client can be active even when no workload container is +# running (for example, compose build, pull, or push). Connected Unix sockets +# remain visible in /proc/net/unix while the request is in progress. Do not +# shut down while one is present. +has_active_docker_client() { + awk '$6 == "03" && $8 == "/etc/docker/docker.sock" { found = 1 } + END { exit(found ? 0 : 1) }' "$UNIX_SOCKETS_FILE" 2>/dev/null + case "$?" in + 0) return 0 ;; + 1) return 1 ;; + *) return 2 ;; + esac +} + +running_containers() { + "$DOCKER_BIN" ps --quiet 2>/dev/null +} + +empty_since= +while :; do + if ! running=$(running_containers); then + # A daemon error is not proof of idleness. Wait for Docker to recover + # before starting or continuing the idle timer. + empty_since= + log "Docker is unavailable; idle shutdown timer reset" + elif [ -n "$running" ]; then + empty_since= + else + active_client_status=0 + has_active_docker_client || active_client_status=$? + if [ "$active_client_status" -eq 0 ]; then + empty_since= + log "Docker client activity detected; idle shutdown timer reset" + [ "$RUN_ONCE" = "1" ] && exit 0 + "$SLEEP_BIN" "$INTERVAL_SECONDS" + continue + elif [ "$active_client_status" -ne 1 ]; then + empty_since= + log "failed to inspect Docker client activity; idle shutdown timer reset" + [ "$RUN_ONCE" = "1" ] && exit 0 + "$SLEEP_BIN" "$INTERVAL_SECONDS" + continue + fi + + now=$("$DATE_BIN" +%s 2>/dev/null) || { + empty_since= + log "failed to read the clock; idle shutdown timer reset" + now= + } + + if [ -n "$now" ]; then + if [ -z "$empty_since" ]; then + empty_since=$now + else + elapsed=$((now - empty_since)) + if [ "$elapsed" -ge "$IDLE_SECONDS" ]; then + # Close the timer race: a container or client may have + # appeared while the clock was being read. + if running=$(running_containers) && [ -z "$running" ]; then + active_client_status=0 + has_active_docker_client || active_client_status=$? + if [ "$active_client_status" -eq 1 ]; then + log "Docker has been idle for ${elapsed}s; powering off" + "$SYSTEMCTL_BIN" poweroff --no-wall + exit $? + fi + fi + empty_since= + fi + fi + fi + fi + + [ "$RUN_ONCE" = "1" ] && exit 0 + "$SLEEP_BIN" "$INTERVAL_SECONDS" +done diff --git a/Sources/ContainerCompose/Resources/container-compose-idle-shutdown.service b/Sources/ContainerCompose/Resources/container-compose-idle-shutdown.service new file mode 100644 index 000000000..e6c54e73c --- /dev/null +++ b/Sources/ContainerCompose/Resources/container-compose-idle-shutdown.service @@ -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. +#===----------------------------------------------------------------------===// +# +# Automatically stop the Compose machine after its nested Docker daemon has +# been idle. The machine filesystem remains persistent across the poweroff. + +[Unit] +Description=Power off the Compose machine after Docker is idle +Wants=docker.service +After=docker.service + +[Service] +Type=simple +EnvironmentFile=-/etc/container/compose-idle-shutdown.env +ExecStart=/usr/local/bin/container-compose-idle-shutdown +Restart=on-failure +RestartSec=10s + +[Install] +WantedBy=multi-user.target diff --git a/Sources/ContainerPersistence/MachineConfig.swift b/Sources/ContainerPersistence/MachineConfig.swift index 6c8161243..de2c2aa98 100644 --- a/Sources/ContainerPersistence/MachineConfig.swift +++ b/Sources/ContainerPersistence/MachineConfig.swift @@ -25,7 +25,14 @@ import SystemPackage /// "use the container runtime default." public struct MachineConfig: Codable, Sendable { public static let `default`: MachineConfig = try! .init( - cpus: nil, memory: nil, homeMount: nil, virtualization: nil, kernelPath: nil) + cpus: nil, + memory: nil, + homeMount: nil, + virtualization: nil, + kernelPath: nil, + runtimeProfile: .standard, + dockerSocketPath: nil + ) public static var defaultCPUs: Int { max(ProcessInfo.processInfo.processorCount / 2, 4) @@ -46,6 +53,12 @@ public struct MachineConfig: Codable, Sendable { case none } + /// Runtime settings required by a machine workload. + public enum RuntimeProfile: String, Sendable, Codable { + case standard + case nestedDocker + } + /// Number of virtual CPUs. public let cpus: Int /// Memory in bytes. @@ -56,6 +69,10 @@ public struct MachineConfig: Codable, Sendable { public let virtualization: Bool /// Optional path to a custom kernel binary. nil falls back to the system default. public let kernelPath: FilePath? + /// Runtime profile used to configure the outer container runtime. + public let runtimeProfile: RuntimeProfile + /// Optional host path for a socket published from the machine. + public let dockerSocketPath: FilePath? private enum CodingKeys: String, CodingKey { case cpus @@ -63,6 +80,8 @@ public struct MachineConfig: Codable, Sendable { case homeMount case virtualization case kernelPath + case runtimeProfile + case dockerSocketPath } /// Settable keys and their descriptions, for CLI help text generation. @@ -79,13 +98,17 @@ public struct MachineConfig: Codable, Sendable { memory: MemorySize?, homeMount: HomeMountOption?, virtualization: Bool?, - kernelPath: FilePath? + kernelPath: FilePath?, + runtimeProfile: RuntimeProfile = .standard, + dockerSocketPath: FilePath? = nil ) throws { self.cpus = cpus ?? Self.defaultCPUs self.memory = memory ?? Self.defaultMemory self.homeMount = homeMount ?? Self.defaultHomeMount self.virtualization = virtualization ?? false self.kernelPath = kernelPath + self.runtimeProfile = runtimeProfile + self.dockerSocketPath = dockerSocketPath try self.validate() } @@ -101,13 +124,17 @@ public struct MachineConfig: Codable, Sendable { // which the project's ConfigSnapshotDecoder can't handle. Persist as a plain String // and lift to FilePath in memory. let kernelPath = try container.decodeIfPresent(String.self, forKey: .kernelPath).map { FilePath($0) } + let runtimeProfile = try container.decodeIfPresent(RuntimeProfile.self, forKey: .runtimeProfile) ?? .standard + let dockerSocketPath = try container.decodeIfPresent(String.self, forKey: .dockerSocketPath).map { FilePath($0) } try self.init( cpus: cpus, memory: memory, homeMount: homeMount, virtualization: virtualization, - kernelPath: kernelPath) + kernelPath: kernelPath, + runtimeProfile: runtimeProfile, + dockerSocketPath: dockerSocketPath) } public func encode(to encoder: any Encoder) throws { @@ -117,6 +144,8 @@ public struct MachineConfig: Codable, Sendable { try container.encode(homeMount, forKey: .homeMount) try container.encode(virtualization, forKey: .virtualization) try container.encodeIfPresent(kernelPath?.string, forKey: .kernelPath) + try container.encode(runtimeProfile, forKey: .runtimeProfile) + try container.encodeIfPresent(dockerSocketPath?.string, forKey: .dockerSocketPath) } private func validate() throws { @@ -133,6 +162,15 @@ public struct MachineConfig: Codable, Sendable { message: "invalid memory value '\(self.memory)'. Must be greater than 1gb." ) } + + if let dockerSocketPath { + guard dockerSocketPath.isAbsolute else { + throw ContainerizationError( + .invalidArgument, + message: "Docker socket path must be absolute: \(dockerSocketPath)" + ) + } + } } } @@ -174,7 +212,9 @@ extension MachineConfig { memory: memory ?? self.memory, homeMount: homeMount ?? self.homeMount, virtualization: virtualization ?? self.virtualization, - kernelPath: kernelPath + kernelPath: kernelPath, + runtimeProfile: self.runtimeProfile, + dockerSocketPath: self.dockerSocketPath ) } diff --git a/Sources/ContainerResource/Common/ResourceLabels.swift b/Sources/ContainerResource/Common/ResourceLabels.swift index b02564a04..e56dcb4a2 100644 --- a/Sources/ContainerResource/Common/ResourceLabels.swift +++ b/Sources/ContainerResource/Common/ResourceLabels.swift @@ -103,6 +103,12 @@ public struct ResourceLabelKeys { /// Indicates a owner of a resource managed by a plugin. public static let plugin = "com.apple.container.plugin" + /// Identifies the persistent machine that owns a machine backing container. + public static let machineID = "com.apple.container.machine.id" + + /// Identifies the persisted owner of a machine backing container. + public static let machineToken = "com.apple.container.machine.token" + /// Indicates a resource with a reserved or dedicated purpose. public static let role = "com.apple.container.resource.role" } diff --git a/Sources/ContainerResource/Container/ContainerListFilters.swift b/Sources/ContainerResource/Container/ContainerListFilters.swift index eee3518a5..662bfc44e 100644 --- a/Sources/ContainerResource/Container/ContainerListFilters.swift +++ b/Sources/ContainerResource/Container/ContainerListFilters.swift @@ -18,8 +18,12 @@ import Foundation /// Filters for listing containers. public struct ContainerListFilters: Sendable, Codable { + public static func exact(_ value: String) -> String { + "^(?:\(NSRegularExpression.escapedPattern(for: value)))$" + } + public static func exclude(_ str: String) -> String { - "^(?!\(str)$)" + "^(?!\(NSRegularExpression.escapedPattern(for: str))$)" } /// Filter by container IDs. If non-empty, only containers with matching IDs are returned. @@ -52,4 +56,10 @@ extension ContainerListFilters { let labels = self.labels.merging([ResourceLabelKeys.plugin: Self.exclude("machine")]) { _, new in new } return ContainerListFilters(ids: self.ids, status: self.status, labels: labels) } + + /// Returns a filter that matches a system-owned machine container exactly. + /// The server still performs an exact label comparison before destructive operations. + public static func machines() -> ContainerListFilters { + ContainerListFilters(labels: [ResourceLabelKeys.plugin: Self.exact("machine")]) + } } diff --git a/Sources/ContainerXPC/XPCClient.swift b/Sources/ContainerXPC/XPCClient.swift index bab008509..45d1177d2 100644 --- a/Sources/ContainerXPC/XPCClient.swift +++ b/Sources/ContainerXPC/XPCClient.swift @@ -98,42 +98,61 @@ extension XPCClient { /// Send the provided message to the service. @discardableResult public func send(_ message: XPCMessage, responseTimeout: Duration? = nil) async throws -> XPCMessage { - try await withThrowingTaskGroup(of: XPCMessage.self, returning: XPCMessage.self) { group in - if let responseTimeout { - group.addTask { - try await Task.sleep(for: responseTimeout) - let route = message.string(key: XPCMessage.routeKey) ?? "nil" - throw ContainerizationError( - .internalError, - message: "XPC timeout for request to \(self.service)/\(route)" - ) + let route = message.string(key: XPCMessage.routeKey) ?? "nil" + return try await Self.waitForReply( + responseTimeout: responseTimeout, + service: service, + route: route + ) { completion in + xpc_connection_send_message_with_reply(self.connection, message.underlying, nil) { reply in + do { + completion(.success(try self.parseReply(reply))) + } catch { + completion(.failure(error)) } } + } + } - group.addTask { - try await withCheckedThrowingContinuation { cont in - xpc_connection_send_message_with_reply(self.connection, message.underlying, nil) { reply in - do { - let message = try self.parseReply(reply) - cont.resume(returning: message) - } catch { - cont.resume(throwing: error) - } - } - } + static func waitForReply( + responseTimeout: Duration? = nil, + service: String, + route: String, + send: (@escaping @Sendable (Result) -> Void) -> Void + ) async throws -> XPCMessage { + let (stream, continuation) = AsyncThrowingStream.makeStream() + send { result in + switch result { + case .success(let message): + continuation.yield(message) + continuation.finish() + case .failure(let error): + continuation.finish(throwing: error) } + } - let response = try await group.next() - // once one task has finished, cancel the rest. - group.cancelAll() - // we don't really care about the second error here - // as it's most likely a `CancellationError`. - try? await group.waitForAll() + let timeoutTask = responseTimeout.map { timeout in + Task { + try? await Task.sleep(for: timeout) + guard !Task.isCancelled else { return } + continuation.finish( + throwing: ContainerizationError( + .internalError, + message: "XPC timeout for request to \(service)/\(route)" + ) + ) + } + } + defer { timeoutTask?.cancel() } - guard let response else { - throw ContainerizationError(.invalidState, message: "failed to receive XPC response") + return try await withTaskCancellationHandler { + for try await message in stream { + return message } - return response + try Task.checkCancellation() + throw ContainerizationError(.invalidState, message: "failed to receive XPC response") + } onCancel: { + continuation.finish(throwing: CancellationError()) } } diff --git a/Sources/ContainerXPC/XPCMessage.swift b/Sources/ContainerXPC/XPCMessage.swift index 612963286..3e6ea400d 100644 --- a/Sources/ContainerXPC/XPCMessage.swift +++ b/Sources/ContainerXPC/XPCMessage.swift @@ -184,6 +184,13 @@ extension XPCMessage { } } + /// Returns whether a value is present for the supplied key. + public func contains(key: String) -> Bool { + lock.withLock { + xpc_dictionary_get_value(self.object, key) != nil + } + } + public func set(key: String, value: Bool) { lock.withLock { xpc_dictionary_set_bool(self.object, key, value) @@ -240,8 +247,17 @@ extension XPCMessage { } public func set(key: String, value: FileHandle) { - let fd = xpc_fd_create(value.fileDescriptor) - close(value.fileDescriptor) + try? setFileHandle(key: key, value: value) + } + + public func setFileHandle(key: String, value: FileHandle) throws { + guard let fd = xpc_fd_create(value.fileDescriptor) else { + throw ContainerizationError( + .internalError, + message: "failed to create xpc fd for \(value.fileDescriptor)" + ) + } + try value.close() lock.withLock { xpc_dictionary_set_value(self.object, key, fd) } @@ -275,7 +291,7 @@ extension XPCMessage { ) } xpc_array_append_value(fdArray, xpcFd) - close(fh.fileDescriptor) + try fh.close() } lock.withLock { xpc_dictionary_set_value(self.object, key, fdArray) diff --git a/Sources/Plugins/Compose/ComposeMain.swift b/Sources/Plugins/Compose/ComposeMain.swift new file mode 100644 index 000000000..e8ee5613a --- /dev/null +++ b/Sources/Plugins/Compose/ComposeMain.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. +//===----------------------------------------------------------------------===// + +import ContainerCompose +import ContainerVersion + +@main +struct ComposePlugin { + static func main() async { + let arguments = Array(CommandLine.arguments.dropFirst()) + if arguments == ["--help"] || arguments == ["-h"] { + print(ComposeCommand.helpMessage()) + return + } + if arguments == ["--version"] { + print(ReleaseVersion.singleLine(appName: "compose")) + return + } + await ComposeCommand.main() + } +} diff --git a/Sources/Plugins/Compose/config.toml b/Sources/Plugins/Compose/config.toml new file mode 100644 index 000000000..5c19f59e9 --- /dev/null +++ b/Sources/Plugins/Compose/config.toml @@ -0,0 +1,3 @@ +abstract = "Run Docker Compose inside a persistent container machine" +author = "Apple" +version = 0.1 diff --git a/Sources/Plugins/MachineAPIServer/MachineAPIServer+Start.swift b/Sources/Plugins/MachineAPIServer/MachineAPIServer+Start.swift index 6e3b89009..4d7b54578 100644 --- a/Sources/Plugins/MachineAPIServer/MachineAPIServer+Start.swift +++ b/Sources/Plugins/MachineAPIServer/MachineAPIServer+Start.swift @@ -61,6 +61,7 @@ extension MachineAPIServer { let resourceRoot = FilePath(resources) let service = try MachinesService(appRoot: pluginStateRoot, resourceRoot: resourceRoot, log: log) + try await service.reconcileOrphanedContainers() let harness = MachinesHarness(service: service) let server = XPCServer( diff --git a/Sources/Services/ContainerAPIService/Client/ContainerClient.swift b/Sources/Services/ContainerAPIService/Client/ContainerClient.swift index 5a2b6d0d3..c672626ee 100644 --- a/Sources/Services/ContainerAPIService/Client/ContainerClient.swift +++ b/Sources/Services/ContainerAPIService/Client/ContainerClient.swift @@ -139,7 +139,7 @@ public struct ContainerClient: Sendable { }() if let h { - request.set(key: key, value: h) + try request.setFileHandle(key: key, value: h) } } @@ -249,7 +249,7 @@ public struct ContainerClient: Sendable { }() if let h { - request.set(key: key, value: h) + try request.setFileHandle(key: key, value: h) } } diff --git a/Sources/Services/ContainerAPIService/Client/HostDNSResolver.swift b/Sources/Services/ContainerAPIService/Client/HostDNSResolver.swift index a72b04dc6..14e572fbe 100644 --- a/Sources/Services/ContainerAPIService/Client/HostDNSResolver.swift +++ b/Sources/Services/ContainerAPIService/Client/HostDNSResolver.swift @@ -104,6 +104,25 @@ public struct HostDNSResolver { return localhost } + /// Returns the registered base hostname for a one-label wildcard alias. + /// + /// For example, `nixstasis.compose.machine` aliases the registered + /// `compose.machine` hostname, while deeper names such as + /// `api.nixstasis.compose.machine` do not. The returned hostname is + /// canonical and includes its trailing dot for network-service lookup. + public static func wildcardBaseHostname(for hostname: String, baseHostname: String) -> String? { + guard let hostname = try? DNSName(hostname), + let baseHostname = try? DNSName(baseHostname), + !baseHostname.labels.isEmpty, + hostname.labels.count == baseHostname.labels.count + 1, + Array(hostname.labels.dropFirst()) == baseHostname.labels + else { + return nil + } + + return baseHostname.description + } + /// Lists application-created local DNS domains. public func listDomains() -> [DNSName] { let fm: FileManager = FileManager.default diff --git a/Sources/Services/ContainerAPIService/Client/Parser.swift b/Sources/Services/ContainerAPIService/Client/Parser.swift index 2306da739..89c873c09 100644 --- a/Sources/Services/ContainerAPIService/Client/Parser.swift +++ b/Sources/Services/ContainerAPIService/Client/Parser.swift @@ -248,14 +248,24 @@ public struct Parser { throw ContainerizationError(.invalidArgument, message: "label cannot be an empty string") } let parts = label.split(separator: "=", maxSplits: 2) + let key = String(parts[0]) + let value: String switch parts.count { case 1: - result[String(parts[0])] = "" + value = "" case 2: - result[String(parts[0])] = String(parts[1]) + value = String(parts[1]) default: throw ContainerizationError(.invalidArgument, message: "invalid label format \(label)") } + guard + !(key == ResourceLabelKeys.machineID + || key == ResourceLabelKeys.machineToken + || (key == ResourceLabelKeys.plugin && value == "machine")) + else { + throw ContainerizationError(.invalidArgument, message: "label is reserved: \(key)") + } + result[key] = value } return result } diff --git a/Sources/Services/ContainerAPIService/Client/ProcessIO.swift b/Sources/Services/ContainerAPIService/Client/ProcessIO.swift index 2af8306ab..6244d42a6 100644 --- a/Sources/Services/ContainerAPIService/Client/ProcessIO.swift +++ b/Sources/Services/ContainerAPIService/Client/ProcessIO.swift @@ -19,6 +19,32 @@ import ContainerizationOS import Foundation import Logging +final class ProcessCancellationController: @unchecked Sendable { + private let process: any ClientProcess + private let gracePeriod: Duration + private let lock = NSLock() + private var terminationStarted = false + + init(process: any ClientProcess, gracePeriod: Duration = .seconds(2)) { + self.process = process + self.gracePeriod = gracePeriod + } + + func cancel() { + lock.withLock { + guard !terminationStarted else { return } + terminationStarted = true + Task { + try? await process.kill(SIGTERM) + } + Task { + try? await Task.sleep(for: gracePeriod) + try? await process.kill(SIGKILL) + } + } + } +} + public struct ProcessIO: Sendable { let stdin: Pipe? let stdout: Pipe? @@ -158,74 +184,101 @@ public struct ProcessIO: Sendable { public func handleProcess(process: ClientProcess, log: Logger) async throws -> Int32 { let signals = AsyncSignalHandler.create(notify: Self.signalSet) - return try await withThrowingTaskGroup(of: Int32?.self, returning: Int32.self) { group in - try await process.start() - try closeAfterStart() - - let waitAdded = group.addTaskUnlessCancelled { - let code = try await process.wait() - try await wait() - return code - } + let signalStream = signals.signals + let cancellation = ProcessCancellationController(process: process) + defer { signals.cancel() } + return try await withTaskCancellationHandler( + operation: { + try await withThrowingTaskGroup(of: Int32?.self, returning: Int32.self) { group in + try Task.checkCancellation() + try await process.start() + try Task.checkCancellation() + try closeAfterStart() + + let waitAdded = group.addTaskUnlessCancelled { + let code = try await process.wait() + try await wait() + return code + } - guard waitAdded else { - group.cancelAll() - return -1 - } + guard waitAdded else { + group.cancelAll() + return -1 + } - if let current = console { - let size = try current.size - // It's supremely possible the process could've exited already. We shouldn't treat - // this as fatal. - try? await process.resize(size) - _ = group.addTaskUnlessCancelled { - let winchHandler = AsyncSignalHandler.create(notify: [SIGWINCH]) - for await _ in winchHandler.signals { - do { - try await process.resize(try current.size) - } catch { - log.error( - "failed to send terminal resize event", - metadata: [ - "error": "\(error)" - ] - ) + if let current = console { + let size = try current.size + // It's supremely possible the process could've exited already. We shouldn't treat + // this as fatal. + try? await process.resize(size) + _ = group.addTaskUnlessCancelled { + for await sig in signalStream { + switch sig { + case SIGWINCH: + do { + try await process.resize(try current.size) + } catch { + log.error( + "failed to send terminal resize event", + metadata: [ + "error": "\(error)" + ] + ) + } + case SIGINT, SIGTERM, SIGUSR1, SIGUSR2: + do { + try await process.kill(sig) + } catch { + log.error( + "failed to send signal", + metadata: [ + "signal": "\(sig)", + "error": "\(error)", + ] + ) + } + default: + continue + } + } + return nil } - } - return nil - } - } else { - _ = group.addTaskUnlessCancelled { - for await sig in signals.signals { - do { - try await process.kill(sig) - } catch { - log.error( - "failed to send signal", - metadata: [ - "signal": "\(sig)", - "error": "\(error)", - ] - ) + } else { + _ = group.addTaskUnlessCancelled { + for await sig in signalStream { + do { + try await process.kill(sig) + } catch { + log.error( + "failed to send signal", + metadata: [ + "signal": "\(sig)", + "error": "\(error)", + ] + ) + } + } + return nil } } - return nil - } - } - while true { - let result = try await group.next() - if result == nil { + while true { + let result = try await group.next() + if result == nil { + return -1 + } + let status = result! + if let status { + group.cancelAll() + return status + } + } return -1 } - let status = result! - if let status { - group.cancelAll() - return status - } - } - return -1 - } + }, + onCancel: { + cancellation.cancel() + }) } public func closeAfterStart() throws { diff --git a/Sources/Services/ContainerAPIService/Client/XPC+.swift b/Sources/Services/ContainerAPIService/Client/XPC+.swift index a4d5aebd3..862327965 100644 --- a/Sources/Services/ContainerAPIService/Client/XPC+.swift +++ b/Sources/Services/ContainerAPIService/Client/XPC+.swift @@ -263,6 +263,10 @@ extension XPCMessage { set(key: key.rawValue, value: value) } + public func setFileHandle(key: XPCKeys, value: FileHandle) throws { + try setFileHandle(key: key.rawValue, value: value) + } + public func fileHandles(key: XPCKeys) -> [FileHandle]? { fileHandles(key: key.rawValue) } diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift index 1871cd149..ef59734c0 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift @@ -93,7 +93,7 @@ public struct ContainersHarness: Sendable { let port = message.uint64(key: .port) let fh = try await service.dial(id: id, port: UInt32(port)) let reply = message.reply() - reply.setFileHandle(fh) + try reply.setFileHandle(fh) return reply } diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift index 81612495f..a12bf355a 100644 --- a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift @@ -1191,8 +1191,8 @@ extension XPCMessage { return handles } - func setFileHandle(_ handle: FileHandle) { - self.set(key: .fd, value: handle) + func setFileHandle(_ handle: FileHandle) throws { + try self.setFileHandle(key: .fd, value: handle) } func processConfig() throws -> ProcessConfiguration { diff --git a/Sources/Services/MachineAPIService/Client/MachineBundle.swift b/Sources/Services/MachineAPIService/Client/MachineBundle.swift index 93cf22793..7580362db 100644 --- a/Sources/Services/MachineAPIService/Client/MachineBundle.swift +++ b/Sources/Services/MachineAPIService/Client/MachineBundle.swift @@ -32,6 +32,7 @@ public struct MachineBundle: Sendable { public static let initFile = FilePath.Component("init") public static let initializedFile = FilePath.Component("machine.initialized") public static let bootConfigFile = FilePath.Component("boot-config.json") + public static let backingContainerTokenFile = FilePath.Component("backing-container.token") /// The path to the bundle public let path: FilePath @@ -104,6 +105,42 @@ public struct MachineBundle: Sendable { try load(filename: Self.bootConfigFile) } } + + public func backingContainerTokenIfPresent() throws -> String? { + let tokenPath = path.appending(Self.backingContainerTokenFile) + guard FileManager.default.fileExists(atPath: tokenPath.string) else { + return nil + } + return try backingContainerToken() + } + + /// Returns the persisted token used to authenticate this machine's + /// backing container during API-server restart reconciliation. + /// + /// This is intentionally read-only: older bundles without a token are not + /// silently migrated or assigned ownership after the fact. + public func backingContainerToken() throws -> String { + let tokenPath = path.appending(Self.backingContainerTokenFile) + do { + let token = try String(contentsOfFile: tokenPath.string, encoding: .utf8) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard UUID(uuidString: token) != nil else { + throw ContainerizationError( + .invalidState, + message: "machine bundle has an invalid backing container token: \(path)" + ) + } + return token + } catch let error as ContainerizationError { + throw error + } catch { + throw ContainerizationError( + .invalidState, + message: "machine bundle has no backing container token; delete and recreate the machine: \(path)", + cause: error + ) + } + } } /// Metadata from an OCI artifact or in-image file that describes how a container machine @@ -149,6 +186,16 @@ extension MachineBundle { let persisted = PersistedMachineConfig(configuration: machineConfiguration, createdDate: Date()) try bundle.write(filename: Self.configFile, value: persisted) try bundle.write(filename: Self.bootConfigFile, value: bootConfig) + let token = UUID().uuidString.lowercased() + try token.write( + toFile: path.appending(Self.backingContainerTokenFile).string, + atomically: true, + encoding: .utf8 + ) + try fm.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: path.appending(Self.backingContainerTokenFile).string + ) let sbin = path.appending(sbinDirectory) let initPath = sbin.appending(initFile) diff --git a/Sources/Services/MachineAPIService/Client/MachineClient.swift b/Sources/Services/MachineAPIService/Client/MachineClient.swift index 4fa5d0902..c1dfba3d1 100644 --- a/Sources/Services/MachineAPIService/Client/MachineClient.swift +++ b/Sources/Services/MachineAPIService/Client/MachineClient.swift @@ -127,6 +127,7 @@ public struct MachineClient: Sendable { configuration: MachineConfiguration, resources: MachineResources?, bootConfig: MachineConfig, + makeDefaultIfNone: Bool = true, ) async throws { do { let request = XPCMessage(route: MachineRoutes.createMachine.rawValue) @@ -141,6 +142,7 @@ public struct MachineClient: Sendable { let bootData = try JSONEncoder().encode(bootConfig) request.set(key: MachineKeys.bootConfig.rawValue, value: bootData) + request.set(key: MachineKeys.makeDefaultIfNone.rawValue, value: makeDefaultIfNone) let _ = try await xpcSend(message: request, timeout: nil) } catch { diff --git a/Sources/Services/MachineAPIService/Client/MachineConfiguration.swift b/Sources/Services/MachineAPIService/Client/MachineConfiguration.swift index ea375ebd4..346af457f 100644 --- a/Sources/Services/MachineAPIService/Client/MachineConfiguration.swift +++ b/Sources/Services/MachineAPIService/Client/MachineConfiguration.swift @@ -55,6 +55,9 @@ public struct MachineConfiguration: Sendable, Codable { public var platform: ContainerizationOCI.Platform /// User setup from first boot. Nil means provisioning has not run yet. public var userSetup: UserSetup + /// Optional owner marker for machines managed by a specialized plugin. + /// Nil is retained for ordinary and legacy machines. + public var managedBy: String? public var user: ProcessConfiguration.User { userSetup.user @@ -88,12 +91,14 @@ public struct MachineConfiguration: Sendable, Codable { id: String, image: ImageDescription, platform: ContainerizationOCI.Platform, - userSetup: UserSetup + userSetup: UserSetup, + managedBy: String? = nil ) throws { self.id = id self.image = image self.platform = platform self.userSetup = userSetup + self.managedBy = managedBy try self.validate() } @@ -106,6 +111,7 @@ public struct MachineConfiguration: Sendable, Codable { 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()) + self.managedBy = try container.decodeIfPresent(String.self, forKey: .managedBy) try self.validate() } diff --git a/Sources/Services/MachineAPIService/Client/MachineKeys.swift b/Sources/Services/MachineAPIService/Client/MachineKeys.swift index f699b07ef..0f59d018a 100644 --- a/Sources/Services/MachineAPIService/Client/MachineKeys.swift +++ b/Sources/Services/MachineAPIService/Client/MachineKeys.swift @@ -31,4 +31,6 @@ public enum MachineKeys: String { case logs /// Special-case environment variables recomputed on container machine start case dynamicEnv + /// Whether creating a machine should make it the default when none exists. + case makeDefaultIfNone } diff --git a/Sources/Services/MachineAPIService/Client/MachineLifecycle.swift b/Sources/Services/MachineAPIService/Client/MachineLifecycle.swift new file mode 100644 index 000000000..2c0b69ef2 --- /dev/null +++ b/Sources/Services/MachineAPIService/Client/MachineLifecycle.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 ContainerAPIClient +import ContainerResource +import ContainerizationError +import Foundation +import Logging + +extension MachineClient { + /// Boots a machine and performs its one-time user provisioning. + /// + /// This is shared by machine commands and specialized machine consumers so + /// that every caller observes the same first-boot and failure behavior. + @discardableResult + public func bootAndInitialize( + id: String?, + dynamicEnv: [String: String] = [:], + forwardSSHAgent: Bool = true, + log: Logger, + interactive: Bool + ) async throws -> MachineSnapshot { + var bootEnvironment = dynamicEnv + if forwardSSHAgent, + bootEnvironment["SSH_AUTH_SOCK"] == nil, + let sshAuthSock = ProcessInfo.processInfo.environment["SSH_AUTH_SOCK"] + { + bootEnvironment["SSH_AUTH_SOCK"] = sshAuthSock + } + + let snapshot = try await boot(id: id, dynamicEnv: bootEnvironment) + 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 stop(id: snapshot.id) + throw error + } + + return try await inspect(id: snapshot.id) + } +} diff --git a/Sources/Services/MachineAPIService/Server/MachinesHarness.swift b/Sources/Services/MachineAPIService/Server/MachinesHarness.swift index 96421395b..25c1719fa 100644 --- a/Sources/Services/MachineAPIService/Server/MachinesHarness.swift +++ b/Sources/Services/MachineAPIService/Server/MachinesHarness.swift @@ -51,8 +51,17 @@ public struct MachinesHarness: Sendable { 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) + let makeDefaultIfNone = + message.contains(key: MachineKeys.makeDefaultIfNone.rawValue) + ? message.bool(key: MachineKeys.makeDefaultIfNone.rawValue) + : true + + try await service.create( + configuration: config, + resources: resources, + bootConfig: bootConfig, + makeDefaultIfNone: makeDefaultIfNone + ) return message.reply() } diff --git a/Sources/Services/MachineAPIService/Server/MachinesService.swift b/Sources/Services/MachineAPIService/Server/MachinesService.swift index f641d3521..a32c44c38 100644 --- a/Sources/Services/MachineAPIService/Server/MachinesService.swift +++ b/Sources/Services/MachineAPIService/Server/MachinesService.swift @@ -143,6 +143,85 @@ public actor MachinesService { } } + nonisolated static func isLegacyBackingContainer( + id: String, + labels: [String: String], + machineID: String + ) -> Bool { + labels[ResourceLabelKeys.plugin] == "machine" + && labels[ResourceLabelKeys.machineID] == machineID + && id.hasPrefix("\(machineID)-") + } + + private func hasLegacyBackingContainer(machineID: String) async throws -> Bool { + let candidates = try await self.client.list(filters: .machines()) + return candidates.contains { + Self.isLegacyBackingContainer( + id: $0.id, + labels: $0.configuration.labels, + machineID: machineID + ) + } + } + + /// Removes backing containers left behind when the machine API server was + /// restarted. Persistent machine bundles are loaded as stopped, so any + /// machine-labeled container present at startup is orphaned from this + /// service instance and must not be reused alongside a new boot. + public func reconcileOrphanedContainers() async throws { + // Labels and the generated ID are user-controlled metadata. Only a + // token persisted inside the machine bundle authenticates ownership. + let candidates = try await self.client.list(filters: .machines()) + var tokens = [String: String]() + for state in self.machines.values { + do { + tokens[state.id] = try self.bundleToken(id: state.id) + } catch { + self.log.warning( + "machine has no valid backing-container ownership token; leaving matching containers untouched", + metadata: ["id": "\(state.id)", "error": "\(error)"] + ) + } + } + let orphaned = candidates.filter { container in + guard container.configuration.labels[ResourceLabelKeys.plugin] == "machine", + let machineID = container.configuration.labels[ResourceLabelKeys.machineID], + let token = tokens[machineID], + container.configuration.labels[ResourceLabelKeys.machineToken] == token + else { + return false + } + return true + } + guard !orphaned.isEmpty else { + return + } + + var firstError: Error? + for container in orphaned { + do { + try await self.client.delete(id: container.id, force: true) + } catch { + firstError = firstError ?? error + self.log.error( + "failed to remove orphaned machine backing container", + metadata: [ + "id": "\(container.id)", + "error": "\(error)", + ] + ) + } + } + + for state in self.machines.values { + cleanupPublishedSocket(path: state.snapshot.bootConfig.dockerSocketPath, log: self.log) + } + + if let firstError { + throw firstError + } + } + public func list() async throws -> [MachineSnapshot] { self.log.debug("\(#function)") var snapshots: [MachineSnapshot] = [] @@ -175,7 +254,12 @@ public actor MachinesService { return snapshots } - public func create(configuration: MachineConfiguration, resources: MachineResources?, bootConfig: MachineConfig) async throws { + public func create( + configuration: MachineConfiguration, + resources: MachineResources?, + bootConfig: MachineConfig, + makeDefaultIfNone: Bool = true + ) async throws { self.log.debug("\(#function)") try await self.lock.withLock { context in @@ -211,7 +295,7 @@ public actor MachinesService { ) await self.setMachineState(configuration.id, state, context: context) - if await self.default == nil { + if makeDefaultIfNone, await self.default == nil { try await self._setDefault(id: configuration.id) } } catch { @@ -245,6 +329,7 @@ public actor MachinesService { try await self._setDefault(id: nil) } + cleanupPublishedSocket(path: state.snapshot.bootConfig.dockerSocketPath, log: self.log) try await self._cleanUp(id: id) } } @@ -291,6 +376,16 @@ public actor MachinesService { self.machines[id] = state } + private nonisolated func bundleToken(id: String) throws -> String { + let path = try self.bundlePath(id: id) + return try MachineBundle(path: path).backingContainerToken() + } + + private nonisolated func bundleTokenIfPresent(id: String) throws -> String? { + let path = try self.bundlePath(id: id) + return try MachineBundle(path: path).backingContainerTokenIfPresent() + } + private nonisolated func bundlePath(id: String) throws -> FilePath { guard let component = FilePath.Component(id) else { throw ContainerizationError( @@ -358,12 +453,30 @@ public actor MachinesService { let rootfs = try bundle.machineRootfs let bootConfig = state.snapshot.bootConfig + let backingContainerToken: String? + // Unmanaged bundles may predate ownership tokens; never assign one during boot. + if state.snapshot.configuration.managedBy == nil { + backingContainerToken = try self.bundleTokenIfPresent(id: id) + if backingContainerToken == nil, + try await self.hasLegacyBackingContainer(machineID: id) + { + throw ContainerizationError( + .invalidState, + message: "container machine \(id) has an unowned backing container; remove it before booting" + ) + } + } else { + backingContainerToken = try self.bundleToken(id: id) + } var config = try await state.snapshot.configuration.toContainerConfig( cid: cid, + backingContainerToken: backingContainerToken, sbin: path.appending(MachineBundle.sbinDirectory), initializedFile: path.appending(MachineBundle.initializedFile), homeMountOption: bootConfig.homeMount, virtualization: bootConfig.virtualization, + runtimeProfile: bootConfig.runtimeProfile, + dockerSocketPath: bootConfig.dockerSocketPath, ) config.resources.cpus = bootConfig.cpus @@ -391,6 +504,7 @@ public actor MachinesService { let process = try await self.client.bootstrap( id: cid, stdio: [nil, nil, nil], dynamicEnv: dynamicEnv) + try setPublishedSocketPermissions(path: bootConfig.dockerSocketPath) try await process.start() try fhs.append(contentsOf: await self.client.logs(id: cid)) @@ -470,6 +584,7 @@ public actor MachinesService { fhs.forEach { try? $0.close() } try? await self.client.delete(id: cid, force: true) + cleanupPublishedSocket(path: bootConfig.dockerSocketPath, log: self.log) state.snapshot.status = .stopped state.snapshot.startedDate = nil @@ -527,6 +642,7 @@ public actor MachinesService { state.snapshot.startedDate = nil state.snapshot.containerId = nil state.snapshot.ipAddress = nil + cleanupPublishedSocket(path: state.snapshot.bootConfig.dockerSocketPath, log: self.log) state.logger?.cancel() await state.logger?.value @@ -635,10 +751,13 @@ extension MachineBundle { extension MachineConfiguration { fileprivate func toContainerConfig( cid: String, + backingContainerToken: String?, sbin: FilePath, initializedFile: FilePath, homeMountOption: MachineConfig.HomeMountOption, virtualization: Bool, + runtimeProfile: MachineConfig.RuntimeProfile, + dockerSocketPath: FilePath?, ) async throws -> ContainerConfiguration { var config = ContainerConfiguration( id: cid, @@ -676,8 +795,12 @@ extension MachineConfiguration { config.platform = platform config.labels = [ - ResourceLabelKeys.plugin: "machine" + ResourceLabelKeys.plugin: "machine", + ResourceLabelKeys.machineID: id, ] + if let backingContainerToken { + config.labels[ResourceLabelKeys.machineToken] = backingContainerToken + } let domain = Self.defaultDNSDomain config.dns = ContainerConfiguration.DNSConfiguration( nameservers: [], @@ -697,6 +820,24 @@ extension MachineConfiguration { config.capAdd = ["ALL"] config.ssh = true config.virtualization = virtualization + if runtimeProfile == .nestedDocker { + config.readonlyPaths = [] + } + + if let dockerSocketPath { + try preparePublishedSocket(path: dockerSocketPath) + config.publishedSockets = [ + try PublishSocket( + // /run is a tmpfs mount inside the machine and is not + // visible through the machine rootfs used by the guest + // socket proxy. Keep the Docker listener on /etc, which + // is visible from both namespaces. + containerPath: FilePath("/etc/docker/docker.sock"), + hostPath: dockerSocketPath, + permissions: FilePermissions(rawValue: 0o600) + ) + ] + } config.rosetta = platform.architecture == "amd64" && Arch.hostArchitecture() == .arm64 @@ -706,4 +847,340 @@ extension MachineConfiguration { return config } + +} + +func preparePublishedSocket(path: FilePath) throws { + let parentFD = try openSecureDirectory(path.removingLastComponent(), create: true) + defer { Darwin.close(parentFD) } + + guard let name = path.lastComponent?.string else { + throw ContainerizationError(.invalidArgument, message: "Docker socket path has no final component: \(path)") + } + + // Remove only a socket we atomically claim. Do not retry: a new endpoint + // appearing after removal belongs to whoever created it and must not be + // treated as another stale socket. + switch try removeStalePublishedSocket(path: path, parentFD: parentFD, name: name) { + case .clear: + return + case .active: + throw ContainerizationError( + .exists, + message: "cannot publish Docker socket at \(path): an active Unix socket already exists" + ) + case .notSocket: + throw ContainerizationError( + .exists, + message: "cannot publish Docker socket at \(path): path exists and is not a Unix socket" + ) + case .changed: + throw ContainerizationError( + .exists, + message: "cannot publish Docker socket at \(path): endpoint changed while it was being prepared" + ) + } +} + +func openSecureDirectory(_ path: FilePath, create: Bool) throws -> Int32 { + guard path.isAbsolute else { + throw ContainerizationError(.invalidArgument, message: "Docker socket parent must be absolute: \(path)") + } + + var fd = Darwin.open("/", O_RDONLY | O_DIRECTORY | O_CLOEXEC) + guard fd >= 0 else { + throw POSIXError.fromErrno() + } + + let components = path.string.split(separator: "/", omittingEmptySubsequences: true) + for component in components { + let name = String(component) + var next = name.withCString { + Darwin.openat(fd, $0, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW) + } + if next < 0, errno == ENOENT, create { + guard name.withCString({ Darwin.mkdirat(fd, $0, 0o700) }) == 0 || errno == EEXIST else { + let error = POSIXError.fromErrno() + Darwin.close(fd) + throw error + } + next = name.withCString { + Darwin.openat(fd, $0, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW) + } + } + guard next >= 0 else { + let error = POSIXError.fromErrno() + Darwin.close(fd) + throw error + } + Darwin.close(fd) + fd = next + } + return fd +} + +enum PublishedSocketRemovalResult { + case clear + case active + case notSocket + case changed +} + +private struct PublishedSocketIdentity: Equatable { + let device: UInt64 + let inode: UInt64 + let mode: UInt32 + + init(_ stat: Darwin.stat) { + self.device = UInt64(stat.st_dev) + self.inode = UInt64(stat.st_ino) + self.mode = UInt32(stat.st_mode & S_IFMT) + } +} + +/// Claims the final directory entry with an atomic rename before inspecting +/// or removing it. A replacement at the public path is never unlinked. +func removeStalePublishedSocket( + path: FilePath, + parentFD: Int32, + name: String, + beforeClaim: (() throws -> Void)? = nil, + afterClaim: (() throws -> Void)? = nil +) throws -> PublishedSocketRemovalResult { + var before = Darwin.stat() + guard name.withCString({ Darwin.fstatat(parentFD, $0, &before, AT_SYMLINK_NOFOLLOW) }) == 0 else { + if errno == ENOENT { + return .clear + } + throw POSIXError.fromErrno() + } + + guard (before.st_mode & S_IFMT) == S_IFSOCK else { + return .notSocket + } + if isSocketListening(at: path) { + return .active + } + + try beforeClaim?() + let renameFlags = UInt32(RENAME_EXCL | RENAME_NOFOLLOW_ANY) + let quarantineLength = max(1, min(name.utf8.count, 12)) + var quarantineName: String? + for _ in 0..<16 { + let candidate = String( + UUID().uuidString.replacingOccurrences(of: "-", with: "") + .lowercased() + .prefix(quarantineLength) + ) + guard candidate != name else { continue } + let renamed = name.withCString { source in + candidate.withCString { destination in + Darwin.renameatx_np(parentFD, source, parentFD, destination, renameFlags) + } + } + if renamed == 0 { + quarantineName = candidate + break + } + if errno == ENOENT { + return .clear + } + guard errno == EEXIST else { + throw ContainerizationError( + .internalError, + message: "failed to quarantine Docker endpoint at \(path)", + cause: POSIXError.fromErrno() + ) + } + } + guard let quarantineName, + let component = FilePath.Component(quarantineName) + else { + throw ContainerizationError( + .exists, + message: "could not reserve a quarantine name for Docker endpoint at \(path)" + ) + } + let quarantinePath = path.removingLastComponent().appending(component) + + do { + try afterClaim?() + } catch { + try restorePublishedSocket( + parentFD: parentFD, + quarantineName: quarantineName, + originalName: name, + quarantinePath: quarantinePath + ) + throw error + } + + var after = Darwin.stat() + guard quarantineName.withCString({ Darwin.fstatat(parentFD, $0, &after, AT_SYMLINK_NOFOLLOW) }) == 0 else { + throw ContainerizationError( + .exists, + message: "claimed Docker endpoint changed; leaving it quarantined at \(quarantinePath)" + ) + } + guard PublishedSocketIdentity(after) == PublishedSocketIdentity(before) else { + try restorePublishedSocket( + parentFD: parentFD, + quarantineName: quarantineName, + originalName: name, + quarantinePath: quarantinePath + ) + return .changed + } + + if isSocketListening(at: quarantinePath) { + try restorePublishedSocket( + parentFD: parentFD, + quarantineName: quarantineName, + originalName: name, + quarantinePath: quarantinePath + ) + return .active + } + + var final = Darwin.stat() + guard quarantineName.withCString({ Darwin.fstatat(parentFD, $0, &final, AT_SYMLINK_NOFOLLOW) }) == 0, + PublishedSocketIdentity(final) == PublishedSocketIdentity(before) + else { + throw ContainerizationError( + .exists, + message: "claimed Docker endpoint changed; leaving it quarantined at \(quarantinePath)" + ) + } + + guard Darwin.unlinkat(parentFD, quarantineName, 0) == 0 else { + if errno == ENOENT { + return .changed + } + throw ContainerizationError( + .internalError, + message: "failed to remove quarantined Docker endpoint at \(quarantinePath)", + cause: POSIXError.fromErrno() + ) + } + return .clear +} + +private func restorePublishedSocket( + parentFD: Int32, + quarantineName: String, + originalName: String, + quarantinePath: FilePath +) throws { + let renameFlags = UInt32(RENAME_EXCL | RENAME_NOFOLLOW_ANY) + let restored = quarantineName.withCString { source in + originalName.withCString { destination in + Darwin.renameatx_np(parentFD, source, parentFD, destination, renameFlags) + } + } + guard restored == 0 else { + throw ContainerizationError( + .exists, + message: "could not restore active Docker endpoint; it remains quarantined at \(quarantinePath)" + ) + } +} + +func isSocketListening(at path: FilePath) -> Bool { + let fileDescriptor = Darwin.socket(AF_UNIX, SOCK_STREAM, 0) + guard fileDescriptor >= 0 else { + return true + } + defer { Darwin.close(fileDescriptor) } + + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + let pathBytes = Array(path.string.utf8) + guard pathBytes.count < MemoryLayout.size(ofValue: address.sun_path) else { + return true + } + + withUnsafeMutableBytes(of: &address.sun_path) { destination in + destination.initializeMemory(as: UInt8.self, repeating: 0) + destination.copyBytes(from: pathBytes) + } + #if os(macOS) + address.sun_len = UInt8(MemoryLayout.size + MemoryLayout.size + pathBytes.count + 1) + #endif + + let result = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { socketAddress in + Darwin.connect( + fileDescriptor, + socketAddress, + socklen_t(MemoryLayout.size) + ) + } + } + + if result == 0 { + return true + } + + switch errno { + case ENOENT, ECONNREFUSED, ECONNRESET: + return false + default: + // Treat permission and other unexpected errors conservatively. A + // plugin restart must not unlink an endpoint it cannot inspect safely. + return true + } +} + +private func setPublishedSocketPermissions(path: FilePath?) throws { + guard let path else { + return + } + + let parentFD = try openSecureDirectory(path.removingLastComponent(), create: false) + defer { Darwin.close(parentFD) } + + guard let name = path.lastComponent?.string else { + throw ContainerizationError(.invalidArgument, message: "Docker socket path has no final component: \(path)") + } + var stat = Darwin.stat() + guard name.withCString({ Darwin.fstatat(parentFD, $0, &stat, AT_SYMLINK_NOFOLLOW) }) == 0 else { + throw POSIXError.fromErrno() + } + guard (stat.st_mode & S_IFMT) == S_IFSOCK else { + throw ContainerizationError( + .invalidState, + message: "published Docker endpoint is not a Unix socket at \(path)" + ) + } + guard Darwin.fchmodat(parentFD, name, 0o600, AT_SYMLINK_NOFOLLOW) == 0 else { + throw POSIXError.fromErrno() + } +} + +func cleanupPublishedSocket(path: FilePath?, log: Logger) { + guard let path, let name = path.lastComponent?.string else { + return + } + guard let parent = try? openSecureDirectory(path.removingLastComponent(), create: false) else { + return + } + defer { Darwin.close(parent) } + + do { + switch try removeStalePublishedSocket(path: path, parentFD: parent, name: name) { + case .clear: + break + case .active: + log.warning("leaving active Docker endpoint in place", metadata: ["path": "\(path)"]) + case .notSocket: + log.warning("leaving non-socket Docker endpoint in place", metadata: ["path": "\(path)"]) + case .changed: + log.warning("leaving Docker endpoint that changed during cleanup in place", metadata: ["path": "\(path)"]) + } + } catch { + log.warning( + "failed to safely clean up Docker endpoint", + metadata: ["path": "\(path)", "error": "\(error)"] + ) + } } diff --git a/Sources/Services/Runtime/RuntimeClient/RuntimeClient.swift b/Sources/Services/Runtime/RuntimeClient/RuntimeClient.swift index 32a4db062..f22e08952 100644 --- a/Sources/Services/Runtime/RuntimeClient/RuntimeClient.swift +++ b/Sources/Services/Runtime/RuntimeClient/RuntimeClient.swift @@ -96,7 +96,7 @@ extension RuntimeClient { }() if let h { - request.set(key: key.rawValue, value: h) + try request.setFileHandle(key: key.rawValue, value: h) } } @@ -149,7 +149,7 @@ extension RuntimeClient { }() if let h { - request.set(key: key.rawValue, value: h) + try request.setFileHandle(key: key.rawValue, value: h) } } diff --git a/Sources/Services/RuntimeLinux/Server/RuntimeService.swift b/Sources/Services/RuntimeLinux/Server/RuntimeService.swift index 948a65603..637bae712 100644 --- a/Sources/Services/RuntimeLinux/Server/RuntimeService.swift +++ b/Sources/Services/RuntimeLinux/Server/RuntimeService.swift @@ -875,7 +875,7 @@ public actor RuntimeService { let fh = try await ctr.container.dialVsock(port: UInt32(port)) let reply = message.reply() - reply.set(key: RuntimeKeys.fd.rawValue, value: fh) + try reply.setFileHandle(key: RuntimeKeys.fd.rawValue, value: fh) return reply default: throw ContainerizationError( @@ -1394,8 +1394,8 @@ extension XPCMessage { return handles } - fileprivate func setFileHandle(_ handle: FileHandle) { - self.set(key: RuntimeKeys.fd.rawValue, value: handle) + fileprivate func setFileHandle(_ handle: FileHandle) throws { + try self.setFileHandle(key: RuntimeKeys.fd.rawValue, value: handle) } fileprivate func processConfig() throws -> ProcessConfiguration { diff --git a/Tests/ComposePluginTests/ComposeCompletionTests.swift b/Tests/ComposePluginTests/ComposeCompletionTests.swift new file mode 100644 index 000000000..623b24b6b --- /dev/null +++ b/Tests/ComposePluginTests/ComposeCompletionTests.swift @@ -0,0 +1,67 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Testing + +@testable import ContainerCompose + +@Suite("Compose completions") +struct ComposeCompletionTests { + @Test + func bashDelegatesNonComposeCompletion() throws { + let probe = """ + _previous_container_completion() { COMPREPLY=(previous); } + complete -o default -F _previous_container_completion container + \(ComposeCompletionProvider.script(for: .bash)) + COMP_WORDS=(container list) + COMP_CWORD=2 + COMPREPLY=() + _container_compose_complete + [[ "${COMPREPLY[0]}" == previous ]] + """ + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/bash") + process.arguments = ["-c", probe] + try process.run() + process.waitUntilExit() + #expect(process.terminationStatus == 0) + } + + @Test(arguments: ComposeCompletionShell.allCases) + func pluginOptionsAreOnlyOfferedBeforeAComposeSubcommand(shell: ComposeCompletionShell) { + let script = ComposeCompletionProvider.script(for: shell) + + #expect(ComposeCompletionProvider.pluginOptions.contains("--socket-path")) + #expect(ComposeCompletionProvider.pluginOptions.contains("--completions")) + #expect(!ComposeCompletionProvider.composeOptions.contains("--socket-path")) + #expect(!ComposeCompletionProvider.composeOptions.contains("--completions")) + #expect(script.components(separatedBy: ComposeCompletionProvider.pluginOptions).count == 2) + + switch shell { + case .bash: + #expect(script.contains("if (( COMP_CWORD <= 2 )); then")) + #expect(script.contains("_container_compose_previous_completion")) + case .zsh: + #expect(script.contains("if (( CURRENT == 3 )); then")) + #expect(script.contains("_container_compose_previous_completion")) + case .fish: + #expect(script.contains("test (count (commandline -opc)) -eq 2")) + #expect(script.contains("test (count (commandline -opc)) -gt 2")) + #expect(script.contains("test (commandline -opc)[2] = compose")) + } + } +} diff --git a/Tests/ComposePluginTests/ComposeEnvironmentTests.swift b/Tests/ComposePluginTests/ComposeEnvironmentTests.swift new file mode 100644 index 000000000..f1ebe84db --- /dev/null +++ b/Tests/ComposePluginTests/ComposeEnvironmentTests.swift @@ -0,0 +1,142 @@ +//===----------------------------------------------------------------------===// +// 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 +import Testing + +@testable import ContainerCompose + +@Suite("Compose environment") +struct ComposeEnvironmentTests { + @Test + func hostSSHAgentIsNotForwardedToComposeEnvironment() throws { + let environment = try ComposeEnvironment.make( + hostEnvironment: [ + "SSH_AUTH_SOCK": "/private/tmp/agent.sock", + "GIT_SSH_COMMAND": "ssh -A", + "PATH": "/host/bin", + ], + homeDirectory: FilePath("/Users/tester"), + workingDirectory: FilePath("/Users/tester/project") + ) + #expect(environment["SSH_AUTH_SOCK"] == nil) + #expect(environment["GIT_SSH_COMMAND"] == nil) + #expect(environment["PATH"] != "/host/bin") + } + + @Test + func reservedOptionsAreClassifiedWithoutParsingCompose() { + #expect(ComposeInvocation.reservedOption(in: ["up", "--socket-path"]) == .socketPath) + #expect(ComposeInvocation.reservedOption(in: ["up", "--completions=bash"]) == .completions) + #expect(ComposeInvocation.reservedOption(in: ["run", "--", "--socket-path"]) == nil) + #expect(ComposeInvocation.reservedOption(in: ["up", "--file", "compose.yaml"]) == nil) + } + + @Test + func forwardedHelpIsDetectedBeforeComposePassthroughArguments() { + #expect(ComposeInvocation.requestsHelp(in: ["up", "--help"])) + #expect(ComposeInvocation.requestsHelp(in: ["config", "-h"])) + #expect(!ComposeInvocation.requestsHelp(in: ["run", "api", "--", "--help"])) + #expect(!ComposeInvocation.requestsHelp(in: ["up", "--help=verbose"])) + } + + @Test + func forwardedHelpUsesContainerComposeName() { + let output = "Usage: docker compose up [OPTIONS] [SERVICE ...]" + + #expect( + ComposeHelpOutput.rewrite(output) + == "Usage: container compose up [OPTIONS] [SERVICE ...]" + ) + } + + @Test + func composeHelpUsesHostCommandAndSubcommandArgument() { + #expect( + ComposeCommand.usageString(for: ComposeCommand.self) + == "container compose [--completions | --socket-path | ...]" + ) + } + + @Test + func helpOutputRewritesOnlyDockerComposeInvocation() { + let output = "docker compose up\ncompose uses docker compose internally\n" + + #expect( + ComposeHelpOutput.rewrite(output) + == "container compose up\ncompose uses container compose internally\n" + ) + } + + @Test + func socketEndpointIsPureAndStable() { + let endpoint = ComposeSocketEndpoint(homeDirectory: FilePath("/Users/tester")) + + #expect(endpoint.path == FilePath("/Users/tester/.local/run/docker.socket")) + #expect(endpoint.dockerHost == "unix:///Users/tester/.local/run/docker.socket") + } + + @Test + func workingDirectoryMustBeVisibleInMachine() throws { + let cwd = try ComposeEnvironment.workingDirectory( + currentDirectory: FilePath("/Users/tester/project"), + homeDirectory: FilePath("/Users/tester") + ) + + #expect(cwd == "/Users/tester/project") + } + + @Test + func workingDirectoryOutsideHomeIsRejected() { + #expect(throws: ContainerizationError.self) { + try ComposeEnvironment.workingDirectory( + currentDirectory: FilePath("/private/tmp/project"), + homeDirectory: FilePath("/Users/tester") + ) + } + } + + @Test + func environmentUsesInnerDockerEndpointAndPreservesComposeValues() throws { + let environment = try ComposeEnvironment.make( + hostEnvironment: [ + "COMPOSE_PROJECT_NAME": "demo", + "DOCKER_HOST": "unix:///host/docker.sock", + "DOCKER_CONTEXT": "desktop-linux", + "DOCKER_TLS_VERIFY": "1", + "DOCKER_CONFIG": "/Users/tester/.docker", + "SSH_AUTH_SOCK": "/private/tmp/agent.sock", + "TMPDIR": "/var/folders/host", + "USER_SETTING": "kept", + ], + homeDirectory: FilePath("/Users/tester"), + workingDirectory: FilePath("/Users/tester/project") + ) + + #expect(environment["COMPOSE_PROJECT_NAME"] == "demo") + #expect(environment["USER_SETTING"] == "kept") + #expect(environment["DOCKER_HOST"] == "unix:///etc/docker/docker.sock") + #expect(environment["DOCKER_CONTEXT"] == nil) + #expect(environment["DOCKER_TLS_VERIFY"] == nil) + #expect(environment["DOCKER_CONFIG"] == "/root/.docker") + #expect(environment["SSH_AUTH_SOCK"] == nil) + #expect(environment["TMPDIR"] == "/tmp") + #expect(environment["HOME"] == "/Users/tester") + #expect(environment["PWD"] == "/Users/tester/project") + } +} diff --git a/Tests/ComposePluginTests/ComposeIdleShutdownTests.swift b/Tests/ComposePluginTests/ComposeIdleShutdownTests.swift new file mode 100644 index 000000000..bb1f86a96 --- /dev/null +++ b/Tests/ComposePluginTests/ComposeIdleShutdownTests.swift @@ -0,0 +1,157 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Testing + +@Suite("Compose idle shutdown") +struct ComposeIdleShutdownTests { + @Test + func disabledByDefault() throws { + let result = try runScript( + dockerScript: "#!/bin/sh\nexit 0\n", + dateScript: "#!/bin/sh\nprintf '601\\n'\n", + timeout: "0", + runOnce: false + ) + + #expect(result.status == 0) + #expect(result.systemctlOutput.isEmpty) + } + + @Test + func powersOffAfterTenMinutesWithoutContainers() throws { + let result = try runScript( + dockerScript: "#!/bin/sh\nexit 0\n", + dateScript: """ + #!/bin/sh + count=$(cat "$COMPOSE_IDLE_SHUTDOWN_DATE_STATE") + if [ "$count" -eq 0 ]; then + printf '0\\n' + else + printf '601\\n' + fi + printf '%s\\n' "$((count + 1))" > "$COMPOSE_IDLE_SHUTDOWN_DATE_STATE" + """, + timeout: "600", + runOnce: false + ) + + #expect(result.status == 0) + #expect(result.systemctlOutput == "poweroff --no-wall\n") + } + + @Test + func doesNotPowerOffWhileAContainerIsRunning() throws { + let result = try runScript( + dockerScript: "#!/bin/sh\nprintf 'container-id\\n'\n", + dateScript: "#!/bin/sh\nprintf '601\\n'\n", + timeout: "600", + runOnce: true + ) + + #expect(result.status == 0) + #expect(result.systemctlOutput.isEmpty) + } + + @Test + func connectedDockerClientPreventsShutdown() throws { + let result = try runScript( + dockerScript: "#!/bin/sh\nexit 0\n", + dateScript: "#!/bin/sh\nprintf '0\\n'\n", + unixSocketsContent: "00000000: 00000003 00000000 00000000 0001 03 123 /etc/docker/docker.sock\\n", + timeout: "600", + runOnce: true + ) + + #expect(result.status == 0) + #expect(result.systemctlOutput.isEmpty) + } + + private struct ScriptResult { + let status: Int32 + let systemctlOutput: String + } + + private func runScript( + dockerScript: String, + dateScript: String, + unixSocketsContent: String = "", + timeout: String, + runOnce: Bool + ) throws -> ScriptResult { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let script = root.appendingPathComponent( + "Sources/ContainerCompose/Resources/container-compose-idle-shutdown" + ) + let temp = FileManager.default.temporaryDirectory + .appendingPathComponent("compose-idle-shutdown-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: temp, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: temp) } + + let docker = temp.appendingPathComponent("docker") + let date = temp.appendingPathComponent("date") + let systemctl = temp.appendingPathComponent("systemctl") + let systemctlLog = temp.appendingPathComponent("systemctl.log") + let dateState = temp.appendingPathComponent("date.state") + let unixSockets = temp.appendingPathComponent("unix") + + try Data(dockerScript.utf8).write(to: docker) + try Data(dateScript.utf8).write(to: date) + try Data(unixSocketsContent.utf8).write(to: unixSockets) + try Data("#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$COMPOSE_IDLE_SHUTDOWN_SYSTEMCTL_LOG\"\n".utf8) + .write(to: systemctl) + try Data("0\n".utf8).write(to: dateState) + for url in [docker, date, systemctl] { + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: 0o755)], + ofItemAtPath: url.path + ) + } + + var environment = ProcessInfo.processInfo.environment + environment["COMPOSE_IDLE_SHUTDOWN_DOCKER_BIN"] = docker.path + environment["COMPOSE_IDLE_SHUTDOWN_DATE_BIN"] = date.path + environment["COMPOSE_IDLE_SHUTDOWN_UNIX_SOCKETS_FILE"] = unixSockets.path + environment["COMPOSE_IDLE_SHUTDOWN_SYSTEMCTL_BIN"] = systemctl.path + environment["COMPOSE_IDLE_SHUTDOWN_SLEEP_BIN"] = "/bin/sleep" + environment["COMPOSE_IDLE_SHUTDOWN_IDLE_SECONDS"] = timeout + environment["COMPOSE_IDLE_SHUTDOWN_INTERVAL_SECONDS"] = "0" + environment["COMPOSE_IDLE_SHUTDOWN_SYSTEMCTL_LOG"] = systemctlLog.path + environment["COMPOSE_IDLE_SHUTDOWN_DATE_STATE"] = dateState.path + if runOnce { + environment["COMPOSE_IDLE_SHUTDOWN_RUN_ONCE"] = "1" + } + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = [script.path] + process.environment = environment + process.standardOutput = Pipe() + process.standardError = Pipe() + try process.run() + process.waitUntilExit() + + let output = + FileManager.default.fileExists(atPath: systemctlLog.path) + ? try String(contentsOf: systemctlLog, encoding: .utf8) + : "" + return ScriptResult(status: process.terminationStatus, systemctlOutput: output) + } +} diff --git a/Tests/ComposePluginTests/ComposeProcessLifecycleTests.swift b/Tests/ComposePluginTests/ComposeProcessLifecycleTests.swift new file mode 100644 index 000000000..c7dcf0fff --- /dev/null +++ b/Tests/ComposePluginTests/ComposeProcessLifecycleTests.swift @@ -0,0 +1,172 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationError +import ContainerizationOS +import Darwin +import Foundation +import Logging +import Testing + +@testable import ContainerCompose + +@Suite("Compose process lifecycle") +struct ComposeProcessLifecycleTests { + @Test + func captureCancellationKillsBeforeStartupAcknowledgement() async throws { + let process = MockClientProcess() + let cancellation = ComposeProcessCancellation(process: process) + + cancellation.cancel() + for _ in 0..<20 where await process.recordedSignals().isEmpty { + try await Task.sleep(for: .milliseconds(10)) + } + #expect(await process.recordedSignals() == [SIGKILL]) + } + + @Test + func capturedOutputLimitKillsTheProcess() async throws { + let stdout = Pipe() + let stderr = Pipe() + let process = MockClientProcess { + try? stderr.fileHandleForWriting.close() + } + stdout.fileHandleForWriting.write(Data(repeating: 1, count: 1_025)) + try stdout.fileHandleForWriting.close() + defer { try? stderr.fileHandleForWriting.close() } + let clock = ContinuousClock() + let start = clock.now + + await #expect(throws: ContainerizationError.self) { + try await ComposeProcessRunner.capture( + process: process, + stdout: stdout, + stderr: stderr, + timeout: .seconds(5), + maxOutputBytes: 1_024 + ) + } + #expect(clock.now - start < .seconds(1)) + for _ in 0..<20 where await process.recordedSignals().isEmpty { + try await Task.sleep(for: .milliseconds(10)) + } + #expect(await process.recordedSignals() == [SIGKILL]) + } + + @Test + func capturedOutputAcceptsTheLimit() throws { + let pipe = Pipe() + pipe.fileHandleForWriting.write(Data(repeating: 1, count: 1_024)) + try pipe.fileHandleForWriting.close() + + let output = try ComposeProcessRunner.readAll( + from: pipe.fileHandleForReading, + maxBytes: 1_024 + ) + #expect(output.count == 1_024) + } + + @Test(.timeLimit(.minutes(1))) + func startupTimeoutKillsTheProcess() async throws { + let process = BlockingComposeProcess() + let stdout = Pipe() + let stderr = Pipe() + try stdout.fileHandleForWriting.close() + try stderr.fileHandleForWriting.close() + + do { + _ = try await ComposeProcessRunner.capture( + process: process, + stdout: stdout, + stderr: stderr, + timeout: .milliseconds(20), + maxOutputBytes: 1_024 + ) + Issue.record("timed out capture unexpectedly succeeded") + } catch let error as ContainerizationError { + #expect(error.isCode(.timeout)) + } + #expect(await process.recordedSignals() == [SIGKILL]) + } + + @Test(.timeLimit(.minutes(1))) + func cancellationEscalatesWhenBuildIgnoresTermination() async throws { + let task = Task { + try await ComposeMachineImageBuilder.CommandRunner().run( + executable: URL(fileURLWithPath: "/bin/sh"), + arguments: ["-c", "trap '' TERM; while :; do :; done"], + log: Logger(label: "compose-process-test") + ) + } + try await Task.sleep(for: .milliseconds(200)) + task.cancel() + + do { + try await task.value + Issue.record("cancelled build unexpectedly succeeded") + } catch is CancellationError { + // Expected. + } + } +} + +private actor MockClientProcess: ClientProcess { + nonisolated let id = "mock" + private let onKill: @Sendable () -> Void + private var signals = [Int32]() + + init(onKill: @escaping @Sendable () -> Void = {}) { + self.onKill = onKill + } + + func start() async throws {} + + func resize(_ size: Terminal.Size) async throws {} + + func kill(_ signal: Int32) async throws { + signals.append(signal) + onKill() + } + + func wait() async throws -> Int32 { 0 } + + func recordedSignals() -> [Int32] { signals } +} + +private actor BlockingComposeProcess: ClientProcess { + nonisolated let id = "blocking" + private var startContinuation: CheckedContinuation? + private var signals = [Int32]() + + func start() async throws { + await withCheckedContinuation { continuation in + startContinuation = continuation + } + } + + func resize(_ size: Terminal.Size) async throws {} + + func kill(_ signal: Int32) async throws { + signals.append(signal) + startContinuation?.resume() + startContinuation = nil + } + + func wait() async throws -> Int32 { 0 } + + func recordedSignals() -> [Int32] { signals } +} diff --git a/Tests/ComposePluginTests/MachineConfigurationTests.swift b/Tests/ComposePluginTests/MachineConfigurationTests.swift new file mode 100644 index 000000000..6ae60a68d --- /dev/null +++ b/Tests/ComposePluginTests/MachineConfigurationTests.swift @@ -0,0 +1,213 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerVersion +import ContainerizationError +import ContainerizationOCI +import Foundation +import MachineAPIClient +import SystemPackage +import Testing + +@testable import ContainerCompose + +struct MachineConfigurationTests { + @Test + func composeConfigurationUsesDaemonRoots() throws { + let health = try JSONDecoder().decode( + SystemHealth.self, + from: Data(#"{"appRoot":"/tmp/app","installRoot":"/tmp/install","logRoot":null,"apiServerVersion":"test","apiServerCommit":"test","apiServerBuild":"debug","apiServerAppName":"test"}"#.utf8) + ) + let files = ComposeMachineManager.configurationFiles(for: health) + #expect(files.map(\.string) == [ + "/tmp/app/config/config.toml", + "/tmp/install/etc/container/config.toml", + ]) + } + + @Test + func idleShutdownSecondsDecodeFromConfiguration() throws { + let data = Data(#"{"idle-shutdown-seconds":600}"#.utf8) + let configuration = try JSONDecoder().decode(ComposeConfiguration.self, from: data) + #expect(configuration.idleShutdownSeconds == 600) + } + + @Test + func defaultComposeImageIsAccepted() { + #expect(ComposeConfiguration.defaultImage == "container-compose-machine:local") + #expect(ComposeConfiguration.isValidImage(ComposeConfiguration.defaultImage)) + } + + @Test + func customComposeImageReferenceIsAccepted() { + #expect(ComposeConfiguration.isValidImage("registry.example/compose-machine:dev")) + #expect(ComposeConfiguration.isValidImage("registry.example/compose-machine@sha256:\(String(repeating: "a", count: 64))")) + #expect(!ComposeConfiguration.isValidImage("not a reference")) + } + + @Test + func emptyComposeImageIsRejected() { + #expect(!ComposeConfiguration.isValidImage("")) + } + + @Test + func installedImageResourcesUseSiblingPluginResources() throws { + let root = URL(fileURLWithPath: "/tmp/compose-plugin") + let executable = FilePath(root.appendingPathComponent("bin/compose").path) + let resources = root.appendingPathComponent("resources") + try FileManager.default.createDirectory(at: resources, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + try Data().write(to: resources.appendingPathComponent("Containerfile")) + try Data().write(to: resources.appendingPathComponent("container-compose-idle-shutdown")) + try Data().write(to: resources.appendingPathComponent("container-compose-idle-shutdown.service")) + + let found = try ComposeMachineImageResources.locate( + executablePath: executable, + mainResourceURL: nil, + moduleResourceURL: nil + ) + #expect(found.directory.path == resources.path) + } + + @Test + func missingImageResourceReportsRequiredFiles() throws { + let root = URL(fileURLWithPath: "/tmp/compose-missing-resources") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + try Data().write(to: root.appendingPathComponent("Containerfile")) + + #expect(throws: ContainerizationError.self) { + try ComposeMachineImageResources.locate( + executablePath: FilePath("/unrelated/compose"), + mainResourceURL: nil, + moduleResourceURL: root + ) + } + } + + @Test + func moduleResourcesCanBeInjectedForResolutionTests() throws { + let root = URL(fileURLWithPath: "/tmp/compose-module") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + try Data().write(to: root.appendingPathComponent("Containerfile")) + try Data().write(to: root.appendingPathComponent("container-compose-idle-shutdown")) + try Data().write(to: root.appendingPathComponent("container-compose-idle-shutdown.service")) + + let found = try ComposeMachineImageResources.locate( + executablePath: FilePath("/unrelated/compose"), + mainResourceURL: nil, + moduleResourceURL: root + ) + #expect(found.directory.path == root.path) + } + + @Test + func backingContainerTokenRoundTrips() throws { + let root = URL(fileURLWithPath: "/tmp/compose-machine-token") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let token = UUID().uuidString.lowercased() + try token.write( + toFile: root.appendingPathComponent(MachineBundle.backingContainerTokenFile.string).path, + atomically: true, + encoding: .utf8 + ) + #expect(try MachineBundle(path: FilePath(root.path)).backingContainerToken() == token) + } + + @Test + func missingBackingContainerTokenIsNotMigrated() throws { + let root = URL(fileURLWithPath: "/tmp/compose-machine-token-missing") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + #expect(throws: ContainerizationError.self) { + _ = try MachineBundle(path: FilePath(root.path)).backingContainerToken() + } + #expect( + !FileManager.default.fileExists( + atPath: root.appendingPathComponent(MachineBundle.backingContainerTokenFile.string).path + )) + } + + @Test + func missingBackingContainerTokenCanBeAbsentForLegacyMachines() throws { + let root = URL(fileURLWithPath: "/tmp/legacy-machine-token-missing") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + #expect(try MachineBundle(path: FilePath(root.path)).backingContainerTokenIfPresent() == nil) + } + + @Test + func composeOwnershipMarkerRoundTrips() throws { + let image = ImageDescription( + reference: "container-compose-machine:local", + descriptor: .init( + mediaType: "application/vnd.oci.image.manifest.v1+json", + digest: "sha256:" + String(repeating: "0", count: 64), + size: 0 + ) + ) + let configuration = try MachineConfiguration( + id: "compose", + image: image, + platform: .current, + userSetup: UserSetup(username: "tester", uid: 501, gid: 20), + managedBy: "compose" + ) + + let decoded = try JSONDecoder().decode( + MachineConfiguration.self, + from: JSONEncoder().encode(configuration) + ) + #expect(decoded.managedBy == "compose") + } + + @Test + func legacyMachineConfigurationHasNoOwner() throws { + let image = ImageDescription( + reference: "alpine:latest", + descriptor: .init( + mediaType: "application/vnd.oci.image.manifest.v1+json", + digest: "sha256:" + String(repeating: "1", count: 64), + size: 0 + ) + ) + let configuration = try MachineConfiguration( + id: "legacy", + image: image, + platform: .current, + userSetup: UserSetup(username: "tester", uid: 501, gid: 20) + ) + let object = try #require( + try JSONSerialization.jsonObject( + with: JSONEncoder().encode(configuration) + ) as? [String: Any] + ) + var legacyObject = object + legacyObject.removeValue(forKey: "managedBy") + let decoded = try JSONDecoder().decode( + MachineConfiguration.self, + from: JSONSerialization.data(withJSONObject: legacyObject) + ) + #expect(decoded.managedBy == nil) + } +} diff --git a/Tests/ContainerAPIClientTests/HostDNSResolverTest.swift b/Tests/ContainerAPIClientTests/HostDNSResolverTest.swift index 4f955172a..1efc8a11c 100644 --- a/Tests/ContainerAPIClientTests/HostDNSResolverTest.swift +++ b/Tests/ContainerAPIClientTests/HostDNSResolverTest.swift @@ -55,6 +55,54 @@ struct HostDNSResolverTest { #expect(domains.map { $0.pqdn } == ["bar.foo", "foo.bar"]) } + @Test + func wildcardMachineAliasResolvesToItsBaseHostname() { + #expect( + HostDNSResolver.wildcardBaseHostname( + for: "Nixstasis.Compose.Machine.", + baseHostname: "compose.machine" + ) == "compose.machine." + ) + } + + @Test + func wildcardMachineAliasRequiresExactlyOneLabel() { + #expect( + HostDNSResolver.wildcardBaseHostname( + for: "compose.machine", + baseHostname: "compose.machine" + ) == nil + ) + #expect( + HostDNSResolver.wildcardBaseHostname( + for: "team.nixstasis.compose.machine", + baseHostname: "compose.machine" + ) == nil + ) + #expect( + HostDNSResolver.wildcardBaseHostname( + for: "nixstasis.other.machine", + baseHostname: "compose.machine" + ) == nil + ) + } + + @Test + func wildcardMachineAliasRejectsInvalidLabels() { + #expect( + HostDNSResolver.wildcardBaseHostname( + for: "-nixstasis.compose.machine", + baseHostname: "compose.machine" + ) == nil + ) + #expect( + HostDNSResolver.wildcardBaseHostname( + for: "nixstasis.compose.machine.example", + baseHostname: "compose.machine" + ) == nil + ) + } + @Test func testHostDNSCreateAlreadyExists() async throws { let fm = FileManager.default diff --git a/Tests/ContainerAPIClientTests/ParserTest.swift b/Tests/ContainerAPIClientTests/ParserTest.swift index dbb3aa3c0..831d7e802 100644 --- a/Tests/ContainerAPIClientTests/ParserTest.swift +++ b/Tests/ContainerAPIClientTests/ParserTest.swift @@ -14,6 +14,7 @@ // limitations under the License. //===----------------------------------------------------------------------===// +import ContainerResource import Containerization import ContainerizationError import ContainerizationExtras @@ -1498,6 +1499,19 @@ struct ParserTest { // MARK: - Collection capacity hints + @Test("machine ownership labels cannot be supplied by users") + func testLabelsRejectMachineOwnershipMetadata() { + for label in [ + "\(ResourceLabelKeys.machineID)=compose", + "\(ResourceLabelKeys.machineToken)=token", + "\(ResourceLabelKeys.plugin)=machine", + ] { + #expect(throws: ContainerizationError.self) { + _ = try Parser.labels([label]) + } + } + } + @Test("labels with large input preserves all entries") func testLabelsLargeInput() throws { let labels = (0..<100).map { "key\($0)=value\($0)" } diff --git a/Tests/ContainerAPIClientTests/ProcessCancellationTests.swift b/Tests/ContainerAPIClientTests/ProcessCancellationTests.swift new file mode 100644 index 000000000..a873419a3 --- /dev/null +++ b/Tests/ContainerAPIClientTests/ProcessCancellationTests.swift @@ -0,0 +1,134 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationOS +import Darwin +import Logging +import Testing + +@testable import ContainerAPIClient + +@Suite("Process cancellation") +struct ProcessCancellationTests { + @Test + func cancellationBeforeStartupAcknowledgementEscalatesOnce() async throws { + let process = CancellationTestProcess() + let cancellation = ProcessCancellationController( + process: process, + gracePeriod: .milliseconds(10) + ) + + cancellation.cancel() + cancellation.cancel() + for _ in 0..<20 where await process.recordedSignals().count < 2 { + try await Task.sleep(for: .milliseconds(10)) + } + #expect(await process.recordedSignals() == [SIGTERM, SIGKILL]) + } + + @Test + func signalDuringStartupIsForwarded() async throws { + let process = BlockingStartProcess() + let io = try ProcessIO.create(tty: false, interactive: false, detach: true) + let task = Task { + try await io.handleProcess( + process: process, + log: Logger(label: "process-signal-test") + ) + } + + for _ in 0..<20 where !(await process.startEntered) { + try await Task.sleep(for: .milliseconds(10)) + } + guard await process.startEntered else { + task.cancel() + _ = try? await task.value + Issue.record("process startup did not begin") + return + } + raise(SIGUSR2) + await process.releaseStart() + + for _ in 0..<20 where await process.recordedSignals().isEmpty { + try await Task.sleep(for: .milliseconds(10)) + } + let signals = await process.recordedSignals() + task.cancel() + _ = try? await task.value + #expect(signals.first == SIGUSR2) + } +} + +private actor CancellationTestProcess: ClientProcess { + nonisolated let id = "mock" + private var signals = [Int32]() + + func start() async throws {} + + func resize(_ size: Terminal.Size) async throws {} + + func kill(_ signal: Int32) async throws { + signals.append(signal) + } + + func wait() async throws -> Int32 { 0 } + + func recordedSignals() -> [Int32] { signals } +} + +private actor BlockingStartProcess: ClientProcess { + nonisolated let id = "blocking-start" + private(set) var startEntered = false + private var startContinuation: CheckedContinuation? + private var signals = [Int32]() + private var exitCode: Int32? + private var waitContinuation: CheckedContinuation? + + func start() async throws { + startEntered = true + guard exitCode == nil else { return } + await withCheckedContinuation { continuation in + startContinuation = continuation + } + } + + func releaseStart() { + startContinuation?.resume() + startContinuation = nil + } + + func resize(_ size: Terminal.Size) async throws {} + + func kill(_ signal: Int32) async throws { + signals.append(signal) + exitCode = 0 + startContinuation?.resume() + startContinuation = nil + waitContinuation?.resume(returning: 0) + waitContinuation = nil + } + + func wait() async throws -> Int32 { + if let exitCode { + return exitCode + } + return await withCheckedContinuation { continuation in + waitContinuation = continuation + } + } + + func recordedSignals() -> [Int32] { signals } +} diff --git a/Tests/ContainerCommandsTests/HelpCommandTests.swift b/Tests/ContainerCommandsTests/HelpCommandTests.swift index 0e5c1c532..7e397b200 100644 --- a/Tests/ContainerCommandsTests/HelpCommandTests.swift +++ b/Tests/ContainerCommandsTests/HelpCommandTests.swift @@ -51,6 +51,11 @@ struct HelpCommandTests { walk(Application.self, path: []) } + @Test + func machineStartIsReachableViaHelp() { + #expect(HelpCommand.resolveSubcommand(path: ["machine", "start"]) != nil) + } + @Test func unknownSubcommandReturnsNil() { let unknownResolved = HelpCommand.resolveSubcommand(path: ["nonexistent"]) == nil diff --git a/Tests/ContainerPersistenceTests/MachineConfigTests.swift b/Tests/ContainerPersistenceTests/MachineConfigTests.swift index 8ea6e5155..1f2a131bc 100644 --- a/Tests/ContainerPersistenceTests/MachineConfigTests.swift +++ b/Tests/ContainerPersistenceTests/MachineConfigTests.swift @@ -25,6 +25,27 @@ struct MachineConfigTests { let config = MachineConfig.default #expect(config.virtualization == false) #expect(config.kernelPath == nil) + #expect(config.runtimeProfile == .standard) + #expect(config.dockerSocketPath == nil) + } + + @Test func nestedDockerProfileRoundTrips() throws { + let config = try MachineConfig( + cpus: 4, + memory: try MemorySize("4gb"), + homeMount: .rw, + virtualization: false, + kernelPath: nil, + runtimeProfile: .nestedDocker, + dockerSocketPath: FilePath("/Users/me/.local/run/docker.socket") + ) + + let decoded = try JSONDecoder().decode( + MachineConfig.self, + from: JSONEncoder().encode(config) + ) + #expect(decoded.runtimeProfile == .nestedDocker) + #expect(decoded.dockerSocketPath == FilePath("/Users/me/.local/run/docker.socket")) } @Test func withSetsVirtualizationTrue() throws { @@ -67,13 +88,16 @@ struct MachineConfigTests { } @Test func decodingMissingFieldsUsesDefaults() throws { - // Older boot-config.json files predate virtualization/kernel — they must still load. + // Older boot-config.json files predate virtualization/kernel and the + // Compose runtime fields; they must still load. let legacy = #"{"cpus":4,"memory":"1gb","homeMount":"rw"}"# let data = Data(legacy.utf8) let decoded = try JSONDecoder().decode(MachineConfig.self, from: data) #expect(decoded.cpus == 4) #expect(decoded.virtualization == false) #expect(decoded.kernelPath == nil) + #expect(decoded.runtimeProfile == .standard) + #expect(decoded.dockerSocketPath == nil) } @Test func roundTripJSON() throws { @@ -91,5 +115,7 @@ struct MachineConfigTests { let keys = MachineConfig.settableKeys.map(\.key) #expect(keys.contains("virtualization")) #expect(keys.contains("kernel")) + #expect(!keys.contains("runtime-profile")) + #expect(!keys.contains("docker-socket-path")) } } diff --git a/Tests/ContainerResourceTests/ContainerListFiltersTests.swift b/Tests/ContainerResourceTests/ContainerListFiltersTests.swift new file mode 100644 index 000000000..d2af74ba0 --- /dev/null +++ b/Tests/ContainerResourceTests/ContainerListFiltersTests.swift @@ -0,0 +1,39 @@ +//===----------------------------------------------------------------------===// +// 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 Testing + +@testable import ContainerResource + +struct ContainerListFiltersTests { + @Test + func exactEscapesRegularExpressionCharacters() { + #expect(ContainerListFilters.exact("machine+owner") == "^(?:machine\\+owner)$") + } + + @Test + func machineFilterIsAnchored() { + #expect( + ContainerListFilters.machines().labels[ResourceLabelKeys.plugin] + == "^(?:machine)$" + ) + } + + @Test + func excludeEscapesRegularExpressionCharacters() { + #expect(ContainerListFilters.exclude("machine+") == "^(?!machine\\+$)") + } +} diff --git a/Tests/ContainerXPCTests/XPCClientTests.swift b/Tests/ContainerXPCTests/XPCClientTests.swift new file mode 100644 index 000000000..111b1e4ca --- /dev/null +++ b/Tests/ContainerXPCTests/XPCClientTests.swift @@ -0,0 +1,93 @@ +//===----------------------------------------------------------------------===// +// 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 Darwin +import Foundation +import Testing + +@testable import ContainerXPC + +@Suite("XPC client lifecycle") +struct XPCClientTests { + @Test(.timeLimit(.minutes(1))) + func cancelledReplyWaitDoesNotWaitForCallback() async { + let task = Task { + try await XPCClient.waitForReply(service: "test", route: "never") { _ in } + } + try? await Task.sleep(for: .milliseconds(10)) + task.cancel() + + await #expect(throws: CancellationError.self) { + try await task.value + } + } + + @Test(.timeLimit(.minutes(1))) + func replyTimeoutDoesNotWaitForCallback() async { + await #expect(throws: ContainerizationError.self) { + try await XPCClient.waitForReply( + responseTimeout: .milliseconds(10), + service: "test", + route: "never" + ) { _ in } + } + } + + @Test + func transferringFileHandleClosesItsOwnerExactlyOnce() throws { + let (handle, descriptor) = ownedHandle(minimumDescriptor: 500) + let message = XPCMessage(route: "test") + + try message.setFileHandle(key: "fd", value: handle) + installReplacement(at: descriptor) + defer { Darwin.close(descriptor) } + + try? handle.close() + #expect(Darwin.fcntl(descriptor, F_GETFD) >= 0) + } + + @Test + func transferringFileHandleArrayClosesOwnersExactlyOnce() throws { + let (first, firstDescriptor) = ownedHandle(minimumDescriptor: 500) + let (second, _) = ownedHandle(minimumDescriptor: 501) + let message = XPCMessage(route: "test") + + try message.set(key: "fds", value: [first, second]) + installReplacement(at: firstDescriptor) + defer { Darwin.close(firstDescriptor) } + + try? first.close() + try? second.close() + #expect(Darwin.fcntl(firstDescriptor, F_GETFD) >= 0) + } + + private func ownedHandle(minimumDescriptor: Int32) -> (FileHandle, Int32) { + let source = Darwin.open("/dev/null", O_WRONLY | O_CLOEXEC) + #expect(source >= 0) + defer { Darwin.close(source) } + let descriptor = Darwin.fcntl(source, F_DUPFD_CLOEXEC, minimumDescriptor) + #expect(descriptor >= minimumDescriptor) + return (FileHandle(fileDescriptor: descriptor, closeOnDealloc: true), descriptor) + } + + private func installReplacement(at descriptor: Int32) { + let source = Darwin.open("/dev/null", O_WRONLY | O_CLOEXEC) + #expect(source >= 0) + defer { Darwin.close(source) } + #expect(Darwin.dup2(source, descriptor) == descriptor) + } +} diff --git a/Tests/IntegrationTests/Machine/TestCLIMachineRuntimeSerial.swift b/Tests/IntegrationTests/Machine/TestCLIMachineRuntimeSerial.swift index 6bd01cefd..678376d2e 100644 --- a/Tests/IntegrationTests/Machine/TestCLIMachineRuntimeSerial.swift +++ b/Tests/IntegrationTests/Machine/TestCLIMachineRuntimeSerial.swift @@ -47,6 +47,39 @@ struct TestCLIMachineRuntimeSerial { } } + @Test func testStartStoppedMachine() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage) + try f.doMachineBoot(name: name) + try f.doMachineStop(name: name) + + try f.runMachine(["start", name]).check() + + let snapshot = try f.doMachineInspect(name: name) + #expect(snapshot.status == "running") + #expect(snapshot.startedDate != nil) + } + } + + @Test func testStartNewMachineInitializes() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage) + + let before = try f.doMachineInspect(name: name) + #expect(before.status == "stopped") + + try f.runMachine(["start", name]).check() + + let after = try f.doMachineInspect(name: name) + #expect(after.status == "running") + #expect(after.startedDate != nil) + } + } + @Test func testStopIdempotent() async throws { try await ContainerFixture.with { f in let name = "\(f.testID)-machine" diff --git a/Tests/MachineAPIServiceTests/MachineOwnershipTests.swift b/Tests/MachineAPIServiceTests/MachineOwnershipTests.swift new file mode 100644 index 000000000..3d4c90baa --- /dev/null +++ b/Tests/MachineAPIServiceTests/MachineOwnershipTests.swift @@ -0,0 +1,57 @@ +//===----------------------------------------------------------------------===// +// 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 Testing + +@testable import MachineAPIService + +struct MachineOwnershipTests { + @Test + func identifiesLegacyBackingContainer() { + #expect( + MachinesService.isLegacyBackingContainer( + id: "legacy-ab12cd", + labels: [ + ResourceLabelKeys.plugin: "machine", + ResourceLabelKeys.machineID: "legacy", + ], + machineID: "legacy" + ) + ) + } + + @Test + func rejectsUnrelatedContainer() { + #expect( + !MachinesService.isLegacyBackingContainer( + id: "legacy-ab12cd", + labels: [ + ResourceLabelKeys.plugin: "machine", + ResourceLabelKeys.machineID: "other", + ], + machineID: "legacy" + ) + ) + #expect( + !MachinesService.isLegacyBackingContainer( + id: "legacy-ab12cd", + labels: [ResourceLabelKeys.machineID: "legacy"], + machineID: "legacy" + ) + ) + } +} diff --git a/Tests/MachineAPIServiceTests/PublishedSocketTests.swift b/Tests/MachineAPIServiceTests/PublishedSocketTests.swift new file mode 100644 index 000000000..08955a0f0 --- /dev/null +++ b/Tests/MachineAPIServiceTests/PublishedSocketTests.swift @@ -0,0 +1,218 @@ +//===----------------------------------------------------------------------===// +// 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 Darwin +import Foundation +import Logging +import SystemPackage +import Testing + +@testable import MachineAPIService + +@Suite("Published machine sockets") +struct PublishedSocketTests { + @Test + func staleSocketIsRemoved() throws { + try withTemporarySocketPath { path in + let socket = try boundSocket(at: path) + Darwin.close(socket) + + try preparePublishedSocket(path: path) + + #expect(!FileManager.default.fileExists(atPath: path.string)) + } + } + + @Test + func activeSocketIsPreserved() throws { + try withTemporarySocketPath { path in + let socket = try boundSocket(at: path) + defer { Darwin.close(socket) } + #expect(Darwin.listen(socket, 1) == 0) + + #expect(throws: Error.self) { + try preparePublishedSocket(path: path) + } + #expect(FileManager.default.fileExists(atPath: path.string)) + } + } + + @Test + func nonSocketAndSymlinkArePreserved() throws { + try withTemporarySocketPath { path in + try Data().write(to: URL(filePath: path.string)) + #expect(throws: Error.self) { + try preparePublishedSocket(path: path) + } + #expect(FileManager.default.fileExists(atPath: path.string)) + } + + try withTemporarySocketPath { path in + #expect(symlink("/tmp", path.string) == 0) + #expect(throws: Error.self) { + try preparePublishedSocket(path: path) + } + var stat = Darwin.stat() + #expect(lstat(path.string, &stat) == 0) + #expect((stat.st_mode & S_IFMT) == S_IFLNK) + } + } + + @Test + func cleanupRemovesOnlyStaleSockets() throws { + let log = Logger(label: "published-socket-test") + try withTemporarySocketPath { path in + let socket = try boundSocket(at: path) + Darwin.close(socket) + + cleanupPublishedSocket(path: path, log: log) + + #expect(!FileManager.default.fileExists(atPath: path.string)) + } + + try withTemporarySocketPath { path in + let socket = try boundSocket(at: path) + defer { Darwin.close(socket) } + #expect(Darwin.listen(socket, 1) == 0) + + cleanupPublishedSocket(path: path, log: log) + + #expect(FileManager.default.fileExists(atPath: path.string)) + } + } + + @Test + func replacementBeforeClaimIsRestored() throws { + try withTemporarySocketPath { path in + let staleSocket = try boundSocket(at: path) + defer { Darwin.close(staleSocket) } + let parentFD = try openSecureDirectory(path.removingLastComponent(), create: false) + defer { Darwin.close(parentFD) } + let name = try #require(path.lastComponent?.string) + var replacementSocket: Int32 = -1 + + let result = try removeStalePublishedSocket( + path: path, + parentFD: parentFD, + name: name, + beforeClaim: { + #expect(Darwin.unlink(path.string) == 0) + replacementSocket = try boundSocket(at: path) + #expect(Darwin.listen(replacementSocket, 1) == 0) + } + ) + defer { if replacementSocket >= 0 { Darwin.close(replacementSocket) } } + + guard case .changed = result else { + Issue.record("replacement was not reported as changed") + return + } + #expect(isSocketListening(at: path)) + } + } + + @Test + func staleSocketNearPathLimitIsRemoved() throws { + let socketName = "docker.sock" + let base = "/private/tmp/" + let pathLimit = MemoryLayout.size(ofValue: sockaddr_un().sun_path) - 1 + let uniquePrefix = String(UUID().uuidString.prefix(8)) + let directoryName = + uniquePrefix + + String( + repeating: "a", + count: pathLimit - base.utf8.count - socketName.utf8.count - uniquePrefix.utf8.count - 1 + ) + let directory = FilePath(base + directoryName) + let path = directory.appending(socketName) + try FileManager.default.createDirectory(atPath: directory.string, withIntermediateDirectories: false) + defer { try? FileManager.default.removeItem(atPath: directory.string) } + let socket = try boundSocket(at: path) + Darwin.close(socket) + + try preparePublishedSocket(path: path) + + #expect(!FileManager.default.fileExists(atPath: path.string)) + } + + @Test + func concurrentReplacementIsNeverRemoved() throws { + try withTemporarySocketPath { path in + let claimedSocket = try boundSocket(at: path) + defer { Darwin.close(claimedSocket) } + let parentFD = try openSecureDirectory(path.removingLastComponent(), create: false) + defer { Darwin.close(parentFD) } + let name = try #require(path.lastComponent?.string) + var replacementSocket: Int32 = -1 + + do { + _ = try removeStalePublishedSocket( + path: path, + parentFD: parentFD, + name: name, + afterClaim: { + #expect(Darwin.listen(claimedSocket, 1) == 0) + replacementSocket = try boundSocket(at: path) + #expect(Darwin.listen(replacementSocket, 1) == 0) + } + ) + Issue.record("concurrent replacement unexpectedly restored the claimed socket") + } catch { + #expect(String(describing: error).contains("remains quarantined")) + } + defer { if replacementSocket >= 0 { Darwin.close(replacementSocket) } } + + #expect(isSocketListening(at: path)) + } + } + + private func withTemporarySocketPath(_ body: (FilePath) throws -> Void) throws { + let directory = FilePath("/private/tmp/cs-\(UUID().uuidString.prefix(8))") + try FileManager.default.createDirectory(atPath: directory.string, withIntermediateDirectories: false) + defer { try? FileManager.default.removeItem(atPath: directory.string) } + try body(directory.appending("docker.sock")) + } + + private func boundSocket(at path: FilePath) throws -> Int32 { + let descriptor = Darwin.socket(AF_UNIX, SOCK_STREAM, 0) + guard descriptor >= 0 else { throw POSIXError.fromErrno() } + + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + let bytes = Array(path.string.utf8) + guard bytes.count < MemoryLayout.size(ofValue: address.sun_path) else { + Darwin.close(descriptor) + throw POSIXError(.ENAMETOOLONG) + } + withUnsafeMutableBytes(of: &address.sun_path) { destination in + destination.initializeMemory(as: UInt8.self, repeating: 0) + destination.copyBytes(from: bytes) + } + address.sun_len = UInt8(MemoryLayout.size + MemoryLayout.size + bytes.count + 1) + + let result = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.bind(descriptor, $0, socklen_t(MemoryLayout.size)) + } + } + guard result == 0 else { + let error = POSIXError.fromErrno() + Darwin.close(descriptor) + throw error + } + return descriptor + } +} diff --git a/docs/command-reference.md b/docs/command-reference.md index d75ab5970..6a45a13f1 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -1272,6 +1272,24 @@ container machine logs [--boot] [--follow] [-n ] [--debug] [] * `-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 start` + +Starts a stopped container machine. Uses the default container machine if no ID is given. + +**Usage** + +```bash +container machine start [--debug] [] +``` + +**Arguments** + +* ``: Container machine ID (uses default if not specified) + +**Options** + +No options. + ### `container machine stop` Stops a running container machine. Uses the default container machine if no ID is given. @@ -1308,6 +1326,75 @@ container machine delete [--debug] No options. +## Docker Compose Management + +`container compose` runs Docker Compose in one persistent container machine named +`compose`. All Compose projects share this machine and its nested Docker daemon. +Compose files and bind mounts must be under the host home directory. For an +overview, quickstart, configuration, and differences from Docker Desktop, see +the [Container Compose guide](./container-compose.md). + +### `container compose` + +Creates or boots the `compose` machine and runs Docker Compose. The first +invocation builds the bundled machine image locally if it is not already in the +host image store. Later invocations reuse the machine and its Docker data. + +**Usage** + +```bash +container compose [--completions ] [--socket-path] [ [ ...]] +``` + +**Options** + +* `--completions `: Print static completion data without starting the Compose machine +* `--socket-path`: Print the Docker socket endpoint without starting the Compose machine; start the machine separately before using it +* `--version`: Print the Compose plugin version +* `-h, --help`: Print Docker Compose help + +**Examples** + +```bash +# start a Compose application +container compose up -d + +# list services and follow application logs +container compose ps +container compose logs -f web + +# create or start the machine, then connect a Docker-compatible client +# `--socket-path` does not start a stopped machine +container compose version +export DOCKER_HOST="$(container compose --socket-path)" +docker version + +# after `container machine stop compose` +container machine start compose +docker ps +``` + +The `--completions` and `--socket-path` options cannot be combined with each +other or with Compose arguments. `container compose down` removes resources for +the selected Compose project but does not stop or delete the machine. + +Published ports are reachable through the Compose machine's IP address. Install +the `machine` DNS domain to use `compose.machine`: + +```bash +sudo container system dns create machine +``` + +Idle shutdown is disabled by default. Set `idle-shutdown-seconds` under +`[plugin.compose]` in `~/.config/container/config.toml` to a positive number of +seconds. Active Docker operations prevent shutdown. Use the machine commands to +stop or delete the Compose machine: + +```bash +container machine stop compose +container machine delete compose +``` + ## System Management System commands manage the container apiserver, logs, DNS settings and kernel. These are only available on macOS hosts. diff --git a/docs/container-compose.md b/docs/container-compose.md new file mode 100644 index 000000000..fcab0afd9 --- /dev/null +++ b/docs/container-compose.md @@ -0,0 +1,261 @@ +# Container Compose + +`container compose` runs the Docker Compose CLI inside a persistent Linux +container machine. It provides a Docker-compatible Compose workflow while +keeping the Docker daemon, images, containers, networks, and volumes inside an +isolated machine managed by `container`. + +## Overview + +The Compose plugin runs the real `/usr/bin/docker compose` command inside one +persistent machine named `compose`. It does not parse Compose files or +translate Compose requests into Apple Container API calls. + +All Compose projects share this machine and its nested Docker daemon: + +```text +compose machine +└── Docker Engine + ├── Compose project A + ├── Compose project B + └── Compose project C +``` + +Compose project isolation follows Docker Compose's normal project-name rules. +Use `-p`, `COMPOSE_PROJECT_NAME`, or a top-level `name` when projects need +stable, distinct names. Dedicated Compose machines per namespace are a deferred +future enhancement. + +The machine uses a read-write, same-path home mount. A working directory below +macOS `$HOME` is visible at the same path inside the machine, so Compose files +and bind mounts under the home directory do not need to be copied. Bind sources +outside the mounted home are unsupported. + +The machine's Docker data persists across stop and boot. `container compose +down` removes only the selected Compose project's resources; it does not stop +or delete the machine. + +## Quickstart + +Start the container system if it is not already running: + +```bash +container system start +``` + +The first Compose command automatically builds the bundled Compose machine +image locally when the default image is not already in the host image store. +This may take a few minutes on first use. From a directory under `$HOME` +containing `compose.yaml` or `docker-compose.yml`, run the normal Compose +commands: + +```bash +container compose up -d +container compose ps +container compose logs -f +container compose down +``` + +The first normal invocation creates or boots the persistent `compose` machine, +waits for Docker Engine, and forwards the Compose arguments unchanged. Later +invocations reuse the same machine. + +### Accessing services + +The Compose machine address is reported when the machine becomes ready. If the +`machine` DNS domain is installed, the machine is also available as +`compose.machine`: + +```bash +sudo container system dns create machine +``` + +Published service ports are exposed through the machine's address. Nested +Compose ports are not automatically published on macOS's loopback interface. +One-label project aliases such as `nixstasis.compose.machine` resolve to the +same machine address when the `machine` DNS domain is configured; the Compose +stack must provide the matching host-based ingress. + +### Using Docker-compatible clients + +The plugin can expose the nested Docker daemon through a per-user Unix socket. +`--socket-path` only prints the endpoint; it does not start the machine. On first +use, run a normal Compose command to create and boot the machine before requesting +the endpoint: + +```bash +container compose version +export DOCKER_HOST="$(container compose --socket-path)" +docker version +docker ps +``` + +If the machine was stopped, start it before using the exported endpoint: + +```bash +container machine start compose +DOCKER_HOST="$(container compose --socket-path)" docker ps +``` + +The endpoint is mode `0600` and grants Docker-root-equivalent access to the +Compose machine. Do not make it group- or world-writable. + +### Managing the machine + +The Compose machine is an ordinary persistent container machine with a Compose +ownership marker. Its boot resources can be changed with `container machine set`: + +```bash +container machine set -n compose cpus=8 memory=8gb +container machine stop compose +container compose ps +``` + +Resource changes take effect on the next boot and affect every Compose project +using the machine. To release resources while retaining Docker data, stop it: + +```bash +container machine stop compose +``` + +To remove the machine and its persistent Docker data: + +```bash +container machine delete compose +``` + +Machine deletion is destructive and removes all Compose projects, images, +volumes, networks, and build cache stored in that machine. + +Idle shutdown is disabled by default. Configure `idle-shutdown-seconds` in +`[plugin.compose]` to a positive number of seconds. Active Docker client +operations, including builds, pulls, and pushes, reset the idle timer. + +## Differences from Docker Desktop's compose + +`container compose` is closer to running Docker using [Lima](https://github.com/lima-vm/lima) +than to Docker Desktop's integrated Compose experience. In both approaches, +Docker runs inside a Linux virtual machine and the host CLI talks to that +Docker daemon. `container` uses its own persistent container machine and the +nested Docker Engine image supplied by the Compose plugin. + +### Docker daemon location + +Docker Desktop manages a Linux VM and Docker installation as part of its desktop +application. With `container compose`, Docker Engine runs inside the persistent +`compose` machine. The machine is controlled with `container machine` commands +and remains independent of Docker Desktop. + +### Resource and lifecycle management + +Docker Desktop commonly manages one application-level VM and its lifecycle. +Here, the Compose machine is explicit and persistent: + +```bash +container machine inspect compose +container machine set -n compose cpus=8 memory=8gb +container machine stop compose +container machine delete compose +``` + +`container compose down` does not stop the machine. Multiple Compose projects +share the machine unless a future namespace feature is added. + +### Filesystem and bind mounts + +The host home directory is mounted at the same path inside the machine. Compose +working directories and bind sources must be below `$HOME`; arbitrary paths +outside that mount are not available. This is similar to VM-based Docker +workflows such as Lima, rather than a daemon running directly on macOS. + +### Networking and published ports + +Compose services run inside the nested Docker daemon and its Linux networking +stack. Published ports are reachable through the Compose machine's IP address, +not automatically through `localhost` on macOS. Use the machine DNS name when +configured, or inspect the startup diagnostics for the address. + +### Images and build cache + +The Compose machine has its own Docker image store and BuildKit cache. Images +built or pulled by `container` itself, Apple Container images, Kubernetes +containerd images, and Compose Docker images are separate stores. Transfers +between stores must be explicit. + +### Docker socket access + +The optional socket returned by `container compose --socket-path` connects to +the nested Docker daemon. It is not Docker Desktop's socket and it does not +share Docker Desktop's containers, images, networks, or volumes. + +### Machine image and updates + +The bundled Compose machine image contains Docker Engine, BuildKit, Buildx, and +the Compose plugin. When the default `container-compose-machine:local` image +is absent, `container compose` builds it from the Containerfile shipped with +the installed plugin and stores it in the host `container` image store before +creating the machine. + +Updating the host `container` executable does not implicitly migrate or replace +an existing Compose machine image. + +### Custom machine images (testing only) + +Normal use requires no image configuration. `container compose` uses the +bundled `container-compose-machine:local` image and builds it automatically if +it is missing. + +To test a replacement image when creating a new Compose machine, set: + +```bash +CONTAINER_COMPOSE_MACHINE_IMAGE=my-compose-machine:dev container compose ps +``` + +This variable overrides the bundled image; it does not rebuild the bundled +image. It also does not replace an existing `compose` machine. Remove the +variable to return to the normal bundled-image workflow. + +### Configure the Compose machine + +Normal use needs no image setting. To configure the persistent Compose machine, +edit the user configuration file at `~/.config/container/config.toml`: + +```toml +[plugin.compose] +cpus = 4 +memory = "4gb" +idle-shutdown-seconds = 600 +``` + +- `cpus` sets the number of virtual CPUs. The default is `4`. +- `memory` sets the machine's RAM. The default is `"4gb"`. Values use binary + units such as `"2gb"`, `"8gb"`, or `"4096mb"`; see the + [`MemorySize` format](./container-system-config.md#memorysize-format). +- `idle-shutdown-seconds` optionally powers off the machine after that many + seconds with no running Docker containers. `0` disables idle shutdown. + +Restart the container service after editing this file so it reloads the +configuration: + +```bash +container system stop +container system start +``` + +These settings apply to the shared `compose` machine and therefore affect every +Compose project. CPU and memory settings are used when the machine is created; +the idle-shutdown setting is applied on the next Compose invocation. To resize +an existing machine, use `container machine set`: + +```bash +container machine set -n compose cpus=8 memory=8gb +container machine stop compose +container compose ps +``` + +## See also + +- [`container compose` command reference](./command-reference.md#container-compose) +- [`Container machine`](./container-machine.md) +- [Compose configuration reference](./container-system-config.md#plugincompose) +- [Lima](https://github.com/lima-vm/lima) diff --git a/docs/container-machine.md b/docs/container-machine.md index 81e36e562..43708b372 100644 --- a/docs/container-machine.md +++ b/docs/container-machine.md @@ -55,6 +55,7 @@ container machine run # operates on dev container machine ls # list all container machines container machine inspect dev # JSON detail for one container machine stop dev # stop the container machine +container machine start dev # start it again container machine rm dev # delete, including its persistent storage ``` diff --git a/docs/container-system-config.md b/docs/container-system-config.md index 8c0b4745d..db72b314b 100644 --- a/docs/container-system-config.md +++ b/docs/container-system-config.md @@ -136,6 +136,34 @@ Plugins can ship their own configuration schemas under `[plugin.]`, where `< |--------------------|----------|---------------------------------------------------------------------------------------------| | `` | varies | Schema is defined by the plugin. TODO: Add tutorial on setting plugin specific values. | +### `[plugin.compose]` + +The Compose plugin owns one persistent machine named `compose`. Normal use +selects the bundled `container-compose-machine:local` image and builds it +locally on first use when it is absent from the host image store. + +`CONTAINER_COMPOSE_MACHINE_IMAGE` is a testing-only environment variable. When +set, it replaces the bundled image for a newly created Compose machine. It does +not rebuild the bundled image and does not replace an existing machine. + +| Key | Type | Default | Description | +|----------|------------------------------|---------------------------------|-------------| +| `image` | `String` | — | Deprecated and ignored. Use `CONTAINER_COMPOSE_MACHINE_IMAGE` only when testing a custom image. | +| `cpus` | `Int` | `4` | CPUs allocated to the persistent Compose machine. | +| `memory` | [MemorySize](#memorysize-format) | `"4gb"` | Memory allocated to the persistent Compose machine. | +| `idle-shutdown-seconds` | `Int` | `0` | Shut down after this many seconds with no running Docker containers; `0` disables it. | + +Idle shutdown is opt-in. Set `idle-shutdown-seconds` to a positive number of +seconds to enable it. Restart the container service after editing this file so +it reloads the configuration (`container system stop && container system start`). +The systemd service resets its timer while Docker client commands such as builds, +pulls, and pushes are active. The setting is applied on the next Compose +invocation and stopping the machine preserves its Docker data. + +The plugin always uses a read-write same-path home mount and its nested-Docker +runtime profile. Existing machines are not automatically migrated or replaced +when this configuration changes; delete and recreate the machine explicitly. + ## Type formats ### MemorySize format