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 /*.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 gitea-macos-runner image build --ipsw --disk-gb 200 """ } }