This commit is contained in:
@@ -304,53 +304,134 @@ public struct GuestProvisioner: Sendable {
|
||||
/// 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.
|
||||
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)
|
||||
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"
|
||||
// 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"
|
||||
// `xip --expand` writes into the current directory and needs no sudo, but
|
||||
// /tmp is small on some layouts; staging under /tmp keeps it beside the
|
||||
// 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)
|
||||
)
|
||||
let quotedXIP = Self.shellQuote(remoteXIP)
|
||||
let quotedStaging = Self.shellQuote(staging)
|
||||
|
||||
// Expansion of a full Xcode takes 20–45 minutes on VM-backed storage.
|
||||
try await executor.runChecked(
|
||||
"cd \(Self.shellQuote(staging)) && sudo -n /usr/bin/xip --expand \(Self.shellQuote(remoteXIP))",
|
||||
timeout: .seconds(5400)
|
||||
)
|
||||
// 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(staging + "/Xcode.app")) /Applications/Xcode.app "
|
||||
+ "&& sudo -n rm -rf \(Self.shellQuote(staging)) \(Self.shellQuote(remoteXIP))",
|
||||
"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.
|
||||
// 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 /Applications/Xcode.app/Contents/Developer",
|
||||
"sudo -n /usr/bin/xcode-select -s "
|
||||
+ Self.shellQuote(destination + "/Contents/Developer"),
|
||||
timeout: .seconds(300)
|
||||
)
|
||||
|
||||
@@ -361,6 +442,7 @@ public struct GuestProvisioner: Sendable {
|
||||
"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)
|
||||
@@ -373,6 +455,82 @@ public struct GuestProvisioner: Sendable {
|
||||
+ 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.
|
||||
|
||||
Reference in New Issue
Block a user