import Foundation import RunnerCore /// Turns a bare macOS guest into something that can execute Gitea Actions jobs. /// /// ## What a guest actually needs /// /// Gitea Actions in `host` schema does not containerize anything: it shells out /// on the guest. The hard requirements are therefore small but non-negotiable: /// /// * **`gitea-runner`** — the runner binary itself (v3.x; renamed from /// `act_runner`, now published from `gitea.com/gitea/runner`). /// * **`node`** — not optional. JavaScript actions such as `actions/checkout` /// are executed by spawning `node` directly; without it, essentially every /// real workflow fails at its first step. Installed from Apple's official /// arm64 `.pkg` via `installer -pkg`. /// * **`git`** and **`bash`** — present on stock macOS, but `git` only after the /// Command Line Tools are materialized, so presence is verified rather than /// assumed. /// * **A writable `$HOME`** — the runner writes its registration and workspace /// under the guest account's home directory. /// /// ## What else provisioning does /// /// Everything in `Resources/provision.sh`: a passwordless-sudo drop-in /// installed through `visudo -cf` (validated before it is moved into place, so a /// syntax error cannot lock the account out), disabling sleep/screensaver so a /// long job is not interrupted, disabling Spotlight indexing of build /// directories, raising `maxfiles` (Xcode and npm both exhaust the stock 256), /// and pre-seeding `github.com` into `known_hosts` so a checkout does not stall /// on host-key confirmation. /// /// - Note: The guest must **never** ship a `gitea-runner` `config.yaml` that /// sets `runner.labels`: that key silently overrides the `--labels` passed at /// registration, so the runner would advertise the wrong labels and never be /// matched. public struct GuestProvisioner: Sendable { /// Creates a provisioner. public init() {} /// Runs the full provisioning sequence against a booted guest. /// /// Steps, in order: /// 1. Upload `Resources/provision.sh` to `/tmp/provision.sh`, `chmod +x`, /// and run it under `sudo` with the guest username and password passed /// via the environment (never as arguments, which are world-visible in /// `ps`). /// 2. Download the Node.js arm64 `.pkg` on the **host**, upload it, and /// `installer -pkg … -target /`. Downloading host-side keeps the guest /// off the public internet for this step and makes the version pinnable. /// 3. Verify `git`, `bash`, and `node` all resolve. /// 4. Download the `gitea-runner` darwin-arm64 release asset on the host /// (URL from ``RunnerConfig/RunnerSection/resolvedDownloadURL``), upload /// it to `/usr/local/bin/gitea-runner`, and `chmod +x`. /// 5. Verify `gitea-runner --version` runs. /// /// - Parameters: /// - executor: A connected guest executor. /// - config: Supplies guest credentials and the runner download URL. /// - progress: Optional per-step callback, for `image build` output. /// - Throws: ``CoreError/provisioningFailed(_:)`` naming the failed step. public func provision( executor: any GuestExecutor, config: RunnerConfig, progress: (@Sendable (String) -> Void)? = nil ) async throws { progress?("system configuration (provision.sh)") try await runProvisionScript(executor: executor, config: config) progress?("Node.js \(Self.defaultNodeVersion)") try await installNode(executor: executor) progress?("verifying toolchain") try await verifyToolchain(executor: executor) progress?("gitea-runner \(config.runner.version)") try await installGiteaRunner(executor: executor, config: config) } /// Uploads and executes `Resources/provision.sh`. /// /// - Parameters: /// - executor: A connected guest executor. /// - config: Guest credentials. public func runProvisionScript( executor: any GuestExecutor, config: RunnerConfig ) async throws { let scriptURL = try Self.provisionScriptURL() do { try await executor.upload(localPath: scriptURL.path, remotePath: "/tmp/provision.sh") } catch { throw CoreError.provisioningFailed( "could not upload provision.sh from \(scriptURL.path): \(error)" ) } // The account password has to reach `sudo -S` somehow, and every obvious // route leaks it: as an argument it is visible in `ps` to any process on // the guest, and `echo pw | sudo -S` puts it in the shell's own argv, // which is exactly the same exposure. A mode-0600 file read via stdin // redirection is the one form that never appears in an argument list; it // is removed in the same command, so it does not outlive the call even if // the script fails. try await executor.uploadData( Data((config.guest.password + "\n").utf8), remotePath: "/tmp/.gmr-auth", mode: "0600" ) let giteaHost = config.gitea.instanceURL.host ?? "" // `sudo VAR=value cmd` is how variables survive sudo's env_reset; a // plain `VAR=value sudo cmd` would be stripped. Neither the username nor // the Gitea hostname is secret, so argv exposure is fine for these. let command = """ sudo -S -p '' \ GUEST_USER=\(Self.shellQuote(config.guest.username)) \ GITEA_HOST=\(Self.shellQuote(giteaHost)) \ /bin/bash /tmp/provision.sh < /tmp/.gmr-auth; \ rc=$?; rm -f /tmp/.gmr-auth /tmp/provision.sh; exit $rc """ // Generous: the Command Line Tools download inside the script is the // long pole and is itself bounded at 45 minutes. let result = try await executor.run(command, timeout: .seconds(3600)) guard result.succeeded else { throw CoreError.provisioningFailed( "provision.sh failed (exit \(result.exitCode))\n" + Self.tail(result.stderr.isEmpty ? result.stdout : result.stderr) ) } // The script warns rather than aborts on best-effort steps, so a zero // exit alone does not prove it ran to the end — a truncated SSH channel // would also look like success. The marker is the actual proof. guard result.stdout.contains("PROVISION_OK") else { throw CoreError.provisioningFailed( "provision.sh exited 0 but never printed PROVISION_OK; it did not run to completion\n" + Self.tail(result.stdout) ) } } /// Installs Node.js from the official arm64 package. /// /// - Parameters: /// - executor: A connected guest executor. /// - version: Node major/minor/patch, e.g. `22.11.0`. /// - packageURL: Overrides the derived download URL entirely. Used by /// ``resolveLatestLTSNodeVersion()`` callers and by air-gapped setups /// pointing at an internal mirror. public func installNode( executor: any GuestExecutor, version: String = GuestProvisioner.defaultNodeVersion, packageURL: URL? = nil ) async throws { guard let url = packageURL ?? Self.nodePackageURL(version: version) else { throw CoreError.configInvalid("cannot form a Node.js package URL for version \(version)") } // Downloaded host-side rather than by the guest: the version is then // pinned by the host's config, the guest needs no outbound access for // this step, and a rebuild of ten images hits the host's cache instead of // nodejs.org ten times. let local = try await Self.downloadToTemporaryFile(url: url, suggestedName: "node.pkg") defer { try? FileManager.default.removeItem(at: local) } do { try await executor.upload(localPath: local.path, remotePath: "/tmp/node.pkg") } catch { throw CoreError.provisioningFailed("could not upload the Node.js package: \(error)") } let install = try await executor.run( "sudo -n /usr/sbin/installer -pkg /tmp/node.pkg -target /; rc=$?; rm -f /tmp/node.pkg; exit $rc", timeout: .seconds(900) ) guard install.succeeded else { throw CoreError.provisioningFailed( "installing Node.js failed (exit \(install.exitCode))\n" + Self.tail(install.stderr.isEmpty ? install.stdout : install.stderr) ) } let check = try await executor.run(Self.withGuestPath("node --version"), timeout: .seconds(120)) guard check.succeeded else { throw CoreError.provisioningFailed( "Node.js installed but `node --version` failed (exit \(check.exitCode)). " + "Gitea's JavaScript actions spawn `node` directly, so this image would fail " + "every workflow that uses actions/checkout.\n" + Self.tail(check.stderr.isEmpty ? check.stdout : check.stderr) ) } } /// Verifies that `git`, `bash`, and `node` are all present and executable. /// /// - Throws: ``CoreError/provisioningFailed(_:)`` naming what is missing. public func verifyToolchain(executor: any GuestExecutor) async throws { // Order matters. On a vanilla guest `/usr/bin/git` is a shim that pops a // GUI "install command line developer tools" dialog and blocks until // someone clicks it — which, headless, is never. So the presence of a // real git is established from the *package receipt* first, and `git` // itself is only invoked once that check passes. let hasTools = try await executor.run( "pkgutil --pkg-info=com.apple.pkg.CLTools_Executables >/dev/null 2>&1 " + "|| [ -x /Applications/Xcode.app/Contents/Developer/usr/bin/git ]", timeout: .seconds(120) ) guard hasTools.succeeded else { throw CoreError.provisioningFailed( """ the guest has no Command Line Tools, so `git` is only a stub that blocks on a \ GUI installer dialog. provision.sh attempted a non-interactive install and it \ did not take. Install a real toolchain instead: gitea-macos-runner image provision --xcode-xip /path/to/Xcode.xip (Shipping the image without git would fail every checkout at job time rather \ than here, so the build stops now.) """ ) } for (tool, command) in [ ("git", "git --version"), ("bash", "bash --version"), ("node", "node --version"), ] { let result = try await executor.run(Self.withGuestPath(command), timeout: .seconds(120)) guard result.succeeded else { throw CoreError.provisioningFailed( "required tool `\(tool)` is not usable in the guest (`\(command)` exited \(result.exitCode))\n" + Self.tail(result.stderr.isEmpty ? result.stdout : result.stderr) ) } } } /// Downloads the `gitea-runner` release asset on the host and installs it /// into the guest at `/usr/local/bin/gitea-runner`. /// /// - Parameters: /// - executor: A connected guest executor. /// - config: Supplies the download URL template and version. public func installGiteaRunner( executor: any GuestExecutor, config: RunnerConfig ) async throws { let url = try config.runner.resolvedDownloadURL let local = try await Self.downloadToTemporaryFile(url: url, suggestedName: "gitea-runner") defer { try? FileManager.default.removeItem(at: local) } do { try await executor.upload(localPath: local.path, remotePath: "/tmp/gitea-runner") } catch { throw CoreError.provisioningFailed("could not upload the gitea-runner binary: \(error)") } try await executor.runChecked( "sudo -n /usr/bin/install -o root -g wheel -m 755 /tmp/gitea-runner /usr/local/bin/gitea-runner " + "&& rm -f /tmp/gitea-runner", timeout: .seconds(300) ) // arm64 macOS refuses to exec a binary with no code signature at all // (SIGKILL, no diagnostic). Release tarballs are usually ad-hoc signed // already, in which case re-signing is a no-op; when they are not, this // is what keeps the runner from being killed on its first invocation. // Best-effort: `codesign` needs the Command Line Tools, and a signature // that was already valid does not need replacing. _ = try? await executor.run( "sudo -n /usr/bin/codesign --force --sign - /usr/local/bin/gitea-runner", timeout: .seconds(300) ) let check = try await executor.run( Self.withGuestPath("gitea-runner --version"), timeout: .seconds(120) ) guard check.succeeded else { throw CoreError.provisioningFailed( "gitea-runner installed from \(url.absoluteString) but `gitea-runner --version` " + "failed (exit \(check.exitCode))\n" + Self.tail(check.stderr.isEmpty ? check.stdout : check.stderr) ) } } /// Installs Xcode from a `.xip` into the guest — the optional heavy step. /// /// Xcode is not installed by default: the `.xip` is ~8 GB and expanding it /// roughly triples the base image, which is a poor default for a workflow /// that only needs `swift build`. Operators who need it run /// `image provision NAME --xcode-xip PATH` once, after which every clone /// inherits it. /// /// Expansion uses `xip --expand` inside the guest, followed by /// `xcode-select -s` and `xcodebuild -license accept`, and finishes by /// running `xcodebuild -runFirstLaunch` so the first job does not pay for /// component installation. /// /// The application's name is **discovered, not assumed**. A release archive /// expands to `Xcode.app`, a beta to `Xcode-beta.app`, and Apple has shipped /// version-qualified names too; whatever comes out keeps its name under /// `/Applications`, because `xcode-select -s` makes the name irrelevant to /// anything that builds. /// /// - Parameters: /// - executor: A connected guest executor. /// - xipPath: Path to the `.xip` **on the host**; it is uploaded. /// - progress: Optional stage callback. Every phase here runs for tens of /// minutes, so silence is indistinguishable from a hang — this is the /// only thing that says otherwise. public func installXcode( executor: any GuestExecutor, xipPath: String, progress: (@Sendable (String) -> Void)? = nil ) async throws { let localURL = URL(fileURLWithPath: (xipPath as NSString).expandingTildeInPath) guard FileManager.default.fileExists(atPath: localURL.path) else { throw CoreError.notFound("Xcode .xip not found at \(localURL.path)") } let localBytes = (try? FileManager.default.attributesOfItem(atPath: localURL.path))?[.size] as? Int ?? 0 let remoteXIP = "/tmp/Xcode.xip" let staging = "/tmp/xcode-expand" let quotedXIP = Self.shellQuote(remoteXIP) let quotedStaging = Self.shellQuote(staging) // Is a previous run's expansion still sitting there, complete? Then the // upload and the expansion — between them the entire cost of this // function — are already paid for. Opportunistic only: macOS clears /tmp // on boot, so after the guest has been power-cycled this finds nothing, // which is fine. var expandedApp = try await Self.reusableExpandedApp(executor: executor, staging: staging) if let expandedApp { progress?("reusing expanded \((expandedApp as NSString).lastPathComponent)") } else { try await Self.checkGuestDisk(executor: executor, xipBytes: localBytes, progress: progress) if try await Self.hasMatchingUpload( executor: executor, remotePath: remoteXIP, expectedBytes: localBytes) { progress?("reusing uploaded xip (\(XcodeInstall.formatGB(localBytes)))") } else { // Uploads go over an SSH exec channel with the payload as stdin, // and `GuestExecutor.upload` reads the whole local file into // memory first — fine for a 90 MB pkg, ruinous for a 12 GB xip. // So this streams the file in bounded chunks and appends them // guest-side instead. It is still slow (an exec channel is not // SCP), but it is functional and its host memory use is capped at // one chunk. let headline = "uploading Xcode (\(XcodeInstall.formatGB(localBytes)))" progress?(headline + " 0%") try await executor.runChecked("rm -f \(quotedXIP)", timeout: .seconds(120)) try await Self.uploadLargeFile( executor: executor, localURL: localURL, remotePath: remoteXIP, progress: { fraction in progress?(headline + " \(Int(fraction * 100))%") } ) progress?(headline + " 100%") } // A half-finished expansion from an earlier attempt would leave // `ls *.app` ambiguous, or leave a truncated bundle to be installed. // Clear it before, not after. try await executor.runChecked( "rm -rf \(quotedStaging) && mkdir -p \(quotedStaging)", timeout: .seconds(600)) // `xip --expand` writes into the current directory and needs no sudo, // but staging beside the archive keeps the later move a rename within // one volume. Expansion of a full Xcode takes 20–45 minutes on // VM-backed storage. progress?("expanding xip (takes 15-40 min)…") try await executor.runChecked( "cd \(quotedStaging) && sudo -n /usr/bin/xip --expand \(quotedXIP)", timeout: .seconds(5400) ) // Immediately, and unconditionally on success: the archive is dead // weight from here on, and the guest is at its tightest right now // holding both copies. Deleting it as part of a success-only `&&` // chain at the very end — which is what this used to do — means a // failure anywhere later strands 12 GB in /tmp. _ = try? await executor.run("rm -f \(quotedXIP)", timeout: .seconds(300)) let listing = try await executor.run( "ls -d \(quotedStaging)/*.app 2>/dev/null", timeout: .seconds(300)) expandedApp = try XcodeInstall.expandedAppPath( fromListing: listing.stdout, staging: staging) } guard let sourceApp = expandedApp else { throw CoreError.provisioningFailed("could not locate the expanded Xcode in \(staging)") } let appName = (sourceApp as NSString).lastPathComponent let destination = "/Applications/" + appName let quotedDestination = Self.shellQuote(destination) // Whatever is already there loses. This is a golden image being built to // a specification, not a user's Mac, and leaving the old copy would both // fail the move and waste tens of gigabytes in every clone. let existing = try await executor.run( "test -e \(quotedDestination) && echo present", timeout: .seconds(120)) if existing.stdout.contains("present") { progress?("replacing existing \(appName) in the guest") try await executor.runChecked( "sudo -n rm -rf \(quotedDestination)", timeout: .seconds(1800)) } // Writing into /Applications needs root. progress?("installing \(appName)…") try await executor.runChecked( "sudo -n mv \(Self.shellQuote(sourceApp)) \(quotedDestination)", timeout: .seconds(1800) ) _ = try? await executor.run("rm -rf \(quotedStaging)", timeout: .seconds(600)) // xcode-select writes /var/db/xcode_select_link — root only. Pointing it // at the discovered path is what makes the bundle's name a non-issue: // `xcodebuild`, `swift`, and every `xcrun` shim resolve through this. try await executor.runChecked( "sudo -n /usr/bin/xcode-select -s " + Self.shellQuote(destination + "/Contents/Developer"), timeout: .seconds(300) ) // Both of these write under /Library and must run as root; -runFirstLaunch // installs the bundled packages (simulators, device support) that would // otherwise be installed lazily during the first job. try await executor.runChecked( "sudo -n /usr/bin/xcodebuild -license accept", timeout: .seconds(600) ) progress?("running xcodebuild -runFirstLaunch (installs simulators; 10-30 min)…") try await executor.runChecked( "sudo -n /usr/bin/xcodebuild -runFirstLaunch", timeout: .seconds(3600) ) let check = try await executor.run("/usr/bin/xcodebuild -version", timeout: .seconds(300)) guard check.succeeded else { throw CoreError.provisioningFailed( "Xcode installed but `xcodebuild -version` failed (exit \(check.exitCode))\n" + Self.tail(check.stderr.isEmpty ? check.stdout : check.stderr) ) } // Proof, in the operator's log, that the thing they waited an hour for // is actually there and selected — on one line, since `-version` prints // two. let version = check.stdout .split(separator: "\n") .map { $0.trimmingCharacters(in: .whitespaces) } .filter { !$0.isEmpty } .joined(separator: " — ") progress?("Xcode ready: \(version) at \(destination)") } /// An already-expanded application left by an earlier attempt, if one is /// there and looks complete. /// /// "Complete" is `Contents/MacOS` existing: an expansion killed part-way /// leaves a directory tree that `ls` is perfectly happy to list, and /// installing that would produce an Xcode that fails at first use rather /// than at install time. Never throws — a guest with nothing staged is the /// normal case, and an ambiguous listing here just means "do it properly". static func reusableExpandedApp( executor: any GuestExecutor, staging: String ) async throws -> String? { let listing = try await executor.run( "ls -d \(shellQuote(staging))/*.app 2>/dev/null", timeout: .seconds(120)) guard let app = try? XcodeInstall.expandedAppPath(fromListing: listing.stdout, staging: staging) else { return nil } let complete = try await executor.run( "test -d \(shellQuote(app + "/Contents/MacOS")) && echo ok", timeout: .seconds(120)) return complete.stdout.contains("ok") ? app : nil } /// Whether the guest already holds a byte-for-byte-sized copy of the upload. /// /// Size only — hashing 12 GB over an exec channel would cost more than the /// upload it is trying to avoid. The archive is written by this code alone, /// to a fixed path, so a size match is strong enough evidence; a partial /// upload from an interrupted run is shorter and fails the check. static func hasMatchingUpload( executor: any GuestExecutor, remotePath: String, expectedBytes: Int ) async throws -> Bool { guard expectedBytes > 0 else { return false } let result = try await executor.run( "stat -f %z \(shellQuote(remotePath)) 2>/dev/null", timeout: .seconds(120)) let reported = Int(result.stdout.trimmingCharacters(in: .whitespacesAndNewlines)) return reported == expectedBytes } /// Refuses the install before the upload when the guest cannot hold it. /// /// The failure this replaces is the worst kind: `xip --expand` fills the /// disk half an hour in, and the error names neither how much was needed nor /// what to do about it. Unparseable `df` output is treated as "cannot check" /// and allowed through — a pre-flight that blocks the install because it did /// not recognise the output is worse than the problem. static func checkGuestDisk( executor: any GuestExecutor, xipBytes: Int, progress: (@Sendable (String) -> Void)? ) async throws { guard xipBytes > 0 else { return } let result = try await executor.run("df -Pk /", timeout: .seconds(120)) guard let available = XcodeInstall.availableBytes(dfOutput: result.stdout) else { return } let needed = XcodeInstall.requiredFreeBytes(xipBytes: xipBytes) guard available >= needed else { throw CoreError.provisioningFailed( XcodeInstall.insufficientDiskMessage(xipBytes: xipBytes, availableBytes: available) ) } progress?( "guest disk: \(XcodeInstall.formatGB(available)) free, " + "\(XcodeInstall.formatGB(needed)) needed") } /// The Node.js version installed when none is specified. /// /// Pinned rather than resolved at build time so that two images built weeks /// apart are identical unless someone changes this line. Use /// ``resolveLatestLTSNodeVersion()`` to look up a newer LTS deliberately. public static let defaultNodeVersion = "24.19.0" /// The official Node.js macOS arm64 package URL for a version. public static func nodePackageURL(version: String) -> URL? { URL(string: "https://nodejs.org/dist/v\(version)/node-v\(version).pkg") } /// Looks up the current Node.js LTS version from nodejs.org. /// /// Best-effort and deliberately not called by ``provision(executor:config:progress:)``: /// an image build that silently picks up a different Node depending on the /// day it ran is not reproducible. Callers that want the newest LTS pass the /// result to ``installNode(executor:version:packageURL:)`` explicitly. /// /// - Returns: The version string without the leading `v`, or `nil` if the /// index could not be read. public static func resolveLatestLTSNodeVersion() async -> String? { guard let indexURL = URL(string: "https://nodejs.org/dist/index.json") else { return nil } var request = URLRequest(url: indexURL) request.timeoutInterval = 30 guard let (data, response) = try? await URLSession.shared.data(for: request), let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode), let entries = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return nil } // The index is newest-first, and `lts` is `false` for non-LTS releases // and the codename string ("Krypton") for LTS ones. for entry in entries { guard let version = entry["version"] as? String else { continue } if entry["lts"] is String { return String(version.dropFirst()) // "v24.19.0" -> "24.19.0" } } return nil } // MARK: - Locating provision.sh /// Finds `Resources/provision.sh`. /// /// The package declares no SwiftPM `resources:`, so `Bundle.module` does not /// exist and the script has to be located by hand. Three deployments matter: /// the signed `.app` the daemon actually runs from (`Contents/Resources`), a /// bare `swift build` binary in `.build/debug`, and a `swift run` from the /// checkout. Each is tried in turn, and the error names every path searched /// so a packaging mistake is diagnosable from the message alone. /// /// - Returns: URL of the script. /// - Throws: ``CoreError/notFound(_:)`` listing the searched paths. public static func provisionScriptURL() throws -> URL { let fileManager = FileManager.default var searched: [URL] = [] func check(_ url: URL) -> URL? { searched.append(url) return fileManager.isReadableFile(atPath: url.path) ? url : nil } // 1. The .app's own resources, via the bundle API and by hand (the API // returns nil for a bare executable with no Info.plist). if let url = Bundle.main.url(forResource: "provision", withExtension: "sh") { searched.append(url) if fileManager.isReadableFile(atPath: url.path) { return url } } var roots: [URL] = [Bundle.main.bundleURL] if let executableDirectory = Bundle.main.executableURL? .resolvingSymlinksInPath() .deletingLastPathComponent() { roots.append(executableDirectory) } roots.append(URL(fileURLWithPath: fileManager.currentDirectoryPath)) // The checkout this file was compiled from: Sources/RunnerHost/ → // three levels up is the package root. Only useful for `swift run` during // development, hence last. roots.append( URL(fileURLWithPath: #filePath) .deletingLastPathComponent() .deletingLastPathComponent() .deletingLastPathComponent() ) for root in roots { var candidate = root.resolvingSymlinksInPath() // Walk upward: `.build/debug/gitea-macos-runner` is four levels below // the checkout root, and an .app nested in a staging directory is // similar. for _ in 0..<6 { if let found = check(candidate.appendingPathComponent("Contents/Resources/provision.sh")) { return found } if let found = check(candidate.appendingPathComponent("Resources/provision.sh")) { return found } let parent = candidate.deletingLastPathComponent() if parent.path == candidate.path { break } candidate = parent } } let list = searched.map { " \($0.path)" }.joined(separator: "\n") throw CoreError.notFound( "provision.sh could not be located. Searched:\n\(list)\n" + "When running from a bundled .app, Resources/provision.sh must be copied into " + "Contents/Resources/ by the build." ) } // MARK: - Helpers /// A PATH that includes `/usr/local/bin`. /// /// `ssh host command` runs a non-login, non-interactive shell, which never /// sources the file where `path_helper` adds `/usr/local/bin`. Both `node` /// and `gitea-runner` install there, so every command that names one is /// wrapped in this. (`provision.sh` also writes `/etc/zshenv` to fix this for /// everything else that talks to the guest.) static func withGuestPath(_ command: String) -> String { "export PATH=/usr/local/bin:/opt/homebrew/bin:$PATH; " + command } /// Wraps a value so `/bin/sh` sees it literally. static func shellQuote(_ value: String) -> String { "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" } /// Trims captured output to something a terminal error can carry. static func tail(_ output: String, lines: Int = 30) -> String { let all = output.split(separator: "\n", omittingEmptySubsequences: false) return all.suffix(lines).joined(separator: "\n") } /// Downloads a URL to a unique temporary file. /// /// - Parameters: /// - url: Source. /// - suggestedName: File name within the temporary directory. /// - Returns: The local file, which the caller owns and must delete. static func downloadToTemporaryFile(url: URL, suggestedName: String) async throws -> URL { let configuration = URLSessionConfiguration.ephemeral configuration.timeoutIntervalForRequest = 60 configuration.timeoutIntervalForResource = 60 * 60 let session = URLSession(configuration: configuration) defer { session.finishTasksAndInvalidate() } let temporary: URL let response: URLResponse do { (temporary, response) = try await session.download(from: url) } catch { throw CoreError.provisioningFailed( "download failed for \(url.absoluteString): \(error.localizedDescription)" ) } if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { try? FileManager.default.removeItem(at: temporary) throw CoreError.provisioningFailed( "download failed: HTTP \(http.statusCode) for \(url.absoluteString)" ) } let destination = FileManager.default.temporaryDirectory .appendingPathComponent("gmr-\(UUID().uuidString)-\(suggestedName)") do { try FileManager.default.moveItem(at: temporary, to: destination) } catch { try? FileManager.default.removeItem(at: temporary) throw CoreError.provisioningFailed( "could not stage the download from \(url.absoluteString): \(error.localizedDescription)" ) } return destination } /// Uploads a file too large to hold in memory, one chunk at a time. /// /// ``GuestExecutor/upload(localPath:remotePath:)`` slurps the whole file, so /// a multi-gigabyte Xcode archive would exhaust host memory before a byte /// moved. Each chunk is written to a scratch path and appended guest-side, /// which keeps both ends bounded. static func uploadLargeFile( executor: any GuestExecutor, localURL: URL, remotePath: String, chunkBytes: Int = 128 * 1024 * 1024, progress: (@Sendable (Double) -> Void)? = nil ) async throws { let handle = try FileHandle(forReadingFrom: localURL) defer { try? handle.close() } let attributes = try? FileManager.default.attributesOfItem(atPath: localURL.path) let totalBytes = attributes?[.size] as? Int let quotedRemote = shellQuote(remotePath) let scratch = remotePath + ".part" let quotedScratch = shellQuote(scratch) var sent = 0 while true { let chunk = try handle.read(upToCount: chunkBytes) ?? Data() if chunk.isEmpty { break } try await executor.uploadData(chunk, remotePath: scratch, mode: "0644") try await executor.runChecked( "cat \(quotedScratch) >> \(quotedRemote) && rm -f \(quotedScratch)", timeout: .seconds(600) ) sent += chunk.count if let totalBytes, totalBytes > 0 { progress?(min(Double(sent) / Double(totalBytes), 1)) } } } }