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 all 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,112 @@ | ||
| import Astrolabe | ||
| import Foundation | ||
|
|
||
| /// Exercises `Brew` on a farm Mac or a clean Apple-silicon VM. | ||
| /// | ||
| /// Write the owner's short name (`whoami`) to `/etc/astrolabe-homebrew-owner`, and the packages | ||
| /// to `/etc/astrolabe-brew-packages`, one per line: | ||
| /// | ||
| /// ``` | ||
| /// wget | ||
| /// cask firefox | ||
| /// tailscale >= 1.102.0 | ||
| /// ``` | ||
| /// | ||
| /// ```sh | ||
| /// echo ops | sudo tee /etc/astrolabe-homebrew-owner | ||
| /// printf 'wget\ncask firefox\n' | sudo tee /etc/astrolabe-brew-packages.new >/dev/null | ||
| /// sudo mv /etc/astrolabe-brew-packages.new /etc/astrolabe-brew-packages | ||
| /// sudo .build/release/BrewEngine --dry-run # rollout brew:formula:wget@…, once Homebrew is there | ||
| /// sudo .build/release/BrewEngine # installs the daemon | ||
| /// ``` | ||
| /// | ||
| /// Write the list to a temp file and `mv` it into place: `sudo tee` truncates first, and a read | ||
| /// in that instant would see an empty list and uninstall everything. | ||
| /// | ||
| /// Add a line and the package installs. Remove it and it's uninstalled. Run | ||
| /// `brew as-brew-user uninstall wget` and it's back within a minute. | ||
| @main | ||
| struct BrewApp: Astrolabe { | ||
| @Fact(\.homebrewOwner) var owner | ||
| @Fact(\.brewList) var packages | ||
|
|
||
| var body: some Component { | ||
| CommandLineTools() | ||
| if let owner { | ||
| Homebrew(owner: owner) | ||
| } | ||
| for package in packages { | ||
| Brew(package.name, type: package.type, version: package.version) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| 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? | ||
|
|
||
| /// A missing file reads as `[]`: nothing declared. An unreadable file or an unparseable line | ||
| /// throws, so the engine keeps the last list rather than uninstalling everything. | ||
| @FactEntry(schedule: .every(.seconds(5)), read: { try PackageFile.read() }) | ||
| var brewList: [ListedPackage] = [] | ||
| } | ||
|
|
||
| /// One line of `/etc/astrolabe-brew-packages`. | ||
| struct ListedPackage: Equatable, Sendable { | ||
| let name: String | ||
| let type: Brew.PackageType | ||
| let version: Brew.Version | ||
| } | ||
|
|
||
| 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 | ||
| } | ||
| } | ||
|
|
||
| /// `[cask] <name> [>= <version>]` per line. Blank lines and `#` comments are skipped. | ||
| enum PackageFile { | ||
| static let path = "/etc/astrolabe-brew-packages" | ||
|
|
||
| struct UnparseableLine: Error, CustomStringConvertible { | ||
| let number: Int | ||
| let line: String | ||
| var description: String { "\(PackageFile.path):\(number): expected `[cask] <name> [>= <version>]`, got `\(line)`" } | ||
| } | ||
|
|
||
| static func read() throws -> [ListedPackage] { | ||
| guard FileManager.default.fileExists(atPath: path) else { return [] } | ||
| let text = try String(contentsOfFile: path, encoding: .utf8) | ||
| var packages: [ListedPackage] = [] | ||
| for (index, line) in text.split(separator: "\n", omittingEmptySubsequences: false).enumerated() { | ||
| let content = line.prefix { $0 != "#" }.trimmingCharacters(in: .whitespaces) | ||
| guard !content.isEmpty else { continue } | ||
| guard let package = parse(content) else { throw UnparseableLine(number: index + 1, line: String(line)) } | ||
| packages.append(package) | ||
| } | ||
| return packages | ||
| } | ||
|
|
||
| private static func parse(_ line: String) -> ListedPackage? { | ||
| var words = line.split(whereSeparator: \.isWhitespace).map(String.init) | ||
| var type = Brew.PackageType.formula | ||
| if words.first == "cask" { | ||
| type = .cask | ||
| words.removeFirst() | ||
| } | ||
| switch words.count { | ||
| case 1: | ||
| return ListedPackage(name: words[0], type: type, version: .installed) | ||
| case 3 where words[1] == ">=": | ||
| return ListedPackage(name: words[0], type: type, version: .atLeast(words[2])) | ||
| default: | ||
| return nil | ||
| } | ||
| } | ||
| } |
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| /// Keeps a Homebrew formula or cask installed, and at or above a minimum version. | ||
| /// | ||
| /// ```swift | ||
| /// var body: some Component { | ||
| /// CommandLineTools() | ||
| /// Homebrew(owner: "ops") | ||
| /// Brew("wget") | ||
| /// Brew("firefox", type: .cask) | ||
| /// Brew("tailscale", version: .atLeast("1.102.0")) | ||
| /// } | ||
| /// ``` | ||
| /// | ||
| /// Waits until Homebrew is on the Mac. Declare `Homebrew(owner:)` so it gets installed and kept | ||
| /// owned by one account; without it, `Brew` runs as whoever owns an existing Homebrew. Every | ||
| /// `brew` runs as that owner, one at a time. A hand-run `brew uninstall` is undone within a | ||
| /// minute. Dropping it from `body` uninstalls it. Changing `version` or tap-qualifying `name` | ||
| /// never does. Use canonical names (`python@3.13`, not the alias `python3`). | ||
| public struct Brew: Component { | ||
| public enum PackageType: String, Sendable, Hashable, Codable { | ||
| case formula | ||
| case cask | ||
| } | ||
|
|
||
| /// Which installed versions satisfy this declaration. Not part of the id: changing it | ||
| /// upgrades in place and never uninstalls. | ||
| public enum Version: Sendable, Hashable { | ||
| /// Any installed version. Never upgrades. The default. | ||
| case installed | ||
| /// `brew upgrade` while the installed version is below this. Homebrew installs its | ||
| /// latest release, not this version itself. | ||
| case atLeast(String) | ||
| } | ||
|
|
||
| let name: String | ||
| let type: PackageType | ||
| let version: Version | ||
| @Fact(\.homebrew.isInstalled) private var homebrewInstalled | ||
|
|
||
| public init(_ name: String, type: PackageType = .formula, version: Version = .installed) { | ||
| self.name = name | ||
| self.type = type | ||
| self.version = version | ||
| } | ||
|
|
||
| public var body: some Component { | ||
| // Gated on `isInstalled`, never on one owner: mixed owners would hide every package, | ||
| // and a hidden package is uninstalled. `isInstalled` goes false only when `bin/brew` is | ||
| // gone, and then the Cellar usually is too, so `remove` finds nothing to remove. | ||
| if homebrewInstalled { | ||
| BrewPackage(name: name, type: type, minimum: version.minimum) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| extension Brew.Version { | ||
| /// `.atLeast`'s floor, or `nil` for `.installed`. | ||
| var minimum: String? { | ||
| switch self { | ||
| case .installed: nil | ||
| case .atLeast(let minimum): minimum | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// Keeps one formula or cask installed and at `minimum` or above. Declared by `Brew`. | ||
| @Resource | ||
| struct BrewPackage { | ||
| static let kind = "astrolabe.brew" | ||
| /// As declared, maybe tap-qualified (`cloudflare/cloudflare/cloudflared`). `brew install` gets it. | ||
| let name: String | ||
| let type: Brew.PackageType | ||
| /// `.atLeast`'s floor, or `nil` for any installed version. | ||
| let minimum: String? | ||
| /// Installed and at or above `minimum`. A hand-run `brew uninstall` or a downgrade (false) | ||
| /// reruns `rollout`. An upgrade made outside Astrolabe changes nothing. | ||
| @Monitor var satisfied: Bool? | ||
|
|
||
| init(name: String, type: Brew.PackageType, minimum: String?) { | ||
| self.name = name | ||
| self.type = type | ||
| self.minimum = minimum | ||
| _satisfied = Monitor(\.homebrew[satisfies: name, type, minimum]) | ||
| } | ||
|
|
||
| /// The short name: tap-qualifying a name rolls out the same id rather than installing one | ||
| /// keg and uninstalling the same keg, with no order between the two. | ||
| var id: String { "brew:\(type.rawValue):\(BrewName.short(name))" } | ||
|
|
||
| static func compute(_ package: Self) -> String { | ||
| Hash.of(kind, package.name, package.type.rawValue, package.minimum) | ||
| } | ||
|
|
||
| @concurrent static func rollout(_ package: Self) async throws { | ||
| try await BrewInstaller.ensure(package.name, type: package.type, minimum: package.minimum) | ||
| } | ||
|
|
||
| /// Uninstalls, as v1 did. Uses only what the id is built from. Not there: a no-op. | ||
| @concurrent static func remove(_ package: Self) async throws { | ||
| try await BrewInstaller.uninstall(package.name, type: package.type) | ||
| } | ||
|
|
||
| /// Failures are mostly network, Homebrew missing or mid re-own (fixed within a minute), or | ||
| /// a floor Homebrew doesn't offer yet. | ||
| static var retryPolicy: RetryPolicy { .retry(Backoff(initial: .seconds(60), max: .seconds(3600))) } | ||
|
|
||
| /// Mostly waiting for the lock: on first boot every package queues behind the others. | ||
| /// Each `brew` command is capped by `ProcessRunner.Timeout.install` (1 h) on its own. | ||
| static var timeout: Duration { .seconds(4 * 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,41 @@ | ||
| extension HomebrewFacts { | ||
| /// Every formula and cask Homebrew has installed, with the version `brew` runs. Read from | ||
| /// the prefix alone: `readdir` and `readlink`, no `brew`, so every minute is cheap. | ||
| /// | ||
| /// Read without the Homebrew lock. It only feeds `@Monitor`s, and a value caught mid-install | ||
| /// just reruns a rollout that checks again under the lock. With the lock, a first boot that | ||
| /// queues every package would also hold up drift detection. | ||
| @FactEntry(schedule: .every(.seconds(60)), read: { try HomebrewReads.packages() }) | ||
| public var packages: BrewPackages = BrewPackages() | ||
|
|
||
| /// Whether `name` (short or tap-qualified) is installed as `type`, at `minimum` or above | ||
| /// (any version when `nil`). Tracked through `packages`. What `BrewPackage` monitors: one | ||
| /// `Bool` per package, so installing one package doesn't rerun every other. | ||
| subscript(satisfies name: String, type: Brew.PackageType, minimum: String?) -> Bool { | ||
| BrewInstaller.check(packages, name, type: type, minimum: minimum) == .satisfied | ||
| } | ||
| } | ||
|
|
||
| /// What Homebrew has installed. Each version is what `brew` runs: for a formula, the `opt` | ||
| /// link's target, otherwise (and for every cask) the highest version directory. | ||
| public struct BrewPackages: Equatable, Sendable { | ||
| /// Short name → version. | ||
| public var formulae: [String: String] | ||
| /// Token → version. | ||
| public var casks: [String: String] | ||
|
|
||
| public init(formulae: [String: String] = [:], casks: [String: String] = [:]) { | ||
| self.formulae = formulae | ||
| self.casks = casks | ||
| } | ||
|
|
||
| /// The installed version of `name` (short or tap-qualified) as `type`, or `nil` when it | ||
| /// isn't installed. | ||
| public func version(of name: String, type: Brew.PackageType) -> String? { | ||
| let short = BrewName.short(name) | ||
| return switch type { | ||
| case .formula: formulae[short] | ||
| case .cask: casks[short] | ||
| } | ||
| } | ||
| } |
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.