This commit is contained in:
@@ -0,0 +1,130 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// The decisions in an Xcode install that are pure string and arithmetic work.
|
||||||
|
///
|
||||||
|
/// Installing Xcode into a guest is a long chain of SSH commands, and the parts
|
||||||
|
/// of it that are easy to get wrong — which `.app` came out of the archive, how
|
||||||
|
/// much disk the expansion is going to want, what `df` actually said — are all
|
||||||
|
/// decidable from text. They live here so they can be tested without a VM,
|
||||||
|
/// which is the only way they ever get tested: the surrounding code takes forty
|
||||||
|
/// minutes and a 12 GB file to run once.
|
||||||
|
public enum XcodeInstall {
|
||||||
|
|
||||||
|
// MARK: - Which app came out of the archive
|
||||||
|
|
||||||
|
/// Picks the expanded application bundle out of an `ls -d …/*.app` listing.
|
||||||
|
///
|
||||||
|
/// The archive's payload is *not* reliably named `Xcode.app`. Beta releases
|
||||||
|
/// expand to `Xcode-beta.app`, and Apple has shipped version-qualified names
|
||||||
|
/// before, so the name has to be discovered rather than assumed — hardcoding
|
||||||
|
/// it is what made a successful 12 GB upload and a 30-minute expansion fail
|
||||||
|
/// on the very last `mv`.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - listing: Standard output of `ls -d <staging>/*.app`.
|
||||||
|
/// - staging: The directory that was listed, named in error messages.
|
||||||
|
/// - Returns: The full path of the single matching bundle.
|
||||||
|
/// - Throws: ``CoreError/provisioningFailed(_:)`` for zero or several
|
||||||
|
/// matches. Both are ambiguous rather than recoverable: picking one of two
|
||||||
|
/// candidates would install something the operator did not ask for.
|
||||||
|
public static func expandedAppPath(fromListing listing: String, staging: String) throws -> String {
|
||||||
|
let candidates = listing
|
||||||
|
.split(separator: "\n")
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
// An unmatched glob is echoed back verbatim by /bin/sh, so the
|
||||||
|
// no-match case arrives looking like a path that ends in `*.app`.
|
||||||
|
.filter { !$0.contains("*") }
|
||||||
|
|
||||||
|
switch candidates.count {
|
||||||
|
case 1:
|
||||||
|
return candidates[0]
|
||||||
|
case 0:
|
||||||
|
throw CoreError.provisioningFailed(
|
||||||
|
"the Xcode archive expanded but produced no .app in \(staging). "
|
||||||
|
+ "The download may be truncated — check the .xip and try again."
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
let names = candidates.map { ($0 as NSString).lastPathComponent }
|
||||||
|
throw CoreError.provisioningFailed(
|
||||||
|
"the Xcode archive expanded to \(candidates.count) applications in \(staging) "
|
||||||
|
+ "(\(names.joined(separator: ", "))), so it is not clear which to install. "
|
||||||
|
+ "Expand the .xip by hand and pass a single-application archive."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Disk
|
||||||
|
|
||||||
|
/// How much room the expansion of an archive is expected to need, excluding
|
||||||
|
/// the archive itself.
|
||||||
|
///
|
||||||
|
/// A `.xip` is an LZMA-compressed cpio of the whole application, and Xcode
|
||||||
|
/// compresses well: recent releases land near 3.5× on expansion. This is an
|
||||||
|
/// estimate used for a pre-flight, so it is deliberately the ratio at the
|
||||||
|
/// pessimistic end of what has been observed rather than an average — the
|
||||||
|
/// cost of overestimating is a clear error message, and the cost of
|
||||||
|
/// underestimating is a guest that runs out of disk 35 minutes in.
|
||||||
|
public static func expansionEstimateBytes(xipBytes: Int) -> Int {
|
||||||
|
(xipBytes * 7) / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total free space the guest needs before the upload starts: the uploaded
|
||||||
|
/// archive plus everything it expands into.
|
||||||
|
///
|
||||||
|
/// Both have to coexist — `xip --expand` reads the archive while it writes —
|
||||||
|
/// and the archive is deleted as soon as the expansion succeeds, before the
|
||||||
|
/// move, which is a same-volume rename that needs no headroom of its own.
|
||||||
|
public static func requiredFreeBytes(xipBytes: Int) -> Int {
|
||||||
|
xipBytes + expansionEstimateBytes(xipBytes: xipBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the available-bytes column out of `df -Pk` output.
|
||||||
|
///
|
||||||
|
/// `-P` matters: without it `df` wraps a long device name onto its own line
|
||||||
|
/// and the columns stop lining up. `-k` fixes the block size at 1024, so the
|
||||||
|
/// value does not depend on the guest's `BLOCKSIZE`.
|
||||||
|
///
|
||||||
|
/// - Returns: Free bytes, or `nil` if the output was not in the expected
|
||||||
|
/// shape — the caller treats that as "could not check" rather than as a
|
||||||
|
/// failure, since refusing to install because `df` was unparseable would
|
||||||
|
/// be worse than the risk it guards against.
|
||||||
|
public static func availableBytes(dfOutput: String) -> Int? {
|
||||||
|
for line in dfOutput.split(separator: "\n") {
|
||||||
|
let fields = line.split(whereSeparator: \.isWhitespace)
|
||||||
|
guard fields.count >= 4, fields[0] != "Filesystem",
|
||||||
|
let kilobytes = Int(fields[3])
|
||||||
|
else { continue }
|
||||||
|
return kilobytes * 1024
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders a byte count the way the progress lines do, e.g. `12.4 GB`.
|
||||||
|
///
|
||||||
|
/// Decimal gigabytes, matching how the archives are advertised and how
|
||||||
|
/// Finder reports them, so the number in an error message is the number the
|
||||||
|
/// operator can see on their own disk.
|
||||||
|
public static func formatGB(_ bytes: Int) -> String {
|
||||||
|
String(format: "%.1f GB", Double(bytes) / 1_000_000_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The message shown when the guest cannot fit the install.
|
||||||
|
///
|
||||||
|
/// Built here, with the numbers spelled out, because "no space left on
|
||||||
|
/// device" 35 minutes into an expansion tells the operator nothing about how
|
||||||
|
/// much bigger the image needed to be.
|
||||||
|
public static func insufficientDiskMessage(xipBytes: Int, availableBytes: Int) -> String {
|
||||||
|
let needed = requiredFreeBytes(xipBytes: xipBytes)
|
||||||
|
return """
|
||||||
|
not enough free disk in the guest to install Xcode: \
|
||||||
|
\(formatGB(availableBytes)) available, about \(formatGB(needed)) needed \
|
||||||
|
(\(formatGB(xipBytes)) for the archive plus roughly \
|
||||||
|
\(formatGB(expansionEstimateBytes(xipBytes: xipBytes))) once expanded).
|
||||||
|
|
||||||
|
Rebuild the base image with a larger disk, e.g.:
|
||||||
|
gitea-macos-runner image delete <name>
|
||||||
|
gitea-macos-runner image build --ipsw <path> --disk-gb 200
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -304,53 +304,134 @@ public struct GuestProvisioner: Sendable {
|
|||||||
/// running `xcodebuild -runFirstLaunch` so the first job does not pay for
|
/// running `xcodebuild -runFirstLaunch` so the first job does not pay for
|
||||||
/// component installation.
|
/// 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:
|
/// - Parameters:
|
||||||
/// - executor: A connected guest executor.
|
/// - executor: A connected guest executor.
|
||||||
/// - xipPath: Path to the `.xip` **on the host**; it is uploaded.
|
/// - xipPath: Path to the `.xip` **on the host**; it is uploaded.
|
||||||
public func installXcode(executor: any GuestExecutor, xipPath: String) async throws {
|
/// - 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)
|
let localURL = URL(fileURLWithPath: (xipPath as NSString).expandingTildeInPath)
|
||||||
guard FileManager.default.fileExists(atPath: localURL.path) else {
|
guard FileManager.default.fileExists(atPath: localURL.path) else {
|
||||||
throw CoreError.notFound("Xcode .xip not found at \(localURL.path)")
|
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 remoteXIP = "/tmp/Xcode.xip"
|
||||||
// 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.
|
|
||||||
try await executor.runChecked("rm -f \(Self.shellQuote(remoteXIP))", timeout: .seconds(120))
|
|
||||||
try await Self.uploadLargeFile(executor: executor, localURL: localURL, remotePath: remoteXIP)
|
|
||||||
|
|
||||||
// Free the disk the old copy occupies before expanding into ~40 GB more.
|
|
||||||
_ = try? await executor.run("sudo -n rm -rf /Applications/Xcode.app", timeout: .seconds(600))
|
|
||||||
|
|
||||||
let staging = "/tmp/xcode-expand"
|
let staging = "/tmp/xcode-expand"
|
||||||
// `xip --expand` writes into the current directory and needs no sudo, but
|
let quotedXIP = Self.shellQuote(remoteXIP)
|
||||||
// /tmp is small on some layouts; staging under /tmp keeps it beside the
|
let quotedStaging = Self.shellQuote(staging)
|
||||||
// archive so the later move is a rename within one volume where possible.
|
|
||||||
try await executor.runChecked(
|
|
||||||
"rm -rf \(Self.shellQuote(staging)) && mkdir -p \(Self.shellQuote(staging))",
|
|
||||||
timeout: .seconds(300)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Expansion of a full Xcode takes 20–45 minutes on VM-backed storage.
|
// Is a previous run's expansion still sitting there, complete? Then the
|
||||||
try await executor.runChecked(
|
// upload and the expansion — between them the entire cost of this
|
||||||
"cd \(Self.shellQuote(staging)) && sudo -n /usr/bin/xip --expand \(Self.shellQuote(remoteXIP))",
|
// function — are already paid for. Opportunistic only: macOS clears /tmp
|
||||||
timeout: .seconds(5400)
|
// 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.
|
// Writing into /Applications needs root.
|
||||||
|
progress?("installing \(appName)…")
|
||||||
try await executor.runChecked(
|
try await executor.runChecked(
|
||||||
"sudo -n mv \(Self.shellQuote(staging + "/Xcode.app")) /Applications/Xcode.app "
|
"sudo -n mv \(Self.shellQuote(sourceApp)) \(quotedDestination)",
|
||||||
+ "&& sudo -n rm -rf \(Self.shellQuote(staging)) \(Self.shellQuote(remoteXIP))",
|
|
||||||
timeout: .seconds(1800)
|
timeout: .seconds(1800)
|
||||||
)
|
)
|
||||||
|
_ = try? await executor.run("rm -rf \(quotedStaging)", timeout: .seconds(600))
|
||||||
|
|
||||||
// xcode-select writes /var/db/xcode_select_link — root only.
|
// 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(
|
try await executor.runChecked(
|
||||||
"sudo -n /usr/bin/xcode-select -s /Applications/Xcode.app/Contents/Developer",
|
"sudo -n /usr/bin/xcode-select -s "
|
||||||
|
+ Self.shellQuote(destination + "/Contents/Developer"),
|
||||||
timeout: .seconds(300)
|
timeout: .seconds(300)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -361,6 +442,7 @@ public struct GuestProvisioner: Sendable {
|
|||||||
"sudo -n /usr/bin/xcodebuild -license accept",
|
"sudo -n /usr/bin/xcodebuild -license accept",
|
||||||
timeout: .seconds(600)
|
timeout: .seconds(600)
|
||||||
)
|
)
|
||||||
|
progress?("running xcodebuild -runFirstLaunch (installs simulators; 10-30 min)…")
|
||||||
try await executor.runChecked(
|
try await executor.runChecked(
|
||||||
"sudo -n /usr/bin/xcodebuild -runFirstLaunch",
|
"sudo -n /usr/bin/xcodebuild -runFirstLaunch",
|
||||||
timeout: .seconds(3600)
|
timeout: .seconds(3600)
|
||||||
@@ -373,6 +455,82 @@ public struct GuestProvisioner: Sendable {
|
|||||||
+ Self.tail(check.stderr.isEmpty ? check.stdout : check.stderr)
|
+ 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.
|
/// The Node.js version installed when none is specified.
|
||||||
|
|||||||
@@ -672,10 +672,14 @@ public struct ImageBuilder: Sendable {
|
|||||||
|
|
||||||
if let xcodeXIPPath {
|
if let xcodeXIPPath {
|
||||||
progress?(.provisioning(step: "Xcode"))
|
progress?(.provisioning(step: "Xcode"))
|
||||||
|
// Xcode is the one step measured in hours, so it reports its own
|
||||||
|
// sub-stages rather than going quiet behind a single headline.
|
||||||
try await provisioner.installXcode(
|
try await provisioner.installXcode(
|
||||||
executor: executor,
|
executor: executor,
|
||||||
xipPath: (xcodeXIPPath as NSString).expandingTildeInPath
|
xipPath: (xcodeXIPPath as NSString).expandingTildeInPath
|
||||||
)
|
) { step in
|
||||||
|
progress?(.provisioning(step: step))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
await executor.close()
|
await executor.close()
|
||||||
|
|||||||
@@ -309,13 +309,23 @@ struct ImageCommand: AsyncParsableCommand {
|
|||||||
case .installing: return "install"
|
case .installing: return "install"
|
||||||
case .firstBoot: return "firstBoot"
|
case .firstBoot: return "firstBoot"
|
||||||
// Each provisioning step is its own headline — "installing Node.js"
|
// Each provisioning step is its own headline — "installing Node.js"
|
||||||
// should not erase "downloading gitea-runner".
|
// should not erase "downloading gitea-runner" — but a step that carries
|
||||||
case .provisioning(let step): return "provisioning:\(step)"
|
// a live percentage keeps rewriting one line rather than scrolling a
|
||||||
|
// hundred of them, so only the part before the payload identifies it.
|
||||||
|
case .provisioning(let step): return "provisioning:\(Self.stableHead(of: step))"
|
||||||
case .finalizing: return "finalizing"
|
case .finalizing: return "finalizing"
|
||||||
case .done: return "done"
|
case .done: return "done"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The fixed part of a status line: everything before the two-space run that
|
||||||
|
/// separates a headline from its payload, following the same convention as
|
||||||
|
/// `installing macOS [====]`. A step with no payload is its own head.
|
||||||
|
static func stableHead(of step: String) -> String {
|
||||||
|
guard let separator = step.range(of: " ") else { return step }
|
||||||
|
return String(step[step.startIndex..<separator.lowerBound])
|
||||||
|
}
|
||||||
|
|
||||||
/// Renders a build stage as one status line.
|
/// Renders a build stage as one status line.
|
||||||
static func describe(_ stage: ImageBuildStage) -> String {
|
static func describe(_ stage: ImageBuildStage) -> String {
|
||||||
switch stage {
|
switch stage {
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import RunnerCore
|
||||||
|
|
||||||
|
/// Tests for ``XcodeInstall`` — the decidable parts of installing Xcode.
|
||||||
|
///
|
||||||
|
/// The bug these exist for: the installer assumed the archive expanded to
|
||||||
|
/// `Xcode.app`. A beta expands to `Xcode-beta.app`, so a 12 GB upload and a
|
||||||
|
/// half-hour expansion both succeeded and then the final `mv` failed with
|
||||||
|
/// "No such file or directory". Nothing about that is worth discovering from a
|
||||||
|
/// forty-minute live run twice.
|
||||||
|
@Suite("XcodeInstall")
|
||||||
|
struct XcodeInstallTests {
|
||||||
|
|
||||||
|
// MARK: - Discovering the expanded app
|
||||||
|
|
||||||
|
@Test("a release archive's Xcode.app is found")
|
||||||
|
func findsReleaseApp() throws {
|
||||||
|
let path = try XcodeInstall.expandedAppPath(
|
||||||
|
fromListing: "/tmp/xcode-expand/Xcode.app\n", staging: "/tmp/xcode-expand")
|
||||||
|
#expect(path == "/tmp/xcode-expand/Xcode.app")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The reported failure, in one line.
|
||||||
|
@Test("a beta archive's Xcode-beta.app is found")
|
||||||
|
func findsBetaApp() throws {
|
||||||
|
let path = try XcodeInstall.expandedAppPath(
|
||||||
|
fromListing: "/tmp/xcode-expand/Xcode-beta.app", staging: "/tmp/xcode-expand")
|
||||||
|
#expect(path == "/tmp/xcode-expand/Xcode-beta.app")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("a version-qualified name is found")
|
||||||
|
func findsVersionQualifiedApp() throws {
|
||||||
|
let path = try XcodeInstall.expandedAppPath(
|
||||||
|
fromListing: " /tmp/xcode-expand/Xcode_16.2.app \n\n", staging: "/tmp/xcode-expand")
|
||||||
|
#expect(path == "/tmp/xcode-expand/Xcode_16.2.app")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `/bin/sh` echoes an unmatched glob back verbatim, so "no match" arrives
|
||||||
|
/// as a plausible-looking path rather than as empty output.
|
||||||
|
@Test("an unmatched glob reads as no match, not as a path")
|
||||||
|
func unmatchedGlobIsNotAPath() {
|
||||||
|
#expect(throws: CoreError.self) {
|
||||||
|
try XcodeInstall.expandedAppPath(
|
||||||
|
fromListing: "/tmp/xcode-expand/*.app\n", staging: "/tmp/xcode-expand")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("empty output is an error naming the staging directory")
|
||||||
|
func emptyListingThrows() {
|
||||||
|
do {
|
||||||
|
_ = try XcodeInstall.expandedAppPath(fromListing: "\n \n", staging: "/tmp/xcode-expand")
|
||||||
|
Issue.record("expected a failure")
|
||||||
|
} catch {
|
||||||
|
#expect("\(error)".contains("/tmp/xcode-expand"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Guessing between two candidates would install something the operator did
|
||||||
|
/// not ask for, so this stops instead.
|
||||||
|
@Test("several apps is an error listing them")
|
||||||
|
func ambiguousListingThrows() {
|
||||||
|
do {
|
||||||
|
_ = try XcodeInstall.expandedAppPath(
|
||||||
|
fromListing: "/tmp/x/Xcode.app\n/tmp/x/Xcode-beta.app\n", staging: "/tmp/x")
|
||||||
|
Issue.record("expected a failure")
|
||||||
|
} catch {
|
||||||
|
let text = "\(error)"
|
||||||
|
#expect(text.contains("Xcode.app"))
|
||||||
|
#expect(text.contains("Xcode-beta.app"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Disk arithmetic
|
||||||
|
|
||||||
|
@Test("the requirement covers the archive and its expansion")
|
||||||
|
func requiredFreeSpaceCoversBoth() {
|
||||||
|
let xip = 12_000_000_000
|
||||||
|
#expect(XcodeInstall.expansionEstimateBytes(xipBytes: xip) == 42_000_000_000)
|
||||||
|
#expect(XcodeInstall.requiredFreeBytes(xipBytes: xip) == 54_000_000_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("df -Pk output yields available bytes")
|
||||||
|
func parsesDF() {
|
||||||
|
let output = """
|
||||||
|
Filesystem 1024-blocks Used Available Capacity Mounted on
|
||||||
|
/dev/disk3s5 488245288 120000000 62914560 66% /
|
||||||
|
"""
|
||||||
|
#expect(XcodeInstall.availableBytes(dfOutput: output) == 62_914_560 * 1024)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unparseable output means "could not check", not "no space" — the caller
|
||||||
|
/// must not refuse to install because `df` printed something unexpected.
|
||||||
|
@Test("unparseable df output yields nil rather than zero")
|
||||||
|
func unparseableDFIsNil() {
|
||||||
|
#expect(XcodeInstall.availableBytes(dfOutput: "df: /nope: No such file or directory") == nil)
|
||||||
|
#expect(XcodeInstall.availableBytes(dfOutput: "") == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("the shortfall message names every number and a remedy")
|
||||||
|
func messageNamesTheNumbers() {
|
||||||
|
let text = XcodeInstall.insufficientDiskMessage(
|
||||||
|
xipBytes: 12_000_000_000, availableBytes: 20_000_000_000)
|
||||||
|
#expect(text.contains("20.0 GB")) // available
|
||||||
|
#expect(text.contains("54.0 GB")) // needed
|
||||||
|
#expect(text.contains("12.0 GB")) // the archive
|
||||||
|
#expect(text.contains("--disk-gb"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("byte counts render as decimal gigabytes")
|
||||||
|
func formatsGigabytes() {
|
||||||
|
#expect(XcodeInstall.formatGB(12_400_000_000) == "12.4 GB")
|
||||||
|
#expect(XcodeInstall.formatGB(0) == "0.0 GB")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user