Merge nucleic/ivory-slate-dingo-9tiv into dev
This commit is contained in:
@@ -50,6 +50,67 @@ extension MacVMEngine {
|
||||
return out
|
||||
}
|
||||
|
||||
/// The file name a `.app` bundle is staged under in the provisioning share's `user-apps/` —
|
||||
/// `Xcode-beta.app` → `Xcode-beta.app.tar`. The provisioner keys on the `.app.tar` suffix and
|
||||
/// strips it back off to get the install name (`provision-macos-guest.sh` §7½).
|
||||
static func stagedArchiveName(forBundleAt source: URL) -> String {
|
||||
"\(source.lastPathComponent).tar"
|
||||
}
|
||||
|
||||
/// **`tar` the `.app` bundle at `source` into `<directory>/<Name>.app.tar`** — how every bundle is
|
||||
/// handed to the base provisioner.
|
||||
///
|
||||
/// The share is virtiofs, and a bundle copied out of it **file by file** does not survive: a
|
||||
/// symlink's own metadata can only be read by opening the *link* (`copyfile(3)` with
|
||||
/// `COPYFILE_NOFOLLOW`), a virtiofs vnode can't be opened that way, and the kernel answers `ELOOP`
|
||||
/// — *"Too many levels of symbolic links"*. `ditto` fails outright; `cp -Rp` fails on every one of
|
||||
/// Xcode's 11160 symlinks; `tar` reading the share warns once per symlink, exits 0 anyway, and
|
||||
/// lands a **bit-damaged** Xcode whose `xcodebuild` is SIGKILLed by the kernel's code-signing check
|
||||
/// (and which Finder calls *"damaged and can't be opened"*).
|
||||
///
|
||||
/// Archiving here inverts the problem. Every per-file read happens on the host's APFS, where
|
||||
/// symlinks, xattrs and Xcode's ~32000 hardlinks are all faithful; the guest then makes ONE
|
||||
/// sequential read of ONE file off the share and re-creates the tree locally on APFS. Same disk
|
||||
/// cost as the tree copy this replaces, and the whole ELOOP class is gone.
|
||||
///
|
||||
/// Throws ``MacVMError/provisionFailed(_:)`` when `tar` can't be started or exits nonzero; a
|
||||
/// partial archive is removed rather than left to be staged as if it were whole.
|
||||
@discardableResult
|
||||
static func stageAppArchive(at source: URL, into directory: URL) throws -> URL {
|
||||
let destination = directory.appendingPathComponent(stagedArchiveName(forBundleAt: source))
|
||||
let tar = Process()
|
||||
tar.executableURL = URL(fileURLWithPath: "/usr/bin/tar")
|
||||
// `-C <parent> <leaf>` roots the archive at the bundle itself, so the guest unpacks it straight
|
||||
// into /Applications with no path surgery. Uncompressed: the share is local disk, and gzipping
|
||||
// several GB of Xcode would cost minutes of CPU to save nothing that matters here.
|
||||
tar.arguments = [
|
||||
"-c", "-f", destination.path,
|
||||
"-C", source.deletingLastPathComponent().path, source.lastPathComponent,
|
||||
]
|
||||
tar.standardOutput = FileHandle.nullDevice
|
||||
let errors = Pipe()
|
||||
tar.standardError = errors
|
||||
do {
|
||||
try tar.run()
|
||||
} catch {
|
||||
throw MacVMError.provisionFailed(
|
||||
"could not archive \(source.lastPathComponent) for the provisioning share: \(error)")
|
||||
}
|
||||
// Read stderr to EOF BEFORE waiting: a bundle that warns thousands of times would otherwise
|
||||
// fill the pipe and park `tar` forever.
|
||||
let stderrData = errors.fileHandleForReading.readDataToEndOfFile()
|
||||
tar.waitUntilExit()
|
||||
guard tar.terminationStatus == 0 else {
|
||||
try? FileManager.default.removeItem(at: destination)
|
||||
let detail = String(decoding: stderrData.suffix(2000), as: UTF8.self)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
throw MacVMError.provisionFailed(
|
||||
"tar exited \(tar.terminationStatus) archiving \(source.lastPathComponent)"
|
||||
+ (detail.isEmpty ? "" : ": \(detail)"))
|
||||
}
|
||||
return destination
|
||||
}
|
||||
|
||||
/// Where a FULL Xcode can live on the host. Order is only a tiebreak — which one gets baked in is
|
||||
/// decided by VERSION (``preferredHostXcode(_:exists:version:)``), not by this list.
|
||||
static let hostXcodeSearchPaths = ["/Applications/Xcode.app", "/Applications/Xcode-beta.app"]
|
||||
|
||||
@@ -770,8 +770,13 @@ extension MacVMEngine {
|
||||
try fm.copyItem(at: nash, to: stageDir.appendingPathComponent("nash"))
|
||||
|
||||
// Stage any user-picked `.app` bundles (Settings → "Included apps") into a `user-apps/`
|
||||
// subdir of the share; the provisioner copies each into the guest's `/Applications`. Best
|
||||
// effort per bundle — a copy that fails (bad/huge bundle) just doesn't make it into the base.
|
||||
// subdir of the share, each as a single `<Name>.app.tar`; the provisioner unpacks them into the
|
||||
// guest's `/Applications`. Best effort per bundle — a bundle that can't be archived (bad/huge)
|
||||
// just doesn't make it into the base.
|
||||
//
|
||||
// An ARCHIVE, not a copied tree, because the share is virtiofs: reading a bundle out of it file
|
||||
// by file cannot preserve it (see ``stageAppArchive(at:into:)`` — `ELOOP` on every symlink, and
|
||||
// a silently damaged Xcode at the end of it). Archiving keeps all the per-file reads on APFS.
|
||||
//
|
||||
// The set is the current Settings selection UNIONED with `carryAppPaths` — the apps recorded on
|
||||
// the base being replaced/reprovisioned — so an app the user had installed here but has since
|
||||
@@ -815,13 +820,28 @@ extension MacVMEngine {
|
||||
if !stagedApps.isEmpty {
|
||||
let userAppsDir = stageDir.appendingPathComponent("user-apps", isDirectory: true)
|
||||
try fm.createDirectory(at: userAppsDir, withIntermediateDirectories: true)
|
||||
// The required Xcode is checked at its SOURCE, before it is archived — the archive itself
|
||||
// can't be inspected cheaply (finding one member in a multi-GB tar means reading the whole
|
||||
// file), and a source that is already incomplete is the failure worth naming.
|
||||
let xcodeSource = URL(fileURLWithPath: requiredXcode)
|
||||
guard
|
||||
fm.isExecutableFile(
|
||||
atPath: xcodeSource.appendingPathComponent(
|
||||
"Contents/Developer/usr/bin/xcodebuild"
|
||||
).path)
|
||||
else {
|
||||
throw MacVMError.provisionFailed(
|
||||
"the host's \(xcodeSource.lastPathComponent) is incomplete "
|
||||
+ "(Contents/Developer/usr/bin/xcodebuild is missing); "
|
||||
+ "the macOS base was not published.")
|
||||
}
|
||||
for path in stagedApps {
|
||||
let src = URL(fileURLWithPath: path)
|
||||
let destination = userAppsDir.appendingPathComponent(src.lastPathComponent)
|
||||
do {
|
||||
try fm.copyItem(at: src, to: destination)
|
||||
try Self.stageAppArchive(at: src, into: userAppsDir)
|
||||
} catch {
|
||||
try? fm.removeItem(at: destination)
|
||||
try? fm.removeItem(
|
||||
at: userAppsDir.appendingPathComponent(Self.stagedArchiveName(forBundleAt: src)))
|
||||
if path == requiredXcode {
|
||||
throw MacVMError.provisionFailed(
|
||||
"could not stage the required \(src.lastPathComponent) into the macOS "
|
||||
@@ -832,13 +852,13 @@ extension MacVMEngine {
|
||||
provisionLog.warning("\(message, privacy: .public)")
|
||||
}
|
||||
}
|
||||
let stagedXcodebuild = userAppsDir
|
||||
.appendingPathComponent(URL(fileURLWithPath: requiredXcode).lastPathComponent)
|
||||
.appendingPathComponent("Contents/Developer/usr/bin/xcodebuild")
|
||||
guard fm.isExecutableFile(atPath: stagedXcodebuild.path) else {
|
||||
let stagedXcodeArchive = userAppsDir.appendingPathComponent(
|
||||
Self.stagedArchiveName(forBundleAt: xcodeSource))
|
||||
let stagedBytes =
|
||||
(try? fm.attributesOfItem(atPath: stagedXcodeArchive.path))?[.size] as? UInt64
|
||||
guard let stagedBytes, stagedBytes > 0 else {
|
||||
throw MacVMError.provisionFailed(
|
||||
"the staged \(URL(fileURLWithPath: requiredXcode).lastPathComponent) is "
|
||||
+ "incomplete (Contents/Developer/usr/bin/xcodebuild is missing); "
|
||||
"the staged \(stagedXcodeArchive.lastPathComponent) is missing or empty; "
|
||||
+ "the macOS base was not published.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +88,16 @@ extension MacVMEngine {
|
||||
// Xcode that can't build against a beta macOS's SDK (the very reason the operator installed
|
||||
// Xcode-beta) — and the inverse, a stale leftover beta, was already possible whenever the
|
||||
// stable Xcode was absent. Re-derive the choice on every base.
|
||||
public static let macOSProvisioningRecipe = 14
|
||||
// v15: `.app` bundles are staged into the provisioning share as `<Name>.app.tar` archives, tarred
|
||||
// host-side off APFS, and unpacked in the guest — instead of being copied out of the share
|
||||
// file by file. The share is virtiofs, where a symlink's own metadata can't be read at all
|
||||
// (`copyfile(3)` opens the LINK; the vnode answers ELOOP), and every copier failed there:
|
||||
// `ditto` outright, `cp -Rp` on all 11160 of Xcode's symlinks, and `tar` SILENTLY — one
|
||||
// warning per symlink, exit 0, and a bit-damaged Xcode whose `xcodebuild` the kernel SIGKILLs
|
||||
// on exec ("damaged and can't be opened"). Phase 7⅞ then blamed the Xcode license for that
|
||||
// SIGKILL and failed the build. §7½ now also proves a required Xcode RUNS before accepting it.
|
||||
// A v14 base was either never published or holds a damaged Xcode, so force them all.
|
||||
public static let macOSProvisioningRecipe = 15
|
||||
|
||||
/// The current Linux base **provisioning-recipe version**. Bump when the bundled Linux provisioning
|
||||
/// assets change in a way that must re-provision an already-built base — a new step in
|
||||
|
||||
@@ -427,6 +427,58 @@ import UniformTypeIdentifiers
|
||||
== ["/one/Foo.app", "/three/Bar.app"])
|
||||
}
|
||||
|
||||
/// A bundle is handed to the base provisioner as ONE archive, because the provisioning share is
|
||||
/// virtiofs and a per-file copy out of it cannot preserve a bundle: a symlink's own metadata is
|
||||
/// only readable by opening the *link*, which a virtiofs vnode refuses with `ELOOP`. `ditto` fails
|
||||
/// outright, `cp -Rp` fails on all 11160 of Xcode's symlinks, and `tar` reading the share warns
|
||||
/// once per symlink, exits 0 anyway, and lands an Xcode whose `xcodebuild` is SIGKILLed by the
|
||||
/// kernel's code-signing check. Archiving host-side keeps every per-file read on APFS.
|
||||
@Test func stageAppArchiveWritesOneTarThatPreservesTheBundle() throws {
|
||||
let fm = FileManager.default
|
||||
let root = fm.temporaryDirectory.appendingPathComponent(
|
||||
"nucleic-stage-archive-\(UUID().uuidString)", isDirectory: true)
|
||||
let source = root.appendingPathComponent("Probe.app/Contents/MacOS", isDirectory: true)
|
||||
let share = root.appendingPathComponent("user-apps", isDirectory: true)
|
||||
try fm.createDirectory(at: source, withIntermediateDirectories: true)
|
||||
try fm.createDirectory(at: share, withIntermediateDirectories: true)
|
||||
defer { try? fm.removeItem(at: root) }
|
||||
let binary = source.appendingPathComponent("Probe")
|
||||
try "#!/bin/sh\nexit 0\n".write(to: binary, atomically: true, encoding: .utf8)
|
||||
try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binary.path)
|
||||
// The link that ELOOPs when it lives on the share — the whole reason this path exists.
|
||||
try fm.createSymbolicLink(
|
||||
at: source.appendingPathComponent("alias"), withDestinationURL: binary)
|
||||
|
||||
let bundle = root.appendingPathComponent("Probe.app")
|
||||
#expect(MacVMEngine.stagedArchiveName(forBundleAt: bundle) == "Probe.app.tar")
|
||||
let archive = try MacVMEngine.stageAppArchive(at: bundle, into: share)
|
||||
#expect(archive.lastPathComponent == "Probe.app.tar")
|
||||
#expect(((try? fm.attributesOfItem(atPath: archive.path))?[.size] as? UInt64 ?? 0) > 0)
|
||||
|
||||
// Unpack it the way the provisioner does, and check the bundle came back whole: the archive is
|
||||
// rooted at the bundle itself (so it lands straight in /Applications), the executable bit
|
||||
// survives, and the symlink is still a symlink rather than a flattened copy.
|
||||
let unpacked = root.appendingPathComponent("unpacked", isDirectory: true)
|
||||
try fm.createDirectory(at: unpacked, withIntermediateDirectories: true)
|
||||
let tar = Process()
|
||||
tar.executableURL = URL(fileURLWithPath: "/usr/bin/tar")
|
||||
tar.arguments = ["-xpf", archive.path, "-C", unpacked.path]
|
||||
try tar.run()
|
||||
tar.waitUntilExit()
|
||||
#expect(tar.terminationStatus == 0)
|
||||
let restored = unpacked.appendingPathComponent("Probe.app/Contents/MacOS")
|
||||
#expect(fm.isExecutableFile(atPath: restored.appendingPathComponent("Probe").path))
|
||||
let linkType = try fm.attributesOfItem(
|
||||
atPath: restored.appendingPathComponent("alias").path)[.type] as? FileAttributeType
|
||||
#expect(linkType == .typeSymbolicLink)
|
||||
|
||||
// A source that isn't there fails loudly instead of staging an empty archive.
|
||||
#expect(throws: MacVMError.self) {
|
||||
try MacVMEngine.stageAppArchive(at: root.appendingPathComponent("Absent.app"), into: share)
|
||||
}
|
||||
#expect(!fm.fileExists(atPath: share.appendingPathComponent("Absent.app.tar").path))
|
||||
}
|
||||
|
||||
@Test func preferredHostXcodePicksTheNewestAndNeedsXcodebuild() {
|
||||
// A full Xcode is identified by the `xcodebuild` the Command Line Tools don't ship — that
|
||||
// binary is the only supported Metal-toolchain installer, so a candidate without it is useless.
|
||||
@@ -1617,6 +1669,59 @@ import UniformTypeIdentifiers
|
||||
#expect(script.contains("could not install $AGENT_APP_DEST"))
|
||||
}
|
||||
|
||||
/// Neither `cp -Rp` nor `tar` actually SOLVED the ELOOP above — they only failed differently, and
|
||||
/// tar failed silently: 11160 "Could not pack extended attributes" warnings, exit 0, and a
|
||||
/// bit-damaged Xcode whose `xcodebuild` the kernel SIGKILLs on exec. So bundles now arrive as
|
||||
/// host-made `<Name>.app.tar` archives that the guest unpacks locally, and the tree copiers are
|
||||
/// only the fallback for a hand-staged bundle directory.
|
||||
@Test func macOSGuestProvisionerUnpacksStagedAppArchives() throws {
|
||||
let repoRoot = URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()
|
||||
let script = try String(
|
||||
contentsOf: repoRoot.appendingPathComponent("scripts/provision-macos-guest.sh"),
|
||||
encoding: .utf8)
|
||||
|
||||
#expect(script.contains("install_app_archive()"))
|
||||
#expect(script.contains(#"sudo /usr/bin/tar -xpf "$archive" -C "$(dirname "$dest")""#))
|
||||
// Phase 7½ takes archives AND (for a hands-on run) bundle directories, archive first so a
|
||||
// leftover staged tree can't shadow the archive of the same name.
|
||||
#expect(script.contains(#"for staged in "$USER_APPS_DIR"/*.app.tar "$USER_APPS_DIR"/*.app"#))
|
||||
#expect(script.contains(#"install_app_archive "$staged" "$dest""#))
|
||||
#expect(script.contains(#"install_app_bundle "$app" "$dest""#))
|
||||
// The surviving tree fallback stops reading symlink xattrs off the share, which is what
|
||||
// produced one warning per symlink (and no failure) on the create side.
|
||||
#expect(script.contains(#"sudo /usr/bin/tar $TAR_NO_METADATA -cf - "$leaf""#))
|
||||
#expect(script.contains(#"/usr/bin/tar --no-mac-metadata --version"#))
|
||||
}
|
||||
|
||||
/// "Structurally complete" is not "intact": a bundle damaged in transit keeps every path in place
|
||||
/// but its binaries are SIGKILLed by the kernel's code-signing check the instant they exec (bash
|
||||
/// prints "Killed: 9"; Finder says "damaged and can't be opened"). Phase 7⅞ met exactly that and
|
||||
/// reported it as an unaccepted Xcode LICENSE — sending the operator to run `-license accept`
|
||||
/// against a bundle that cannot run at all. A signal death must be diagnosed as damage, at the
|
||||
/// copy, and a plain nonzero exit must still be treated as the license being outstanding.
|
||||
@Test func macOSGuestProvisionerDiagnosesADamagedXcodeRatherThanBlamingTheLicense() throws {
|
||||
let repoRoot = URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()
|
||||
let script = try String(
|
||||
contentsOf: repoRoot.appendingPathComponent("scripts/provision-macos-guest.sh"),
|
||||
encoding: .utf8)
|
||||
|
||||
// The copy itself is gated on the installed Xcode actually running.
|
||||
let installed = try #require(script.range(of: "installed $name is DAMAGED"))
|
||||
#expect(script.contains(#"|| xcodebuild_rc=$?"#))
|
||||
#expect(script.contains(#"if [ "$xcodebuild_rc" -ge 128 ]"#))
|
||||
// …and the later license pass separates a signal from a nonzero exit before it accuses the
|
||||
// license, which it must still go on to accept for the ordinary (exit 1) case.
|
||||
let licenseCheck = try #require(script.range(of: "xcode_run_status()"))
|
||||
let damaged = try #require(script.range(of: "is DAMAGED: xcodebuild is killed by signal"))
|
||||
let licenseAccepted = try #require(script.range(of: "-license accept", options: .backwards))
|
||||
#expect(installed.upperBound < licenseCheck.lowerBound)
|
||||
#expect(damaged.upperBound < licenseAccepted.lowerBound)
|
||||
#expect(script.contains("This is NOT a license problem"))
|
||||
#expect(script.contains(#"xcode_license_ok() { [ "$(xcode_run_status)" -eq 0 ]; }"#))
|
||||
}
|
||||
|
||||
/// A copied-in Xcode is unusable until its license is accepted by root, and an agent exec has no
|
||||
/// TTY to answer the prompt in — so the provisioner must accept it during the base build, verify
|
||||
/// the acceptance the way a build does, and fall back to driving the interactive prompt with its
|
||||
@@ -2857,7 +2962,7 @@ import UniformTypeIdentifiers
|
||||
provisioned: true, agent: false, grants: true), os: .macOS))
|
||||
#expect(MacVMEngine.baseIsUsable(status(provisioned: true, agent: false), os: .linux))
|
||||
#expect(!MacVMEngine.baseIsUsable(status(provisioned: false, agent: false), os: .linux))
|
||||
#expect(MacVMEngine.macOSProvisioningRecipe == 14)
|
||||
#expect(MacVMEngine.macOSProvisioningRecipe == 15)
|
||||
}
|
||||
|
||||
/// Clone-time admission duplicates the current-recipe publication invariant: neither a stale
|
||||
|
||||
+25
-15
@@ -486,8 +486,9 @@ On top of that, two ways to customize what every session clone ships with, both
|
||||
Settings → "macOS virtual machines":
|
||||
|
||||
- **Included apps** (`bundledAppPaths`) — local `.app` bundles the *operator supplies*. The engine
|
||||
stages each into the provisioning share's `user-apps/` and the provisioner copies them into the
|
||||
guest's `/Applications` (`provision-macos-guest.sh` §7½).
|
||||
`tar`s each into the provisioning share's `user-apps/` as `<Name>.app.tar` (`stageAppArchive`) and
|
||||
the provisioner unpacks them into the guest's `/Applications` (`provision-macos-guest.sh` §7½). An
|
||||
archive rather than a copied tree because the share is virtiofs — see the note below.
|
||||
- **Common packages** (`selectedPackages`) — curated, well-known *optional* tools the guest fetches and
|
||||
installs itself (`MacVMPackage.catalog`). Currently only **Xcode**, and it's grayed out because
|
||||
Xcode is no longer optional: Apple gates its download behind an Apple ID, so the base build copies
|
||||
@@ -554,25 +555,34 @@ hits), because `-license accept` can exit 0 having recorded nothing. It also run
|
||||
`DevToolsSecurity -enable` and adds the agent to the `_developer` group, so the first debug/test run in
|
||||
a clone doesn't pop the "Developer Tools Access needs to take control" authentication panel.
|
||||
Xcode is a base-publication invariant, not a best-effort extra. Host-side staging errors propagate;
|
||||
the guest copies the bundle with `install_app_bundle` (preserving symlinks, modes, and signatures — see
|
||||
the note on `ditto` and the share below), and §7⅞ aborts
|
||||
the guest unpacks the staged `Xcode*.app.tar` with `install_app_archive` (see the virtiofs note
|
||||
below), and §7⅞ aborts
|
||||
the build if Xcode is missing/incomplete, license or first-launch setup fails, Metal cannot compile a
|
||||
probe, or the selected macOS SDK cannot typecheck a minimal `import SwiftUI` view. The same pass
|
||||
resolves the core Homebrew tools promised by the base. This keeps failures in the one-time base build
|
||||
instead of cloning them into every agent session.
|
||||
Budget roughly **6 GB** of base disk for this (Xcode ≈ 3.6 GB, the Metal toolchain asset ≈ 2.5 GB).
|
||||
|
||||
> **`ditto` cannot copy a symlink that lives on the virtiofs share.** `copyfile(3)` carries a link's own
|
||||
> metadata by opening the *link* (`open(2)` with `O_SYMLINK`); a virtiofs vnode doesn't support being
|
||||
> opened that way, so the kernel answers `ELOOP` — *"Too many levels of symbolic links"*. It is not a
|
||||
> link cycle: a staged `Xcode-beta.app` failed on **all 11152** of its symlinks (one error each, every
|
||||
> regular file copied fine), including same-directory links like `Contents/Developer/usr/bin/g++ →
|
||||
> clang++` that cannot loop. `ditto` exits 1, and Phase 7½ aborted every base build at *"failed to
|
||||
> install required Xcode-beta.app"*. `cp -R` is immune — it re-creates a link with `readlink(2)` +
|
||||
> `symlink(2)` and never opens it. So `install_app_bundle` (scripts/provision-macos-guest.sh) probes the
|
||||
> source filesystem with a throwaway symlink, keeps `ditto` wherever it still works (local disk), and
|
||||
> copies with `cp -Rp` (then `tar`) off the share. A required bundle that lands with **no** symlinks is
|
||||
> fatal, because it would look installed and fail much later.
|
||||
> **A bundle cannot be copied file-by-file off the virtiofs share — it is staged as an archive.**
|
||||
> `copyfile(3)` carries a link's own metadata by opening the *link* (`open(2)` with `O_SYMLINK`); a
|
||||
> virtiofs vnode doesn't support being opened that way, so the kernel answers `ELOOP` — *"Too many
|
||||
> levels of symbolic links"*. It is not a link cycle: a staged `Xcode-beta.app` failed on **all 11160**
|
||||
> of its symlinks (one error each, every regular file copied fine), including same-directory links like
|
||||
> `Contents/Developer/usr/bin/g++ → clang++` that cannot loop.
|
||||
>
|
||||
> Every copier hits it, and the dangerous one hits it *quietly*. `ditto` exits 1 outright. `cp -Rp`
|
||||
> re-creates the link with `readlink(2)` + `symlink(2)` but still reads its xattrs, so it errors per
|
||||
> symlink. `tar` reading the share warns per symlink (*"Could not pack extended attributes"*), **exits 0
|
||||
> regardless**, and lands a bit-damaged Xcode: `xcodebuild` is then SIGKILLed by the kernel's
|
||||
> code-signing check on exec (`Killed: 9`) and Finder calls the bundle *"damaged and can't be opened"*.
|
||||
> Phase 7⅞ read that SIGKILL as an unaccepted Xcode **license** and failed every base build there.
|
||||
>
|
||||
> So the engine `tar`s each bundle **host-side, off APFS** (`stageAppArchive`), where symlinks, xattrs
|
||||
> and Xcode's ~32000 hardlinks are all faithful, and the guest makes one sequential read of one file to
|
||||
> unpack it locally (`install_app_archive`). `install_app_bundle` — the ditto/`cp -Rp`/`tar` cascade,
|
||||
> now with `--no-mac-metadata` on the create side — survives only for a hand-staged bundle directory.
|
||||
> A required bundle that lands with **no** symlinks, or whose `xcodebuild` dies on a *signal* rather
|
||||
> than an exit code, is fatal at §7½: both look installed and fail much later.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -150,6 +150,25 @@ clt_present() { /usr/bin/xcode-select -p >/dev/null 2>&1 && /usr/bin/xcrun --fin
|
||||
# call can't stall the whole build. `alarm` survives the exec, so the wrapped process is killed on time.
|
||||
run_timeout() { perl -e 'alarm shift; exec @ARGV' "$@"; }
|
||||
|
||||
# ── Installing .app bundles staged as ARCHIVES (the normal path) ──────────────────────────────────
|
||||
# Nucleic stages each bundle into the share as a single `<Name>.app.tar`, tarred HOST-side off APFS
|
||||
# (MacVMEngine+InstallApps.swift → `stageAppArchive`). That is deliberate: a bundle copied out of the
|
||||
# share file-by-file cannot be preserved (see the ELOOP note below), and the copy that "succeeds"
|
||||
# anyway lands a bit-damaged Xcode. Unpacking an archive touches the share exactly once, sequentially,
|
||||
# for one file — every symlink, hardlink and xattr is then re-created locally on APFS.
|
||||
install_app_archive() { # $1 = staged <Name>.app.tar, $2 = destination bundle path
|
||||
local archive="$1" dest="$2" leaf
|
||||
leaf="$(basename "$dest")"
|
||||
sudo /bin/rm -rf "$dest" || return 1
|
||||
sudo /bin/mkdir -p "$(dirname "$dest")" || return 1
|
||||
# `-p` restores modes/owners; extraction is into /Applications, so the archive's top-level name must
|
||||
# match the destination leaf (it does — the host roots each archive at the bundle itself).
|
||||
sudo /usr/bin/tar -xpf "$archive" -C "$(dirname "$dest")" || return 1
|
||||
[ -d "$dest" ] || { echo " ✗ $leaf: archive did not contain $leaf" >&2; return 1; }
|
||||
sudo /usr/bin/xattr -dr com.apple.quarantine "$dest" 2>/dev/null || true
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── Copying .app bundles OFF the virtiofs provisioning share ──────────────────────────────────────
|
||||
# `ditto` CANNOT copy a symlink that lives on the share. copyfile(3) carries a link's own metadata by
|
||||
# opening the LINK (open(2) with O_SYMLINK), and a virtiofs vnode doesn't support being opened as a
|
||||
@@ -159,6 +178,22 @@ run_timeout() { perl -e 'alarm shift; exec @ARGV' "$@"; }
|
||||
# cannot loop. `ditto` then exits 1 and Phase 7½ aborts the whole base build on the required Xcode.
|
||||
# `cp -R` is immune: it re-creates a link with readlink(2)+symlink(2) and never opens it.
|
||||
#
|
||||
# NEITHER `cp -Rp` NOR `tar` FIXES THIS — they only fail differently. `cp -Rp` re-creates the link but
|
||||
# still reads its xattrs through copyfile(3), so it errors on all 11160 of Xcode's symlinks; bsdtar
|
||||
# reading the share warns once per symlink ("Could not pack extended attributes"), EXITS 0 REGARDLESS,
|
||||
# and produced an Xcode whose `xcodebuild` was SIGKILLed by the kernel's code-signing check the moment
|
||||
# it ran — the "Xcode-beta is damaged and can't be opened" copy. That is why the engine now stages an
|
||||
# ARCHIVE (`install_app_archive` above) and this helper is only the fallback for a HAND-STAGED bundle
|
||||
# directory: nothing routinely reads a bundle tree off the share any more.
|
||||
#
|
||||
# bsdtar packs each file's xattrs through copyfile(3) — the very call that answers ELOOP on a share
|
||||
# symlink — so the tar fallback below turns that off. Probed rather than assumed: an older bsdtar
|
||||
# without the flag would abort on an unknown option, which is worse than the warnings it suppresses.
|
||||
TAR_NO_METADATA=""
|
||||
if /usr/bin/tar --no-mac-metadata --version >/dev/null 2>&1; then
|
||||
TAR_NO_METADATA="--no-mac-metadata"
|
||||
fi
|
||||
#
|
||||
# So probe the source filesystem — write a throwaway directory holding one symlink and try to `ditto`
|
||||
# it — and pick the copier that actually works there. ditto keeps its bundle fidelity everywhere it
|
||||
# still can (a local source, or a share whose symlinks it can read); the share falls back to cp -Rp,
|
||||
@@ -195,7 +230,10 @@ install_app_bundle() { # $1 = source bundle, $2 = destination
|
||||
# installer streams (MacVMEngine+InstallApps.swift), so it is a proven shape for a bundle.
|
||||
echo " ⚠ cp -Rp failed for $leaf — retrying with tar …" >&2
|
||||
sudo /bin/rm -rf "$dest"
|
||||
( cd "$(dirname "$src")" && sudo /usr/bin/tar -cf - "$leaf" ) \
|
||||
# `$TAR_NO_METADATA` on the CREATE side only: reading a share symlink's xattrs is what fails, and
|
||||
# without it bsdtar emits one warning per symlink (11160 of them for Xcode) and still exits 0.
|
||||
# Extraction keeps `-p`, since writing metadata onto local APFS works fine.
|
||||
( cd "$(dirname "$src")" && sudo /usr/bin/tar $TAR_NO_METADATA -cf - "$leaf" ) \
|
||||
| ( cd "$(dirname "$dest")" && sudo /usr/bin/tar -xpf - ) || return 1
|
||||
[ "$leaf" = "$(basename "$dest")" ] \
|
||||
|| sudo /bin/mv "$(dirname "$dest")/$leaf" "$dest" || return 1
|
||||
@@ -728,23 +766,37 @@ echo " ────────────────────────
|
||||
# ── Phase 7½: user-provided apps → /Applications ────────────────────────────────────────────────────
|
||||
# ══════════════════════════════════════════════════════════════════════════════════════════════════
|
||||
# Nucleic stages any .app bundles the operator picked in Settings ("Base image" → "Included apps")
|
||||
# into a `user-apps/` subdir of the provisioning share; bake each into the base's /Applications so
|
||||
# every session clone has them. Optional apps remain best effort, but Xcode is a required base
|
||||
# invariant: a failed or incomplete Xcode copy aborts provisioning. `install_app_bundle` picks a
|
||||
# copier that survives the share's virtiofs symlink limitation (see its definition — a plain `ditto`
|
||||
# here failed on every one of Xcode's 11152 symlinks) while preserving symlinks, executable bits and
|
||||
# signatures. Quarantine is stripped so copied apps launch without Gatekeeper prompting inside the
|
||||
# (disposable, NAT-isolated) guest.
|
||||
# into a `user-apps/` subdir of the provisioning share — each as a single `<Name>.app.tar` — and bakes
|
||||
# them into the base's /Applications so every session clone has them. Optional apps remain best
|
||||
# effort, but Xcode is a required base invariant: a failed or incomplete Xcode install aborts
|
||||
# provisioning. Archives are unpacked with `install_app_archive`; a bundle DIRECTORY (a hands-on run
|
||||
# that staged one by hand) still goes through `install_app_bundle`, which picks the least-bad copier
|
||||
# for the share's virtiofs symlink limitation. Quarantine is stripped so copied apps launch without
|
||||
# Gatekeeper prompting inside the (disposable, NAT-isolated) guest.
|
||||
USER_APPS_DIR="$PROVISION_SHARE/user-apps"
|
||||
if [ -d "$USER_APPS_DIR" ]; then
|
||||
echo "▸ Installing user-provided apps into /Applications …"
|
||||
shopt -s nullglob
|
||||
for app in "$USER_APPS_DIR"/*.app; do
|
||||
name="$(basename "$app")"
|
||||
for staged in "$USER_APPS_DIR"/*.app.tar "$USER_APPS_DIR"/*.app; do
|
||||
app=""
|
||||
case "$staged" in
|
||||
*.app.tar) name="$(basename "${staged%.tar}")" ;;
|
||||
*) name="$(basename "$staged")"; app="$staged" ;;
|
||||
esac
|
||||
# An archive wins over a same-named directory, so a leftover hand-staged tree can't shadow it.
|
||||
if [ -n "$app" ] && [ -f "$USER_APPS_DIR/$name.tar" ]; then
|
||||
continue
|
||||
fi
|
||||
dest="/Applications/$name"
|
||||
required=0
|
||||
case "$name" in Xcode*.app) required=1 ;; esac
|
||||
if install_app_bundle "$app" "$dest"; then
|
||||
installed=0
|
||||
if [ -n "$app" ]; then
|
||||
if install_app_bundle "$app" "$dest"; then installed=1; fi
|
||||
else
|
||||
if install_app_archive "$staged" "$dest"; then installed=1; fi
|
||||
fi
|
||||
if [ "$installed" -eq 1 ]; then
|
||||
if [ "$required" -eq 1 ] && [ ! -x "$dest/Contents/Developer/usr/bin/xcodebuild" ]; then
|
||||
echo " ✗ installed $name is incomplete (xcodebuild is missing) — aborting." >&2
|
||||
exit 1
|
||||
@@ -756,6 +808,23 @@ if [ -d "$USER_APPS_DIR" ]; then
|
||||
echo " ✗ installed $name has no symlinks — the copy did not preserve them; aborting." >&2
|
||||
exit 1
|
||||
fi
|
||||
# STRUCTURALLY complete is not the same as INTACT. A bundle whose bytes were altered in transit
|
||||
# still has every path in place, but the kernel's code-signing check SIGKILLs its binaries the
|
||||
# instant they exec (bash reports "Killed: 9"), and Finder calls the app "damaged and can't be
|
||||
# opened". So make the required Xcode actually RUN here, where the failure is attributable to the
|
||||
# copy — Phase 7⅞ used to meet the same SIGKILL and blame the Xcode license for it. Only a
|
||||
# signal death is fatal: a plain nonzero exit is the licence still being outstanding, which is
|
||||
# exactly what 7⅞ goes on to accept.
|
||||
if [ "$required" -eq 1 ]; then
|
||||
xcodebuild_rc=0
|
||||
"$dest/Contents/Developer/usr/bin/xcodebuild" -version >/dev/null 2>&1 || xcodebuild_rc=$?
|
||||
if [ "$xcodebuild_rc" -ge 128 ]; then
|
||||
echo " ✗ installed $name is DAMAGED — xcodebuild was killed by signal $((xcodebuild_rc - 128))" >&2
|
||||
echo " on launch, i.e. its code signature no longer validates, so the bundle did not" >&2
|
||||
echo " survive the copy out of the provisioning share. Aborting." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
echo " ✓ installed $dest"
|
||||
else
|
||||
if [ "$required" -eq 1 ]; then
|
||||
@@ -959,7 +1028,27 @@ else
|
||||
# The proof is functional, not an exit code: `xcodebuild -version` run as the agent (NOT root) is
|
||||
# exactly what a build hits, and it fails while the license is outstanding. Probe with it after
|
||||
# every attempt — `-license accept` can exit 0 having recorded nothing.
|
||||
xcode_license_ok() { "$XCODE_DEVDIR/usr/bin/xcodebuild" -version >/dev/null 2>&1; }
|
||||
#
|
||||
# But separate the TWO ways that probe can fail. An outstanding license is a nonzero EXIT; a bundle
|
||||
# whose bytes didn't survive being copied here is a SIGNAL — the kernel's code-signing check kills
|
||||
# xcodebuild on exec ("Killed: 9"), and no amount of `-license accept` will ever change that. Reading
|
||||
# the second as the first is what made a damaged Xcode report itself as an unaccepted license, and
|
||||
# sent the operator to run `xcodebuild -license accept` against a bundle that cannot run at all.
|
||||
xcode_run_status() {
|
||||
local rc=0
|
||||
"$XCODE_DEVDIR/usr/bin/xcodebuild" -version >/dev/null 2>&1 || rc=$?
|
||||
printf '%s' "$rc"
|
||||
}
|
||||
xcode_license_ok() { [ "$(xcode_run_status)" -eq 0 ]; }
|
||||
|
||||
XCODE_RUN_RC="$(xcode_run_status)"
|
||||
if [ "$XCODE_RUN_RC" -ge 128 ]; then
|
||||
echo " ✗ $XCODE_APP is DAMAGED: xcodebuild is killed by signal $((XCODE_RUN_RC - 128)) on" >&2
|
||||
echo " launch, so its code signature no longer validates — the bundle did not survive being" >&2
|
||||
echo " copied into this guest. (Finder reports the same bundle as \"damaged and can't be" >&2
|
||||
echo " opened\".) This is NOT a license problem; re-stage Xcode and rebuild the base." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if xcode_license_ok; then
|
||||
echo " ✓ Xcode license already accepted."
|
||||
|
||||
Reference in New Issue
Block a user