Skip to content
112 changes: 112 additions & 0 deletions Examples/BrewEngine/BrewApp.swift
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
}
}
}
47 changes: 47 additions & 0 deletions Examples/HomebrewEngine/HomebrewApp.swift
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
}
}
10 changes: 10 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,5 +70,15 @@ let package = Package(
dependencies: ["Astrolabe"],
path: "Examples/StagingEngine"
),
.executableTarget(
name: "HomebrewEngine",
dependencies: ["Astrolabe"],
path: "Examples/HomebrewEngine"
),
.executableTarget(
name: "BrewEngine",
dependencies: ["Astrolabe"],
path: "Examples/BrewEngine"
),
]
)
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,37 @@
> `.latest`, that update still moves the declared `minimum`, as it always has: one rollout that
> does nothing.
>
> `Homebrew(owner: "ops")` (ENG-3749) keeps Homebrew at `/opt/homebrew`, owned by one account,
> and installs it headlessly with Homebrew's own `install.sh`. `owner` is that account's short
> name (what `whoami` prints), never root, and there's one per Mac: every `brew` runs as it.
> `install.sh` refuses root and needs `sudo` only to create the prefix in root's `/opt`, so the
> daemon creates it and gives it to `owner`, then runs the script as `owner` with
> `NONINTERACTIVE=1`: no password, even at the login window. Declare `CommandLineTools()` too:
> Homebrew never installs CLT, and without it waits silently (the dry run shows no `homebrew`
> id). People run `brew` by hand as the owner, with `brew as-brew-user <cmd>`. Through
> `@Monitor(\.homebrew.owners)`, anything another account creates in the prefix is `chown`ed back
> within a minute, with nothing downloaded, and a deleted Homebrew is installed again. Two facts
> sit under `\.homebrew`: `owners`, every account that owns part of it, and `isInstalled`.
> Apple silicon only, as Homebrew is on macOS. Dropping it from `body` never uninstalls.
>
> `Brew("wget")` (ENG-3738) keeps a formula installed, and `Brew("firefox", type: .cask)` a
> cask. `Brew("tailscale", version: .atLeast("1.102.0"))` also runs `brew upgrade` while the
> installed version is below the floor; Homebrew installs its latest, and changing `version`
> never uninstalls. The id is `brew:<type>:<short name>`, so tap-qualifying a name
> (`cloudflare/cloudflare/cloudflared`) keeps the id: nothing is uninstalled, and the rollout
> finds it installed. Dropping a `Brew` from `body` uninstalls it, also after a restart, from
> `records.json`. Every `brew` runs as Homebrew's one owner, one at a time, under the lock
> `Homebrew` holds for `install.sh` and a re-own. A third fact, `\.homebrew.packages`, scans the
> Cellar and Caskroom every minute without running `brew`, and each `Brew` monitors whether it's
> satisfied, so a hand-run `brew uninstall`, or a downgrade below the floor, is undone within a
> minute. `Brew` waits on `\.homebrew.isInstalled`, never on one owner: when another admin runs
> `brew install`, mixed owners would hide every package, and a hidden package is uninstalled.
> Declare `Homebrew(owner:)` too. Without it, `Brew` waits silently on a clean Mac, and on a Mac
> that has Homebrew it runs as the owner, failing and retrying while the owners are mixed. Read
> `\.homebrew.packages` in your own components with `@Fact`, e.g. to declare something only once
> a binary is installed. Use canonical names (`python@3.13`, not the alias `python3`): an alias
> never reads as installed, so it fails with `didNotInstall` forever. See `Examples/BrewEngine`.
>
> Everything below documents v1, which stays on `main`.

A declarative macOS configuration framework. Describe the desired state of a machine -- packages, services, system settings -- and Astrolabe continuously converges reality to match.
Expand Down
109 changes: 109 additions & 0 deletions Sources/Astrolabe/BuiltIn/Brew/Brew.swift
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)
Comment thread
underthestars-zhy marked this conversation as resolved.
Resolved
}
}
}

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) }
}
41 changes: 41 additions & 0 deletions Sources/Astrolabe/BuiltIn/Brew/BrewFact.swift
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]
}
}
}
Loading