diff --git a/Makefile b/Makefile index 1855256d..ef90124b 100644 --- a/Makefile +++ b/Makefile @@ -147,7 +147,6 @@ $(STAGING_DIR): @install "$(BUILD_BIN_DIR)/machine-apiserver" "$(join $(STAGING_DIR), libexec/container/plugins/machine-apiserver/bin/machine-apiserver)" @install Sources/Plugins/MachineAPIServer/config.toml "$(join $(STAGING_DIR), libexec/container/plugins/machine-apiserver/config.toml)" @install Sources/Plugins/MachineAPIServer/Resources/init "$(join $(STAGING_DIR), libexec/container/plugins/machine-apiserver/resources/init)" - @install Sources/Plugins/MachineAPIServer/Resources/create-user.sh "$(join $(STAGING_DIR), libexec/container/plugins/machine-apiserver/resources/create-user.sh)" @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)" diff --git a/Sources/ContainerCommands/Machine/MachineCreate.swift b/Sources/ContainerCommands/Machine/MachineCreate.swift index 4cf2a6d7..b6496642 100644 --- a/Sources/ContainerCommands/Machine/MachineCreate.swift +++ b/Sources/ContainerCommands/Machine/MachineCreate.swift @@ -35,6 +35,9 @@ extension Application { @OptionGroup(title: "Management options") var managementFlags: Flags.MachineManagement + @OptionGroup(title: "User options") + var userFlags: Flags.MachineUser + @OptionGroup(title: "Registry options") var registryFlags: Flags.Registry @@ -121,10 +124,11 @@ extension Application { } let client = MachineClient() - let (config, resources) = try await MachineClient.machineConfigFromFlags( + let config = try await MachineClient.machineConfigFromFlags( id: id, image: image, management: managementFlags, + user: userFlags, registry: registryFlags, imageFetch: imageFetchFlags, containerSystemConfig: containerSystemConfig, @@ -132,7 +136,7 @@ extension Application { ) do { - try await client.create(configuration: config, resources: resources, bootConfig: bootConfig) + try await client.create(configuration: config, bootConfig: bootConfig) progress.finish() // Finish before subsequent output to avoid mangling } catch let error as ContainerizationError { if let cause = error.cause as? ContainerizationError, cause.isCode(.exists) { @@ -147,7 +151,7 @@ extension Application { } if !noBoot { - try await bootMachine(id: id, client: client, log: log, interactive: false) + try await bootMachine(id: id, client: client, log: log) } print(id) diff --git a/Sources/ContainerCommands/Machine/MachineHelpers.swift b/Sources/ContainerCommands/Machine/MachineHelpers.swift index 3241a3a8..bf98e1ab 100644 --- a/Sources/ContainerCommands/Machine/MachineHelpers.swift +++ b/Sources/ContainerCommands/Machine/MachineHelpers.swift @@ -35,20 +35,27 @@ func resolveMachineId(_ id: String?, client: MachineClient) async throws -> Stri return defaultId } -/// Boots a container machine and, on first ever boot, runs the in-VM init script -/// to set up the host user. Returns the resulting snapshot. +/// Boots a container machine and runs user setup inside the guest. 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. +/// User setup runs on every boot, not just the first: it's idempotent, so this +/// keeps the container user provisioned even if it's ever ended up in a +/// half-configured state. The setup script never needs a terminal or stdin, +/// so it always runs with `tty: false, interactive: false, detach: false` +/// regardless of the caller's own interactivity — this keeps signal +/// forwarding and cancellation working via `ProcessIO.handleProcess` (so +/// Ctrl-C during boot actually kills the setup process rather than hanging), +/// while still streaming its stderr live to the host so a caller like +/// `machine create`, which doesn't otherwise show the guest's output, can see +/// *why* setup refused to run (e.g. a uid/username/home conflict with an +/// existing account in the image). /// /// On any failure during user setup the machine is stopped to leave it in a clean state. @discardableResult func bootMachine( id: String?, client: MachineClient, - log: Logger, - interactive: Bool + log: Logger ) async throws -> MachineSnapshot { var dynamicEnv: [String: String] = [:] if let sshAuthSock = ProcessInfo.processInfo.environment["SSH_AUTH_SOCK"] { @@ -56,10 +63,6 @@ func bootMachine( } 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( @@ -68,20 +71,16 @@ func bootMachine( ) } - let io = try ProcessIO.create( - tty: interactive, - interactive: interactive, - detach: !interactive - ) + let io = try ProcessIO.create(tty: false, interactive: false, detach: false) defer { try? io.close() } let processConfig = ProcessConfiguration( - executable: "/\(MachineBundle.sbinDirectory)/\(MachineBundle.initFile)", - arguments: ["-u"], + executable: "/bin/sh", + arguments: ["-c", MachineUserSetup.script], environment: snapshot.configuration.processEnvironment, - terminal: interactive + terminal: false ) let process = try await ContainerClient().createProcess( @@ -92,6 +91,7 @@ func bootMachine( let exitCode = try await io.handleProcess(process: process, log: log) guard exitCode == 0 else { + log.error("container machine user setup failed", metadata: ["id": "\(snapshot.id)", "exitCode": "\(exitCode)"]) throw ContainerizationError( .invalidState, message: "container machine failed to create user" diff --git a/Sources/ContainerCommands/Machine/MachineRun.swift b/Sources/ContainerCommands/Machine/MachineRun.swift index dc62132c..c707fb18 100644 --- a/Sources/ContainerCommands/Machine/MachineRun.swift +++ b/Sources/ContainerCommands/Machine/MachineRun.swift @@ -57,7 +57,7 @@ extension Application { let client = MachineClient() let containerClient = ContainerClient() - let snapshot = try await bootMachine(id: name, client: client, log: log, interactive: true) + let snapshot = try await bootMachine(id: name, client: client, log: log) guard let containerId = snapshot.containerId else { throw ContainerizationError( diff --git a/Sources/Plugins/MachineAPIServer/Resources/create-user.sh b/Sources/Plugins/MachineAPIServer/Resources/create-user.sh deleted file mode 100755 index 3260afe2..00000000 --- a/Sources/Plugins/MachineAPIServer/Resources/create-user.sh +++ /dev/null @@ -1,47 +0,0 @@ -#!/bin/sh -# Copyright © 2026 Apple Inc. and the container project authors. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# -# First-time container user setup. Intended to be container machine-agnostic -# by directly manipulating /etc/group, /etc/passwd, and /etc/shadow rather -# than relying on image-specific tools (useradd, adduser, etc.). Also -# populates the home directory from /etc/skel and grants passwordless sudo -# access. -# -# Expects CONTAINER_USER, CONTAINER_UID, CONTAINER_GID, and CONTAINER_HOME to -# be set in the environment. -# - -set -e - -if ! getent group "${CONTAINER_GID}" >/dev/null 2>&1; then - echo "${CONTAINER_USER}:x:${CONTAINER_GID}:" >> /etc/group -fi - -if ! getent passwd "${CONTAINER_UID}" >/dev/null 2>&1; then - echo "${CONTAINER_USER}:x:${CONTAINER_UID}:${CONTAINER_GID}::${CONTAINER_HOME}:${CONTAINER_SHELL}" >> /etc/passwd - echo "${CONTAINER_USER}:!:19000:0:99999:7:::" >> /etc/shadow -fi - -mkdir -p "${CONTAINER_HOME}" -if [ -d /etc/skel ]; then - cp -a /etc/skel/. "${CONTAINER_HOME}" -fi -chown -R "${CONTAINER_UID}:${CONTAINER_GID}" "${CONTAINER_HOME}" - -mkdir -p /etc/sudoers.d -sudoers_file=$(echo "${CONTAINER_USER}" | tr '.' '_') -echo "${CONTAINER_USER} ALL=(ALL) NOPASSWD:ALL" > "/etc/sudoers.d/${sudoers_file}" -chmod 440 "/etc/sudoers.d/${sudoers_file}" diff --git a/Sources/Plugins/MachineAPIServer/Resources/init b/Sources/Plugins/MachineAPIServer/Resources/init index 3cf45115..495b51ba 100755 --- a/Sources/Plugins/MachineAPIServer/Resources/init +++ b/Sources/Plugins/MachineAPIServer/Resources/init @@ -4,66 +4,30 @@ # executed when a container machine boots. Performs container machine-specific setup # before handing off to the real system init: # -# 1. Resolve the system default shell. -# Debian/Ubuntu systems use DSHELL from /etc/adduser.conf; other images -# use SHELL from /etc/default/useradd. Falls back to /bin/bash or /bin/sh. -# -# 2. Open a user shell when invoked with the "-s" flag. +# 1. Open a user shell when invoked with the "-s" flag. # Looks up the current user's shell from /etc/passwd and execs into it, -# falling back to the system default shell. If additional arguments follow -# "-s", runs them as a command via " -c " instead of dropping -# into an interactive session. +# falling back to /bin/sh. If additional arguments follow "-s", runs them +# as a command via " -c " instead of dropping into an +# interactive session. # -# 3. Run first-time user setup when invoked with the "-u" flag (only once). -# If /etc/.machine.initialized does not exist, runs the create-user script -# to create the container user and apply one-time configuration. A custom -# script at /etc/machine/create-user.sh takes precedence over the built-in -# one at /sbin.machine/create-user.sh. Marks initialization complete by -# touching /etc/.machine.initialized. -# -# 4. Boot the system when invoked with no flags. +# 2. Boot the system when invoked with no flags. # Sets the hostname to CONTAINER_MACHINE_ID and execs /sbin/init to hand # off to the real system init. # +# User setup no longer happens here — it's run by the host directly on every +# boot as a separate exec (see MachineUserSetup.swift), before this script's +# "-s" or default path is invoked. set -e -MACHINE_INITIALIZED=/etc/.machine.initialized -CUSTOM_SETUP=/etc/machine/create-user.sh -DEFAULT_SETUP=/sbin.machine/create-user.sh - -. /etc/os-release 2>/dev/null -case "${ID:-}" in - ubuntu|debian) - SHELL=$(unset DSHELL; . /etc/adduser.conf 2>/dev/null \ - && [ -n "${DSHELL:-}" ] \ - && echo "${DSHELL}") || SHELL=/bin/bash ;; - *) - SHELL=$(unset SHELL; . /etc/default/useradd 2>/dev/null \ - && [ -n "${SHELL:-}" ] \ - && echo "${SHELL}") || SHELL=/bin/sh ;; -esac -export CONTAINER_SHELL=${SHELL} - if [ "$1" = "-s" ]; then shift USER_SHELL=$(grep "^$(id -un):" /etc/passwd 2>/dev/null | cut -d: -f7) if [ $# -gt 0 ]; then - exec "${USER_SHELL:-${SHELL}}" -c "$*" + exec "${USER_SHELL:-/bin/sh}" -c "$*" else - exec "${USER_SHELL:-${SHELL}}" + exec "${USER_SHELL:-/bin/sh}" fi -elif [ "$1" = "-u" ]; then - # DEPRECATED 0.11.0.0 - use `id` instead of checking `${MACHINE_INITIALIZED}` for backward compatibility, remove in 0.13.0.0 - if ! id "${CONTAINER_USER}" >/dev/null 2>&1; then - if [ -f ${CUSTOM_SETUP} ]; then - ${CUSTOM_SETUP} - else - ${DEFAULT_SETUP} - fi - fi - - echo 1 > ${MACHINE_INITIALIZED} else echo "${CONTAINER_MACHINE_ID}" > /etc/hostname diff --git a/Sources/Services/ContainerAPIService/Client/Parser.swift b/Sources/Services/ContainerAPIService/Client/Parser.swift index 00dd717f..591d1827 100644 --- a/Sources/Services/ContainerAPIService/Client/Parser.swift +++ b/Sources/Services/ContainerAPIService/Client/Parser.swift @@ -94,6 +94,50 @@ public struct Parser { return (user, supplementalGroups) } + /// Resolve a username/uid/gid triple for an account that's about to be created (as opposed + /// to `user(user:uid:gid:defaultUser:)`, which selects an *existing* identity to run a + /// process as). `user` uses the same `[:]` format, but unlike + /// `user(user:uid:gid:defaultUser:)` the group component must be numeric — there's no existing + /// `/etc/group` on the not-yet-created account to resolve a group name against. + /// + /// Each of the three pieces is resolved independently, falling back in order from `user`, to + /// the matching `uid`/`gid` argument, to the default. `user` only wins outright over `uid`/`gid` + /// when it actually specifies that piece (e.g. `--user 1500 --uid 9999` keeps 1500) — a bare + /// name in `user` doesn't specify a uid/gid, so `uid`/`gid` still apply in that case. + public static func userAccount( + user: String?, uid: UInt32?, gid: UInt32?, + defaultUsername: String, defaultUID: UInt32, defaultGID: UInt32 + ) throws -> (username: String, uid: UInt32, gid: UInt32) { + var username = defaultUsername + var resolvedUID: UInt32? + var resolvedGID: UInt32? + + if let user, !user.isEmpty { + let parts = user.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) + let primary = String(parts[0]) + + if !primary.isEmpty { + if let uidValue = UInt32(primary) { + resolvedUID = uidValue + } else { + username = primary + } + } + + if parts.count == 2 { + guard let gidValue = UInt32(parts[1]) else { + throw ContainerizationError( + .invalidArgument, + message: "invalid group '\(parts[1])' in --user '\(user)': group must be numeric" + ) + } + resolvedGID = gidValue + } + } + + return (username, resolvedUID ?? uid ?? defaultUID, resolvedGID ?? gid ?? defaultGID) + } + public static func platform(os: String, arch: String) -> ContainerizationOCI.Platform { .init(arch: arch, os: os) } diff --git a/Sources/Services/MachineAPIService/Client/Flags.swift b/Sources/Services/MachineAPIService/Client/Flags.swift index 12e4649e..13a247ad 100644 --- a/Sources/Services/MachineAPIService/Client/Flags.swift +++ b/Sources/Services/MachineAPIService/Client/Flags.swift @@ -30,4 +30,20 @@ extension Flags { @Option(name: .long, help: "Platform for the image if it's multi-platform. This takes precedence over --os and --arch") public var platform: String? } + + public struct MachineUser: ParsableArguments { + public init() {} + + @Option(name: .shortAndLong, help: "Set the user for the container machine account (format: name|uid[:gid]). Defaults to the host user") + public var user: String? + + @Option(name: .long, help: "Set the user ID for the container machine account. Defaults to the host user's") + public var uid: UInt32? + + @Option(name: .long, help: "Set the group ID for the container machine account. Defaults to the host user's") + public var gid: UInt32? + + @Option(name: .long, help: "Set the home directory for the container machine account. Defaults to /home/") + public var home: String? + } } diff --git a/Sources/Services/MachineAPIService/Client/MachineBundle.swift b/Sources/Services/MachineAPIService/Client/MachineBundle.swift index 93cf2279..5e6eb58c 100644 --- a/Sources/Services/MachineAPIService/Client/MachineBundle.swift +++ b/Sources/Services/MachineAPIService/Client/MachineBundle.swift @@ -24,7 +24,6 @@ public struct MachineBundle: Sendable { private static let rootfsBlockFile = FilePath.Component("rootfs.ext4") private static let rootfsFile = FilePath.Component("rootfs.json") private static let configFile = FilePath.Component("config.json") - private static let userSetupFile = FilePath.Component("create-user.sh") private static let bootLogFile = FilePath.Component("vminitd.log") private static let stdioLogFile = FilePath.Component("stdio.log") @@ -106,39 +105,11 @@ public struct MachineBundle: Sendable { } } -/// Metadata from an OCI artifact or in-image file that describes how a container machine -/// should be configured (shell, user creation script, etc.). -public struct MachineResources: Sendable, Codable, Equatable { - /// The media type for container machine configuration artifacts. - public static let configMediaType = "application/vnd.apple.container.machine.config.v1+json" - - /// The media type for container machine user setup scripts. - public static let setupScriptMediaType = "application/vnd.apple.container.machine.setup.v1+sh" - - public var schemaVersion: Int - public var shell: String? - public var setupScript: String? - - public init(schemaVersion: Int = 1, shell: String? = nil, setupScript: String? = nil) { - self.schemaVersion = schemaVersion - self.shell = shell - self.setupScript = setupScript - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.schemaVersion = try container.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 1 - self.shell = try container.decodeIfPresent(String.self, forKey: .shell) - self.setupScript = try container.decodeIfPresent(String.self, forKey: .setupScript) - } -} - extension MachineBundle { public static func create( path: FilePath, machineConfiguration: MachineConfiguration, resourceRoot: FilePath, - resources: MachineResources?, bootConfig: MachineConfig, ) throws -> MachineBundle { let fm = FileManager.default @@ -152,19 +123,11 @@ extension MachineBundle { let sbin = path.appending(sbinDirectory) let initPath = sbin.appending(initFile) - let setupScriptPath = sbin.appending(userSetupFile) let initializedPath = path.appending(initializedFile) try fm.createDirectory(atPath: sbin.string, withIntermediateDirectories: true) try fm.copyItem(atPath: resourceRoot.appending(initFile).string, toPath: initPath.string) - if let setupScript = resources?.setupScript { - try setupScript.write(toFile: setupScriptPath.string, atomically: true, encoding: .utf8) - try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: setupScriptPath.string) - } else { - try fm.copyItem(atPath: resourceRoot.appending(userSetupFile).string, toPath: setupScriptPath.string) - } - guard fm.createFile(atPath: initializedPath.string, contents: "".data(using: .utf8)) else { throw ContainerizationError(.internalError, message: "failed to create \(initializedPath.string)") } @@ -179,15 +142,10 @@ extension MachineBundle { let sbin = path.appending(sbinDirectory) let initPath = sbin.appending(initFile) - let setupScriptPath = sbin.appending(userSetupFile) let initializedPath = path.appending(initializedFile) try fm.createDirectory(atPath: sbin.string, withIntermediateDirectories: true) - if !fm.fileExists(atPath: setupScriptPath.string) { - try fm.copyItem(atPath: resourceRoot.appending(userSetupFile).string, toPath: setupScriptPath.string) - } - if fm.fileExists(atPath: initPath.string) { try fm.removeItem(atPath: initPath.string) } diff --git a/Sources/Services/MachineAPIService/Client/MachineClient.swift b/Sources/Services/MachineAPIService/Client/MachineClient.swift index 4fa5d090..909f85d4 100644 --- a/Sources/Services/MachineAPIService/Client/MachineClient.swift +++ b/Sources/Services/MachineAPIService/Client/MachineClient.swift @@ -31,11 +31,12 @@ public struct MachineClient: Sendable { id: String, image: String, management: Flags.MachineManagement, + user: Flags.MachineUser, registry: Flags.Registry, imageFetch: Flags.ImageFetch, containerSystemConfig: ContainerSystemConfig, progressUpdate: @escaping ProgressUpdateHandler - ) async throws -> (MachineConfiguration, MachineResources?) { + ) async throws -> MachineConfiguration { var requestedPlatform = Parser.platform(os: management.os, arch: management.arch) // Prefer --platform if let platform = management.platform { @@ -68,10 +69,14 @@ public struct MachineClient: Sendable { platform: requestedPlatform, progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: progressUpdate)) + let (username, uid, gid) = try Parser.userAccount( + user: user.user, uid: user.uid, gid: user.gid, + defaultUsername: NSUserName(), defaultUID: getuid(), defaultGID: getgid()) let userSetup = UserSetup( - username: NSUserName(), - uid: getuid(), - gid: getgid()) + username: username, + uid: uid, + gid: gid, + home: user.home) let config = try MachineConfiguration( id: id, @@ -79,10 +84,7 @@ public struct MachineClient: Sendable { platform: requestedPlatform, userSetup: userSetup) - let resources = try? await Self.fetchMachineArtifact( - reference: img.reference, platform: requestedPlatform, scheme: scheme) - - return (config, resources) + return config } private let xpcClient: XPCClient @@ -125,7 +127,6 @@ public struct MachineClient: Sendable { /// Create a new container machine with the given configuration public func create( configuration: MachineConfiguration, - resources: MachineResources?, bootConfig: MachineConfig, ) async throws { do { @@ -134,11 +135,6 @@ public struct MachineClient: Sendable { let config = try JSONEncoder().encode(configuration) request.set(key: MachineKeys.machineConfig.rawValue, value: config) - if let resources { - let data = try JSONEncoder().encode(resources) - request.set(key: MachineKeys.machineResources.rawValue, value: data) - } - let bootData = try JSONEncoder().encode(bootConfig) request.set(key: MachineKeys.bootConfig.rawValue, value: bootData) @@ -313,87 +309,3 @@ public struct MachineClient: Sendable { } } } - -// MARK: Container machine artifact fetching - -extension MachineClient { - /// Fetch machine metadata from an OCI artifact attached to an image via the referrers API. - /// - /// Returns `nil` if no artifact is found or the registry doesn't support referrers. - static func fetchMachineArtifact( - reference: String, - platform: Platform, - scheme: RequestScheme - ) async throws -> MachineResources? { - let ref = try Reference.parse(reference) - guard let domain = ref.resolvedDomain else { - return nil - } - - let insecure = try scheme.schemeFor(host: ref.resolvedDomain ?? "", internalDnsDomain: nil) == .http - - // Look up credentials from keychain - let keychain = KeychainHelper(securityDomain: Constants.keychainID) - let auth = try? keychain.lookup(hostname: domain) - - let client = try RegistryClient(reference: reference, insecure: insecure, auth: auth) - let name = ref.path - - // Resolve the image reference to get the manifest digest. - // We need the platform-specific manifest digest, not the index digest. - let tag = ref.digest ?? ref.tag ?? "latest" - let topDescriptor = try await client.resolve(name: name, tag: tag) - - // If the top-level is an index, find the platform-specific manifest - let manifestDigest: String - switch topDescriptor.mediaType { - case MediaTypes.index, MediaTypes.dockerManifest: - let index: Index = try await client.fetch(name: name, descriptor: topDescriptor) - guard let platformDesc = index.manifests.first(where: { $0.platform == platform }) else { - return nil - } - manifestDigest = platformDesc.digest - case MediaTypes.imageManifest: - manifestDigest = topDescriptor.digest - default: - return nil - } - - // Query referrers API for container machine config artifacts - let referrersIndex = try await client.referrers( - name: name, - digest: manifestDigest, - artifactType: MachineResources.configMediaType - ) - - guard let artifactDesc = referrersIndex.manifests.first else { - return nil - } - - // Fetch the artifact manifest - let artifactManifest: Manifest = try await client.fetch(name: name, descriptor: artifactDesc) - - // Extract metadata JSON and setup script from artifact layers - var resources: MachineResources? - var setupScript: String? - - for layer in artifactManifest.layers { - if layer.mediaType == MachineResources.configMediaType { - let data = try await client.fetchData(name: name, descriptor: layer) - resources = try JSONDecoder().decode(MachineResources.self, from: data) - } else if layer.mediaType == MachineResources.setupScriptMediaType { - let data = try await client.fetchData(name: name, descriptor: layer) - let script = String(decoding: data, as: UTF8.self) - if !script.isEmpty { - setupScript = script - } - } - } - - if var resources, let setupScript { - resources.setupScript = setupScript - } - - return resources - } -} diff --git a/Sources/Services/MachineAPIService/Client/MachineConfiguration.swift b/Sources/Services/MachineAPIService/Client/MachineConfiguration.swift index ea375ebd..e34bada3 100644 --- a/Sources/Services/MachineAPIService/Client/MachineConfiguration.swift +++ b/Sources/Services/MachineAPIService/Client/MachineConfiguration.swift @@ -26,19 +26,38 @@ public struct UserSetup: Sendable, Codable, Equatable { public var username: String public var uid: UInt32 public var gid: UInt32 - - public var home: String { - "/home/\(username)" - } + public var home: String public var user: ProcessConfiguration.User { .id(uid: uid, gid: gid) } - public init(username: String, uid: UInt32, gid: UInt32) { + /// The `CONTAINER_*` environment variables `MachineUserSetup.script` expects. + public var processEnvironment: [String] { + [ + "CONTAINER_USER=\(username)", + "CONTAINER_HOME=\(home)", + "CONTAINER_UID=\(uid)", + "CONTAINER_GID=\(gid)", + ] + } + + public init(username: String, uid: UInt32, gid: UInt32, home: String? = nil) { self.username = username self.uid = uid self.gid = gid + self.home = home ?? "/home/\(username)" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.username = try container.decode(String.self, forKey: .username) + self.uid = try container.decode(UInt32.self, forKey: .uid) + self.gid = try container.decode(UInt32.self, forKey: .gid) + // DEPRECATED 1.3.0 - `decodeIfPresent` used for down-revision compatibility with bundles + // persisted before `home` existed; replace with plain `decode` once compatibility with + // pre-1.3.0 bundles is no longer required (2.0.0). + self.home = try container.decodeIfPresent(String.self, forKey: .home) ?? "/home/\(self.username)" } } @@ -67,13 +86,8 @@ public struct MachineConfiguration: Sendable, Codable { public var processEnvironment: [String] { [ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", - "CONTAINER_MACHINE_ID=\(id)", - "CONTAINER_USER=\(userSetup.username)", - "CONTAINER_HOME=\(userSetup.home)", - "CONTAINER_UID=\(userSetup.uid)", - "CONTAINER_GID=\(userSetup.gid)", - ] + ] + userSetup.processEnvironment } public var dnsName: String { diff --git a/Sources/Services/MachineAPIService/Client/MachineKeys.swift b/Sources/Services/MachineAPIService/Client/MachineKeys.swift index f699b07e..a67531d2 100644 --- a/Sources/Services/MachineAPIService/Client/MachineKeys.swift +++ b/Sources/Services/MachineAPIService/Client/MachineKeys.swift @@ -19,8 +19,6 @@ public enum MachineKeys: String { case id /// Container machine configuration. case machineConfig - /// Container machine resources. - case machineResources /// List of container machine snapshots. case machines /// Single container machine snapshot. diff --git a/Sources/Services/MachineAPIService/Client/MachineUserSetup.swift b/Sources/Services/MachineAPIService/Client/MachineUserSetup.swift new file mode 100644 index 00000000..9aeca7b3 --- /dev/null +++ b/Sources/Services/MachineAPIService/Client/MachineUserSetup.swift @@ -0,0 +1,84 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +/// Container machine user setup, embedded as a literal and exec'd directly +/// inside the guest (as `sh -c "\(MachineUserSetup.script)"`) rather than +/// shipped as a file. Intended to be image-agnostic by directly manipulating +/// /etc/group, /etc/passwd, and /etc/shadow rather than relying on +/// image-specific tools (useradd, adduser, etc.). +/// +/// Safe to run on every boot: user/shadow creation and home directory +/// population only happen once (guarded by looking the account up by both +/// uid and username), while the sudoers entry is cheaply reasserted every +/// run. If the image already has a *different* account using the requested +/// uid or username, or anything already exists at the requested home path, +/// setup fails loudly instead of silently skipping — proceeding could mean +/// resolving to the wrong account, creating a duplicate/ambiguous passwd +/// entry, or recursively chowning pre-existing content that isn't ours. +/// +/// Expects CONTAINER_USER, CONTAINER_UID, CONTAINER_GID, and CONTAINER_HOME +/// to be set in the environment. +public enum MachineUserSetup { + public static let script = #""" + set -e + + . /etc/os-release 2>/dev/null || true + case "${ID:-}" in + ubuntu|debian) + CONTAINER_SHELL=$(unset DSHELL; . /etc/adduser.conf 2>/dev/null \ + && [ -n "${DSHELL:-}" ] \ + && echo "${DSHELL}") || CONTAINER_SHELL=/bin/bash ;; + *) + CONTAINER_SHELL=$(unset SHELL; . /etc/default/useradd 2>/dev/null \ + && [ -n "${SHELL:-}" ] \ + && echo "${SHELL}") || CONTAINER_SHELL=/bin/sh ;; + esac + + if ! getent group "${CONTAINER_GID}" >/dev/null 2>&1; then + echo "${CONTAINER_USER}:x:${CONTAINER_GID}:" >> /etc/group + fi + + existing_by_uid=$(getent passwd "${CONTAINER_UID}" 2>/dev/null) || true + existing_by_name=$(getent passwd "${CONTAINER_USER}" 2>/dev/null) || true + + if [ -n "${existing_by_uid}" ] || [ -n "${existing_by_name}" ]; then + if [ "${existing_by_uid}" != "${existing_by_name}" ]; then + echo "container machine: refusing to provision user '${CONTAINER_USER}' (uid ${CONTAINER_UID}): a different account in this image already uses this uid or username" >&2 + exit 1 + fi + # Otherwise this is our own account from a previous boot: nothing to do. + else + if [ -e "${CONTAINER_HOME}" ]; then + echo "container machine: refusing to use existing path '${CONTAINER_HOME}' as the home directory for user '${CONTAINER_USER}'" >&2 + exit 1 + fi + + echo "${CONTAINER_USER}:x:${CONTAINER_UID}:${CONTAINER_GID}::${CONTAINER_HOME}:${CONTAINER_SHELL}" >> /etc/passwd + echo "${CONTAINER_USER}:!:$(($(date +%s) / 86400)):0:99999:7:::" >> /etc/shadow + + mkdir -p "${CONTAINER_HOME}" + if [ -d /etc/skel ]; then + cp -a /etc/skel/. "${CONTAINER_HOME}" + fi + chown -R "${CONTAINER_UID}:${CONTAINER_GID}" "${CONTAINER_HOME}" + fi + + mkdir -p /etc/sudoers.d + sudoers_file=$(echo "${CONTAINER_USER}" | tr '.' '_') + echo "${CONTAINER_USER} ALL=(ALL) NOPASSWD:ALL" > "/etc/sudoers.d/${sudoers_file}" + chmod 440 "/etc/sudoers.d/${sudoers_file}" + """# +} diff --git a/Sources/Services/MachineAPIService/Server/MachinesHarness.swift b/Sources/Services/MachineAPIService/Server/MachinesHarness.swift index 96421395..c8729546 100644 --- a/Sources/Services/MachineAPIService/Server/MachinesHarness.swift +++ b/Sources/Services/MachineAPIService/Server/MachinesHarness.swift @@ -38,12 +38,6 @@ public struct MachinesHarness: Sendable { ) } - let machineResources = message.dataNoCopy(key: MachineKeys.machineResources.rawValue) - var resources: MachineResources? = nil - if let machineResources { - resources = try JSONDecoder().decode(MachineResources.self, from: machineResources) - } - let bootConfigData = message.dataNoCopy(key: MachineKeys.bootConfig.rawValue) guard let bootConfigData else { throw ContainerizationError(.invalidArgument, message: "bootConfig cannot be empty") @@ -52,7 +46,7 @@ public struct MachinesHarness: Sendable { let config = try JSONDecoder().decode(MachineConfiguration.self, from: machineConfig) - try await service.create(configuration: config, resources: resources, bootConfig: bootConfig) + try await service.create(configuration: config, bootConfig: bootConfig) return message.reply() } diff --git a/Sources/Services/MachineAPIService/Server/MachinesService.swift b/Sources/Services/MachineAPIService/Server/MachinesService.swift index ff3df1fd..de2b701d 100644 --- a/Sources/Services/MachineAPIService/Server/MachinesService.swift +++ b/Sources/Services/MachineAPIService/Server/MachinesService.swift @@ -175,7 +175,7 @@ public actor MachinesService { return snapshots } - public func create(configuration: MachineConfiguration, resources: MachineResources?, bootConfig: MachineConfig) async throws { + public func create(configuration: MachineConfiguration, bootConfig: MachineConfig) async throws { self.log.debug("\(#function)") try await self.lock.withLock { context in @@ -191,7 +191,6 @@ public actor MachinesService { path: path, machineConfiguration: configuration, resourceRoot: self.resourceRoot, - resources: resources, bootConfig: bootConfig, ) diff --git a/Tests/ContainerAPIClientTests/ParserTest.swift b/Tests/ContainerAPIClientTests/ParserTest.swift index aa359331..bf295791 100644 --- a/Tests/ContainerAPIClientTests/ParserTest.swift +++ b/Tests/ContainerAPIClientTests/ParserTest.swift @@ -1652,4 +1652,142 @@ struct ParserTest { let result = try Parser.allEnv(imageEnvs: imageEnvs, envFiles: [], envs: envs) #expect(result.count == 100) } + + // MARK: - Parser.userAccount + + @Test + func testUserAccountNilUsesDefaults() throws { + let result = try Parser.userAccount( + user: nil, uid: nil, gid: nil, + defaultUsername: "app", defaultUID: 501, defaultGID: 20 + ) + #expect(result.username == "app") + #expect(result.uid == 501) + #expect(result.gid == 20) + } + + @Test + func testUserAccountNameOnly() throws { + let result = try Parser.userAccount( + user: "alice", uid: nil, gid: nil, + defaultUsername: "app", defaultUID: 501, defaultGID: 20 + ) + #expect(result.username == "alice") + #expect(result.uid == 501) + #expect(result.gid == 20) + } + + @Test + func testUserAccountUIDOnly() throws { + let result = try Parser.userAccount( + user: "1500", uid: nil, gid: nil, + defaultUsername: "app", defaultUID: 501, defaultGID: 20 + ) + #expect(result.username == "app") + #expect(result.uid == 1500) + #expect(result.gid == 20) + } + + @Test + func testUserAccountNameAndGID() throws { + let result = try Parser.userAccount( + user: "alice:100", uid: nil, gid: nil, + defaultUsername: "app", defaultUID: 501, defaultGID: 20 + ) + #expect(result.username == "alice") + #expect(result.uid == 501) + #expect(result.gid == 100) + } + + @Test + func testUserAccountUIDAndGID() throws { + let result = try Parser.userAccount( + user: "1500:100", uid: nil, gid: nil, + defaultUsername: "app", defaultUID: 501, defaultGID: 20 + ) + #expect(result.username == "app") + #expect(result.uid == 1500) + #expect(result.gid == 100) + } + + @Test + func testUserAccountGIDOnlyLeadingColon() throws { + // "--user :100" specifies only a group; the name/uid should fall back to the defaults. + let result = try Parser.userAccount( + user: ":100", uid: nil, gid: nil, + defaultUsername: "app", defaultUID: 501, defaultGID: 20 + ) + #expect(result.username == "app") + #expect(result.uid == 501) + #expect(result.gid == 100) + } + + @Test + func testUserAccountUserWinsOverUIDWhenItSpecifiesUID() throws { + let result = try Parser.userAccount( + user: "1500", uid: 9999, gid: nil, + defaultUsername: "app", defaultUID: 501, defaultGID: 20 + ) + #expect(result.uid == 1500) + } + + @Test + func testUserAccountBareNameDoesNotOverrideUIDGID() throws { + // A bare name in `user` doesn't specify a uid/gid, so the uid/gid arguments still apply. + let result = try Parser.userAccount( + user: "alice", uid: 9999, gid: 42, + defaultUsername: "app", defaultUID: 501, defaultGID: 20 + ) + #expect(result.username == "alice") + #expect(result.uid == 9999) + #expect(result.gid == 42) + } + + @Test + func testUserAccountInvalidGroup() throws { + #expect { + _ = try Parser.userAccount( + user: "alice:notanumber", uid: nil, gid: nil, + defaultUsername: "app", defaultUID: 501, defaultGID: 20 + ) + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("invalid group") && error.description.contains("must be numeric") + } + } + + @Test + func testUserAccountBareColonThrowsInsteadOfCrashing() throws { + // Regression test: `user.split(separator: ":", maxSplits: 1)` with default + // omittingEmptySubsequences drops both empty halves of ":" and returns an empty + // array, so indexing `parts[0]` used to crash. It must now throw a proper error. + #expect { + _ = try Parser.userAccount( + user: ":", uid: nil, gid: nil, + defaultUsername: "app", defaultUID: 501, defaultGID: 20 + ) + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("invalid group") && error.description.contains("must be numeric") + } + } + + @Test + func testUserAccountTrailingColonThrows() throws { + #expect { + _ = try Parser.userAccount( + user: "alice:", uid: nil, gid: nil, + defaultUsername: "app", defaultUID: 501, defaultGID: 20 + ) + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("invalid group") && error.description.contains("must be numeric") + } + } } diff --git a/Tests/IntegrationTests/Machine/TestCLIMachineCommand.swift b/Tests/IntegrationTests/Machine/TestCLIMachineCommand.swift index e670c642..117f77ec 100644 --- a/Tests/IntegrationTests/Machine/TestCLIMachineCommand.swift +++ b/Tests/IntegrationTests/Machine/TestCLIMachineCommand.swift @@ -63,4 +63,13 @@ struct TestCLIMachineCommand { #expect(result.status != 0, "create should reject names longer than max") } } + + @Test func testCreateRejectsNonNumericUserGroup() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + let result = try f.runMachine(["create", "--no-boot", "--name", name, "--user", "devuser:notanumber", machineImage]) + #expect(result.status != 0, "create should reject a non-numeric group in --user") + #expect(result.error.contains("must be numeric"), "error should explain the constraint") + } + } } diff --git a/Tests/IntegrationTests/Machine/TestCLIMachineRuntimeSerial.swift b/Tests/IntegrationTests/Machine/TestCLIMachineRuntimeSerial.swift index 6e5323bb..67e721aa 100644 --- a/Tests/IntegrationTests/Machine/TestCLIMachineRuntimeSerial.swift +++ b/Tests/IntegrationTests/Machine/TestCLIMachineRuntimeSerial.swift @@ -578,6 +578,105 @@ struct TestCLIMachineRuntimeSerial { } } + @Test func testUserSetupRerunsAcrossRestart() 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 await f.waitForMachineStatus(name, status: "running") + try f.doMachineStop(name: name) + + try f.doMachineBoot(name: name) + try await f.waitForMachineStatus(name, status: "running") + + let username = NSUserName() + let passwdCount = try f.doMachineRun( + name: name, root: true, + command: ["grep", "-c", "^\(username):", "/etc/passwd"] + ).trimmingCharacters(in: .whitespacesAndNewlines) + #expect(passwdCount == "1", "user setup re-running on restart should not duplicate the passwd entry") + + let sudoers = try f.doMachineRun( + name: name, root: true, + command: ["cat", "/etc/sudoers.d/\(username)"] + ).trimmingCharacters(in: .whitespacesAndNewlines) + #expect(sudoers == "\(username) ALL=(ALL) NOPASSWD:ALL") + } + } + + @Test func testCreateWithCustomUser() 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, + extraArgs: ["--user", "devuser", "--uid", "1500", "--gid", "1600"]) + try f.doMachineBoot(name: name) + try await f.waitForMachineStatus(name, status: "running") + + let uid = try f.doMachineRun(name: name, command: ["id", "-u"]) + .trimmingCharacters(in: .whitespacesAndNewlines) + let gid = try f.doMachineRun(name: name, command: ["id", "-g"]) + .trimmingCharacters(in: .whitespacesAndNewlines) + let username = try f.doMachineRun(name: name, command: ["id", "-un"]) + .trimmingCharacters(in: .whitespacesAndNewlines) + let home = try f.doMachineRun(name: name, command: ["echo", "$HOME"]) + .trimmingCharacters(in: .whitespacesAndNewlines) + #expect(uid == "1500") + #expect(gid == "1600") + #expect(username == "devuser") + #expect(home == "/home/devuser") + + let sudoers = try f.doMachineRun( + name: name, root: true, + command: ["cat", "/etc/sudoers.d/devuser"] + ).trimmingCharacters(in: .whitespacesAndNewlines) + #expect(sudoers == "devuser ALL=(ALL) NOPASSWD:ALL") + } + } + + @Test func testCreateWithUidGidOnlyKeepsHostUsername() 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, + extraArgs: ["--uid", "1500", "--gid", "1600"]) + try f.doMachineBoot(name: name) + try await f.waitForMachineStatus(name, status: "running") + + let uid = try f.doMachineRun(name: name, command: ["id", "-u"]) + .trimmingCharacters(in: .whitespacesAndNewlines) + let username = try f.doMachineRun(name: name, command: ["id", "-un"]) + .trimmingCharacters(in: .whitespacesAndNewlines) + #expect(uid == "1500") + #expect(username == NSUserName(), "username should stay host-derived when only --uid/--gid are set") + } + } + + @Test func testCreateWithCustomHome() 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, + extraArgs: ["--home", "/srv/devhome"]) + try f.doMachineBoot(name: name) + try await f.waitForMachineStatus(name, status: "running") + + let home = try f.doMachineRun(name: name, command: ["echo", "$HOME"]) + .trimmingCharacters(in: .whitespacesAndNewlines) + #expect(home == "/srv/devhome") + + let listing = try f.doMachineRun( + name: name, root: true, + command: ["ls", "-ld", "/srv/devhome"] + ).trimmingCharacters(in: .whitespacesAndNewlines) + #expect(listing.hasPrefix("d"), "custom home directory should have been created") + } + } + // MARK: - set tests @Test func testSetCpus() async throws { diff --git a/Tests/IntegrationTests/Machine/TestCLIMachineUserSetupScript.swift b/Tests/IntegrationTests/Machine/TestCLIMachineUserSetupScript.swift new file mode 100644 index 00000000..a824f32b --- /dev/null +++ b/Tests/IntegrationTests/Machine/TestCLIMachineUserSetupScript.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 ContainerTestSupport +import MachineAPIClient +import Testing + +/// Exercises `MachineUserSetup.script` directly against a plain, ephemeral +/// container instead of a full container machine (persistent VM, systemd, +/// virtiofs mounts). The script only depends on a Linux userland with +/// `getent`/passwd semantics, so this is a much cheaper way to cover its +/// collision-handling logic than booting a real machine. +@Suite +struct TestCLIMachineUserSetupScript { + private let image = WarmupImage.alpine320.rawValue + + /// Runs `MachineUserSetup.script` inside `name` with the given identity, returning + /// the raw exec result (not `.check()`ed, so failure cases can be asserted on directly). + private func runSetup(_ f: ContainerFixture, name: String, user: UserSetup) throws -> CommandResult { + let exports = user.processEnvironment.map { "export \($0)" }.joined(separator: "\n") + return try f.run(["exec", name, "sh", "-c", exports + "\n" + MachineUserSetup.script]) + } + + @Test func testSetupCreatesAccountAndSudoers() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-c" + try await f.doLongRun(name: name, image: image) + f.addCleanup { try? f.doStop(name) } + try await f.waitForContainerRunning(name) + + let user = UserSetup(username: "devuser", uid: 1500, gid: 1600, home: "/home/devuser") + try runSetup(f, name: name, user: user).check() + + let passwd = try f.doExec(name, cmd: ["grep", "^devuser:", "/etc/passwd"]) + .trimmingCharacters(in: .whitespacesAndNewlines) + #expect(passwd == "devuser:x:1500:1600::/home/devuser:/bin/sh") + + let sudoers = try f.doExec(name, cmd: ["cat", "/etc/sudoers.d/devuser"]) + .trimmingCharacters(in: .whitespacesAndNewlines) + #expect(sudoers == "devuser ALL=(ALL) NOPASSWD:ALL") + } + } + + @Test func testSetupIsIdempotent() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-c" + try await f.doLongRun(name: name, image: image) + f.addCleanup { try? f.doStop(name) } + try await f.waitForContainerRunning(name) + + let user = UserSetup(username: "devuser", uid: 1500, gid: 1600, home: "/home/devuser") + try runSetup(f, name: name, user: user).check() + try runSetup(f, name: name, user: user).check() + + let count = try f.doExec(name, cmd: ["grep", "-c", "^devuser:", "/etc/passwd"]) + .trimmingCharacters(in: .whitespacesAndNewlines) + #expect(count == "1", "re-running setup with the same identity should not duplicate the passwd entry") + } + } + + @Test func testSetupFailsOnUidCollision() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-c" + try await f.doLongRun(name: name, image: image) + f.addCleanup { try? f.doStop(name) } + try await f.waitForContainerRunning(name) + + // uid 0 always belongs to root already. + let user = UserSetup(username: "devuser", uid: 0, gid: 0, home: "/home/devuser") + let result = try runSetup(f, name: name, user: user) + #expect(result.status != 0) + #expect(result.error.contains("already uses this uid or username")) + } + } + + @Test func testSetupFailsOnUsernameCollision() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-c" + try await f.doLongRun(name: name, image: image) + f.addCleanup { try? f.doStop(name) } + try await f.waitForContainerRunning(name) + + // "root" already exists, but at uid 0, not this free uid. + let user = UserSetup(username: "root", uid: 1500, gid: 1600, home: "/home/root2") + let result = try runSetup(f, name: name, user: user) + #expect(result.status != 0) + #expect(result.error.contains("already uses this uid or username")) + + // Must not have created a duplicate "root" entry. + let count = try f.doExec(name, cmd: ["grep", "-c", "^root:", "/etc/passwd"]) + .trimmingCharacters(in: .whitespacesAndNewlines) + #expect(count == "1") + } + } + + @Test func testSetupFailsOnHomeCollision() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-c" + try await f.doLongRun(name: name, image: image) + f.addCleanup { try? f.doStop(name) } + try await f.waitForContainerRunning(name) + + // /etc definitely already exists and has content. + let user = UserSetup(username: "devuser", uid: 1500, gid: 1600, home: "/etc") + let result = try runSetup(f, name: name, user: user) + #expect(result.status != 0) + #expect(result.error.contains("refusing to use existing path")) + + // Must not have touched /etc's ownership. + let owner = try f.doExec(name, cmd: ["stat", "-c", "%u:%g", "/etc/passwd"]) + .trimmingCharacters(in: .whitespacesAndNewlines) + #expect(owner == "0:0", "pre-existing /etc content must not be chowned") + } + } +} diff --git a/docs/command-reference.md b/docs/command-reference.md index d4c04329..3d7cce71 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -1126,6 +1126,13 @@ container machine create [] * `--os `: Set OS if image can target multiple operating systems (default: linux) * `--platform `: Platform for the image if it's multi-platform. This takes precedence over --os and --arch +**User Options** + +* `-u, --user `: Set the user for the container machine account (format: name|uid[:gid]). Defaults to the host user +* `--uid `: Set the user ID for the container machine account. Defaults to the host user's +* `--gid `: Set the group ID for the container machine account. Defaults to the host user's +* `--home `: Set the home directory for the container machine account. Defaults to /home/ + **Registry Options** * `--scheme `: Scheme to use when connecting to the container registry. One of (http, https) (default: https) diff --git a/docs/container-machine.md b/docs/container-machine.md index 81e36e56..57af0a10 100644 --- a/docs/container-machine.md +++ b/docs/container-machine.md @@ -1,11 +1,20 @@ # Container machine -Container machine provides a highly integrated Linux environment that works seamlessly on your Mac. Container machines are fast, lightweight and persistent. They are based on standard OCI images that can be built and shared. Host integrations such as automatic user and home directory sharing provide quick and easy access to your Linux environment no matter where you are in a terminal. +Container machine provides a highly integrated Linux environment that works seamlessly on your Mac. Container machines are fast, lightweight and persistent. They're based on standard OCI images you can build and share. A container machine has seamless access to macOS home directory – if you're anywhere under your home directory and you open a shell session in a container machine, the Linux session starts in that same directory, with your same username and file access permissions. + +> [!WARNING] +> Running an image as a container machine is much closer to running a macOS program directly on your host than to running a regular container. A container machine trades away much of the isolation that makes a regular Apple container safer for running untrusted code. Only create container machines from images you trust. +> +> A container machine image supplies its own `/sbin/init` that serves as the entry point for the machine, running as the OS init process (PID 1). That process, and everything it chooses to start afterward (services, cron jobs, and anything else), can do anything a Linux process running as root can do — with access to more of your host system. A container machine: +> +> - Automatically mounts your macOS home directory into the guest **read-write, at the same path**. Anything running in your container machine has access, with your macOS user permissions, to your repos, dotfiles, and anything else under `${HOME}`. Equivalent to `container create -v "${HOME}:${HOME}"`. +> - Forwards your host `SSH_AUTH_SOCK` into the guest, so anything running in the machine can use your ssh-agent to act as you (e.g. for `git`/`ssh` operations). Equivalent to `container create --ssh`. +> - Runs with **all Linux capabilities added** and **no masked or read-only `/proc`/`/sys` paths** — a container machine deliberately bypasses the default access controls that a regular container receives, since the image's init system needs to behave like a full Linux install. Equivalent to `container create --cap-add ALL --masked-path NONE --read-only-path NONE`. +> - Creates a guest account and Linux home directory, and configures **passwordless sudo** (`NOPASSWD:ALL`), mapped to your host UID/GID. Root inside the container machine can write anywhere in your mounted home directory with your macOS user permissions. ## Why container machines -Containers are typically modeled after an application. A container machine is modeled after a Linux environment. It runs the image's init system allowing you to register long running services or test your application under a process supervisor. -A container machine automatically maps your username and home directory into the Linux environment. Your repositories and dotfiles are available on both platforms. Use editors and tools directly on macOS simultaneously building and running your application inside of the Linux environment. +Containers are typically modeled after an application. A container machine is modeled after a complete Linux environment. It runs the image's init system, so you can register long-running services or test your application under a process supervisor. It also maps your macOS username and home directory into the Linux environment. Your repositories and dotfiles are available on both platforms, so that you can: - **Edit on the Mac, build inside.** Your repo lives in `$HOME` on macOS and is mounted at `/Users/` inside the container machine. Use your macOS editor or IDE; compile and run inside your container machine. - **Use macOS-native tooling against Linux artifacts.** Profilers, screenshot tools, browsers, and GUI debuggers on your Mac all see the same files the container machine sees — there is no copy step between "I built it" and "I am inspecting it". @@ -17,7 +26,7 @@ A container machine automatically maps your username and home directory into the ```bash container machine create alpine:latest --name dev container machine run -n dev whoami # your host username, not root -container machine run -n dev pwd # /home/ — your Mac home dir, mounted in +container machine run -n dev pwd # your host current directory if you're under it; otherwise the Linux user home directory container machine run -n dev # interactive shell; cd into your repos in $HOME ``` @@ -40,6 +49,8 @@ container machine run -n dev uname -a container machine run -n dev -- cat /proc/cpuinfo ``` +When you run `container machine run` anywhere under your macOS home directory, the Linux process runs in the equivalent directory in the container machine's home mount. Otherwise, the process runs in the your Linux user's home directory. + ### Set a default Pick a default container machine so you can drop the `-n` flag: @@ -76,7 +87,7 @@ Memory defaults to half of host memory. The home-mount can be `rw` (default), `r A container machine supports nested virtualization. The requirements for this to work are: -1. Apple Silicon **M3 or later** with **macOS 15 or later** is required. +1. Apple Silicon **M3 or later** with **macOS 15 or later**. 2. A Linux kernel with CONFIG_KVM=y enabled. The default kernel does not support this. ```bash @@ -101,9 +112,18 @@ container machine run -n dev -- ls -l /dev/kvm container machine set -n dev kernel= ``` -## Bring your own container machine image +## Bring your own image -Any Linux image that includes `/sbin/init` works as a container machine. For example, this Dockerfile builds an Ubuntu 24.04 container machine image with `systemd` and common command-line tools: +Any Linux image that includes `/sbin/init` works as a container machine. Beyond that, the built-in user setup described below needs a few common tools already present in most general-purpose distro images — setup fails if any of these are missing: + +- A POSIX shell at `/bin/sh` (busybox's `ash`/`dash`, or `bash`, both work) — what the setup script itself runs as. +- `getent`, to check for existing accounts/groups by uid, gid, or name. +- `date` with `%s` support (seconds since epoch), to timestamp the new account. +- `chown`, `mkdir`, `cp`, and `tr` — standard coreutils/busybox utilities. + +Setup also always writes a passwordless-sudo grant to `/etc/sudoers.d/`, but that's just a file — it does nothing unless `sudo` is installed and configured to read `/etc/sudoers.d/` (the default on virtually every distro). If `sudo` is missing, setup still succeeds; the grant is simply inert. + +For example, this Dockerfile builds an Ubuntu 24.04 container machine image with `systemd` and common command-line tools (including `sudo`, so the passwordless-sudo grant actually works): ```dockerfile FROM ubuntu:24.04 @@ -140,10 +160,12 @@ container build -t local/ubuntu-machine:latest . container machine create local/ubuntu-machine:latest --name ubuntu ``` -By default, `container` runs a built-in setup script on first boot to provision the user described above. To use your own setup instead, add an executable script at `/etc/machine/create-user.sh` to the image. It runs once, as root, on first boot, with these variables set: +On every boot, `container` provisions the container machine user by directly editing `/etc/passwd`, `/etc/group`, and `/etc/shadow` — no distro-specific tooling (`useradd`, `adduser`, etc.) required. This is idempotent and safe to re-run: it's a no-op for anything that already exists, and it reapplies passwordless sudo access each time in case anything in the image removed it. -- `CONTAINER_GID` -- `CONTAINER_HOME` -- `CONTAINER_MACHINE_ID` -- `CONTAINER_UID` -- `CONTAINER_USER` +By default the account matches your host user (username, uid, gid, and home directory `/home/`). Override any of these at creation time: + +```bash +container machine create --user devuser --uid 1500 --gid 1600 --home /srv/devhome alpine:latest --name dev +``` + +`--user` accepts the format `name|uid[:gid]` (i.e. `name`, `uid`, `name:gid`, or `uid:gid` — the group must be numeric, since there's no existing account to resolve a group name against). `--uid`/`--gid` set the pieces `--user` didn't specify; any piece left fully unset falls back to the host user's.