Repository navigation
feat(homebrew): Homebrew — Homebrew itself as a v2 resource (ENG-3749) #32
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Ryan Zhu (underthestars-zhy)
merged 7 commits into
ryan/eng-3751-astrolabe-monitor-repair-resources-that-drift
from
ryan/eng-3749-astrolabe-homebrew-itself-as-a-v2-resource
Nov 1, 2026
Merged
Changes from 2 commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
54bbc7a
fix(clt): read installed every minute
underthestars-zhy cbd02b0
feat(homebrew): Homebrew — Homebrew itself as a v2 resource (ENG-3749)
underthestars-zhy d38f3bf
feat(brew): Brew — Homebrew formulae and casks as a v2 resource (ENG-…
underthestars-zhy 0502504
Merge branch 'ryan/eng-3751-astrolabe-monitor-repair-resources-that-d…
underthestars-zhy f760f7b
fix(homebrew): time out a stalled install.sh fetch
underthestars-zhy bb41f63
Merge branch 'ryan/eng-3749-astrolabe-homebrew-itself-as-a-v2-resourc…
underthestars-zhy 6b21452
Merge pull request #33 from photon-hq/ryan/eng-3738-astrolabe-brew-as…
underthestars-zhy File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| import Astrolabe | ||
| import Foundation | ||
|
|
||
| /// Exercises `Homebrew` on a farm Mac or a clean Apple-silicon VM, ideally at the login window. | ||
| /// | ||
| /// Write the owner's short name (`whoami`) to `/etc/astrolabe-homebrew-owner`, then run it: | ||
| /// | ||
| /// ```sh | ||
| /// echo ops | sudo tee /etc/astrolabe-homebrew-owner | ||
| /// sudo .build/release/HomebrewEngine --dry-run # rollout commandLineTools@…; no homebrew until CLT is there | ||
| /// sudo .build/release/HomebrewEngine # installs the daemon | ||
| /// ``` | ||
| /// | ||
| /// CLT installs first; within about a minute of it landing, Homebrew does, owned by the owner. | ||
| /// Write another account to the file and the prefix is re-owned, with nothing downloaded. Empty | ||
| /// the file and Homebrew is no longer declared, which leaves it in place. | ||
| /// | ||
| /// The owner comes from a file, not an environment variable: the daemon launchd starts never | ||
| /// sees the environment of the `sudo` that installed it. | ||
| @main | ||
| struct HomebrewApp: Astrolabe { | ||
| @Fact(\.homebrewOwner) var owner | ||
|
|
||
| var body: some Component { | ||
| CommandLineTools() | ||
| if let owner { | ||
| Homebrew(owner: owner) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| extension Facts { | ||
| /// The first line of `/etc/astrolabe-homebrew-owner`, or `nil` when it's missing or empty. | ||
| @FactEntry(schedule: .every(.seconds(5)), read: { OwnerFile.read() }) | ||
| var homebrewOwner: String? | ||
| } | ||
|
|
||
| enum OwnerFile { | ||
| static let path = "/etc/astrolabe-homebrew-owner" | ||
|
|
||
| /// Never throws: a missing file is a real reading, "no owner", not a failed one. | ||
| static func read() -> String? { | ||
| guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { return nil } | ||
| let owner = text.split(whereSeparator: \.isNewline).first?.trimmingCharacters(in: .whitespaces) ?? "" | ||
| return owner.isEmpty ? nil : owner | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| /// Keeps Homebrew installed at `/opt/homebrew` and owned by `owner`, headlessly, with Homebrew's | ||
| /// own `install.sh`. | ||
| /// | ||
| /// ```swift | ||
| /// var body: some Component { | ||
| /// CommandLineTools() // required: Homebrew waits for it and never installs it | ||
| /// Homebrew(owner: "ops") | ||
| /// } | ||
| /// ``` | ||
| /// | ||
| /// `owner` is one account's short name (what `whoami` prints), never root. Every `brew` runs as | ||
| /// it. Run `brew` by hand as the owner too: `brew as-brew-user install wget`. Files anyone else | ||
| /// creates in the prefix are given back to `owner` within a minute. | ||
| /// | ||
| /// Declares nothing until CLT is on the Mac. Without `CommandLineTools()` in `body`, Homebrew | ||
| /// waits forever, and nothing logs it: the dry run shows no `homebrew` id. | ||
| /// | ||
| /// A deleted Homebrew is installed again. Changing `owner` re-owns the prefix. Keeping Homebrew | ||
| /// current is `brew update`'s job. Never uninstalls: dropping it from `body` leaves Homebrew and | ||
| /// every formula in place. Apple silicon only, as Homebrew is on macOS. | ||
| public struct Homebrew: Component { | ||
| let owner: String | ||
| @Fact(\.commandLineTools.isInstalled) private var commandLineToolsInstalled | ||
|
|
||
| public init(owner: String) { | ||
| self.owner = owner | ||
| } | ||
|
|
||
| public var body: some Component { | ||
| // Safe to gate: `HomebrewInstall.remove` does nothing, so CLT vanishing removes nothing. | ||
| if commandLineToolsInstalled { | ||
| HomebrewInstall(owner: owner) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// Keeps Homebrew installed and owned by `owner` alone. Declared by `Homebrew`. | ||
| @Resource | ||
| struct HomebrewInstall { | ||
| static let kind = "astrolabe.homebrew" | ||
| /// Owns the prefix. Every later `brew` runs as this user. | ||
| let owner: String | ||
| /// Deleted (`[]`), re-owned, or mixed (`["bob", "ops"]`) reruns `rollout`. `brew update` | ||
| /// changes nothing. | ||
| @Monitor(\.homebrew.owners) var owners | ||
|
|
||
| /// One per Mac. | ||
| var id: String { "homebrew" } | ||
|
|
||
| static func compute(_ resource: Self) -> String { | ||
| Hash.of(kind, resource.owner) | ||
| } | ||
|
|
||
| @concurrent static func rollout(_ resource: Self) async throws { | ||
| try await HomebrewInstaller.ensure(owner: resource.owner) | ||
| } | ||
|
|
||
| /// Never uninstalls: that deletes every formula on the Mac, and nothing would put them back. | ||
| @concurrent static func remove(_ resource: Self) async throws {} | ||
|
|
||
| /// Failures are mostly network, a missing CLT, or a missing owner, and each install fetches | ||
| /// Homebrew from GitHub. | ||
| static var retryPolicy: RetryPolicy { .retry(Backoff(initial: .seconds(60), max: .seconds(3600))) } | ||
|
|
||
| /// Waiting out another holder of the lock, plus a re-own, `install.sh`, and the checks. | ||
| static var timeout: Duration { .seconds(3 * 3600) } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| extension Facts { | ||
| /// Facts about Homebrew. Reads nothing by itself. | ||
| public var homebrew: HomebrewFacts { HomebrewFacts(facts: self) } | ||
| } | ||
|
|
||
| /// Who owns Homebrew at `/opt/homebrew`, and whether it's there. | ||
| public struct HomebrewFacts: FactNamespace { | ||
| public let facts: Facts | ||
|
|
||
| /// Every account that owns part of Homebrew, sorted, without duplicates. `[]` when | ||
| /// `/opt/homebrew/bin/brew` isn't there. An owner with no account reads as `#<uid>`. | ||
| /// | ||
| /// Reads the owner of `/opt/homebrew`, of `Cellar`, `Caskroom`, `opt` and `bin`, and of each | ||
| /// entry directly inside those: where every `brew install` creates something. `lstat` only, | ||
| /// never following a link: a few hundred calls and no subprocess, so every minute is cheap. | ||
| /// | ||
| /// Read under the same lock as `Homebrew`'s rollout, so it never sees an install half done. | ||
| /// It waits out an install in progress, so the timeout outlasts the longest lock hold: a | ||
| /// re-own, `install.sh`, and the checks after. | ||
| @FactEntry(schedule: .every(.seconds(60)), timeout: .seconds(100 * 60), read: { | ||
| try await HomebrewReads.owners() | ||
| }) | ||
| public var owners: [String] = [] | ||
|
|
||
| /// Whether `brew` is at the prefix. Tracked through `owners`. | ||
| public var isInstalled: Bool { !owners.isEmpty } | ||
| } | ||
|
|
||
| /// The reads behind `HomebrewFacts`, on the task-local context. | ||
| enum HomebrewReads { | ||
| static func owners() async throws -> [String] { | ||
| let context = HomebrewContext.current | ||
| return try await HomebrewInstaller.locked(context) { try context.system.owners() } | ||
| } | ||
| } |
132 changes: 132 additions & 0 deletions
132
Sources/Astrolabe/BuiltIn/Homebrew/HomebrewInstaller.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,132 @@ | ||
| import Semaphore | ||
| import Synchronization | ||
|
|
||
| /// The Mac Homebrew acts on, plus the state its fact and its resource share. | ||
| /// | ||
| /// Task-local, so tests bind their own: the engine's run task, its fact loops and its operations | ||
| /// all inherit it. | ||
| final class HomebrewContext: Sendable { | ||
| @TaskLocal static var current = HomebrewContext(system: InstallScriptHomebrew()) | ||
|
|
||
| let system: any HomebrewSystem | ||
| /// Serializes the `owners` fact's reads and the whole of a rollout's install or re-own, so a | ||
| /// read never sees an install half done. `Brew` (ENG-3738) takes it too, through `ensure`, | ||
| /// so two rollouts never install at once. | ||
| let lock = AsyncSemaphore(value: 1) | ||
| /// A rollout holds the lock and has started changing the Mac. `ensure` then waits for it | ||
| /// rather than trusting owners that can look right before the install is done. | ||
| let installing = Mutex(false) | ||
|
|
||
| init(system: any HomebrewSystem) { | ||
| self.system = system | ||
| } | ||
| } | ||
|
|
||
| /// Installs Homebrew with its own `install.sh`, headlessly, and keeps it owned by one account. | ||
| enum HomebrewInstaller { | ||
| /// Makes sure Homebrew is installed at `/opt/homebrew`, owned by `owner` alone, and that | ||
| /// `brew` runs as `owner`. | ||
| /// | ||
| /// Missing: gives `owner` the prefix, then runs `install.sh` as `owner`. Owned by anyone else, | ||
| /// or by several accounts: `chown`s the prefix back to `owner`, with nothing downloaded. | ||
| /// Never installs Command Line Tools: the app declares `CommandLineTools()`. | ||
| /// | ||
| /// Checks the Mac first, without the lock: when Homebrew is healthy, it changes nothing and | ||
| /// never waits. Everything that changes the Mac happens under the lock. | ||
| static func ensure(owner: String) async throws { | ||
| let context = HomebrewContext.current | ||
| let system = context.system | ||
| guard system.isAppleSilicon() else { throw HomebrewError.unsupportedArchitecture } | ||
| guard let uid = system.uid(of: owner) else { throw HomebrewError.unknownOwner(owner) } | ||
| guard uid != 0 else { throw HomebrewError.ownerIsRoot(owner) } | ||
|
|
||
| // The flag is read after the check: a check made before an install started saw it whole. | ||
| if try await isHealthy(owner: owner, system), !context.installing.withLock({ $0 }) { return } | ||
|
|
||
| // A locked read: it waits out a CLT install or update in progress rather than running | ||
| // `install.sh` against a half-replaced `git`. It releases the CLT lock before the | ||
| // Homebrew lock is taken, so the two never nest. | ||
| guard try await CommandLineToolsReads.installed() != nil else { | ||
| throw HomebrewError.commandLineToolsMissing | ||
| } | ||
|
|
||
| try await locked(context) { | ||
| if try await isHealthy(owner: owner, system) { return } // another rollout just did it | ||
| context.installing.withLock { $0 = true } | ||
| defer { context.installing.withLock { $0 = false } } | ||
|
|
||
| let found = try system.owners() | ||
| if found.isEmpty { | ||
| // Missing. `install.sh` refuses root, and as `owner` it needs `sudo` only to make | ||
| // the prefix in root's `/opt`. So root makes it and gives it to `owner`, along | ||
| // with anything a broken install left in it, and the script needs no `sudo`. | ||
| try system.createPrefix() | ||
| try await system.reown(owner) | ||
| try Task.checkCancellation() | ||
| try await system.runInstallScript(as: owner) | ||
| try system.addToPaths() | ||
| } else { | ||
| // Owned by someone else, or by several: give it all back. A right owner whose | ||
| // `brew` fails gets it too, for a wrong owner deeper in. Nothing is downloaded. | ||
| let why = found == [owner] ? "brew --version fails as \(owner)" : "Homebrew is owned by \(found.joined(separator: ", "))" | ||
| print("[Astrolabe] \(why); re-owning \(InstallScriptHomebrew.prefix) for \(owner)") | ||
| try await system.reown(owner) | ||
| } | ||
|
|
||
| let owners = try system.owners() | ||
| guard owners == [owner] else { throw HomebrewError.didNotTake(expected: owner, owners: owners) } | ||
| guard try await system.brewVersion(as: owner) != nil else { | ||
| throw HomebrewError.brewDoesNotRun(owner: owner) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// `owner` alone owns Homebrew, and `brew --version` runs as `owner`. | ||
| static func isHealthy(owner: String, _ system: any HomebrewSystem) async throws -> Bool { | ||
| guard try system.owners() == [owner] else { return false } | ||
| return try await system.brewVersion(as: owner) != nil | ||
| } | ||
|
|
||
| /// Runs `body` holding the context's lock. Waiting is cancellable. | ||
| static func locked<T>(_ context: HomebrewContext, _ body: () async throws -> T) async throws -> T { | ||
| try await context.lock.waitUnlessCancelled() | ||
| defer { context.lock.signal() } | ||
| return try await body() | ||
| } | ||
| } | ||
|
|
||
| enum HomebrewError: Error, Equatable, Sendable, CustomStringConvertible { | ||
| /// Homebrew supports only Apple silicon on macOS. | ||
| case unsupportedArchitecture | ||
| /// `owner` names no account on this Mac. | ||
| case unknownOwner(String) | ||
| /// `owner` is root (uid 0), which Homebrew refuses. | ||
| case ownerIsRoot(String) | ||
| /// CLT isn't installed. The app declares `CommandLineTools()`; Homebrew never installs it. | ||
| case commandLineToolsMissing | ||
| /// Fetching `install.sh` ended with an HTTP status other than 200, or an empty script. | ||
| case installScriptUnavailable(status: Int) | ||
| /// The install or re-own finished, but Homebrew isn't owned by `expected` alone. | ||
| case didNotTake(expected: String, owners: [String]) | ||
| /// `owner` owns Homebrew, but `brew --version` fails as `owner`. | ||
| case brewDoesNotRun(owner: String) | ||
|
|
||
| var description: String { | ||
| switch self { | ||
| case .unsupportedArchitecture: | ||
| "Homebrew installs only on Apple silicon" | ||
| case .unknownOwner(let owner): | ||
| "no user named \(owner) on this Mac to own Homebrew" | ||
| case .ownerIsRoot(let owner): | ||
| "Homebrew refuses root as its owner (\(owner) is uid 0)" | ||
| case .commandLineToolsMissing: | ||
| "Command Line Tools aren't installed: declare CommandLineTools()" | ||
| case .installScriptUnavailable(let status): | ||
| "fetching Homebrew's install.sh returned HTTP \(status) or an empty script" | ||
| case .didNotTake(let expected, let owners): | ||
| "Homebrew should be owned by \(expected) alone, but is owned by \(owners.isEmpty ? "nobody (not installed)" : owners.joined(separator: ", "))" | ||
| case .brewDoesNotRun(let owner): | ||
| "brew --version fails as \(owner)" | ||
| } | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.