131 lines
6.2 KiB
Swift
131 lines
6.2 KiB
Swift
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
|
||
"""
|
||
}
|
||
}
|