Files
2026-08-07 04:14:34 -07:00

131 lines
6.2 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
"""
}
}