Skip to content
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
}
}
5 changes: 5 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,5 +70,10 @@ let package = Package(
dependencies: ["Astrolabe"],
path: "Examples/StagingEngine"
),
.executableTarget(
name: "HomebrewEngine",
dependencies: ["Astrolabe"],
path: "Examples/HomebrewEngine"
),
]
)
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,19 @@
> `.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.
>
> 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
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ final class CommandLineToolsContext: Sendable {

let system: any CommandLineToolsSystem
/// Serializes everything CLT does: both facts' reads and the whole of a rollout's install.
/// Homebrew's rollout takes it too, through `CLTInstaller.ensure`.
/// Homebrew's rollout waits on it too, through `CommandLineToolsReads.installed()`.
let lock = AsyncSemaphore(value: 1)
/// An install holds the lock and has started changing the Mac. `ensure` then waits for it
/// rather than trusting a receipt that can show before all of CLT's files are there.
Expand All @@ -26,7 +26,7 @@ final class CommandLineToolsContext: Sendable {
/// Installs Command Line Tools through `softwareupdate`, headlessly.
enum CLTInstaller {
/// Makes sure CLT is installed at `minimum` or newer; with `nil`, installs the newest Apple
/// offers if CLT is missing. Homebrew's rollout calls `ensure(minimum: nil)`.
/// offers if CLT is missing.
///
/// Checks the Mac first, without the lock: when CLT is already there, it never waits on a
/// catalog listing. Everything that changes the Mac happens under the lock.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
/// ```
///
/// Installs the newest CLT Apple offers this macOS, and installs it again if someone deletes it:
/// `installed` is read every 15 minutes. Never downgrades, never uninstalls: dropping it from
/// `installed` is read every minute. Never downgrades, never uninstalls: dropping it from
/// `body` leaves CLT in place.
public struct CommandLineTools: Component {
public enum Version: Sendable, Equatable {
Expand Down Expand Up @@ -68,7 +68,7 @@ struct CommandLineToolsInstall {
/// Failures are mostly network (catalog unreachable), and each attempt lists the catalog.
static var retryPolicy: RetryPolicy { .retry(Backoff(initial: .seconds(120), max: .seconds(3600))) }

/// Waiting out another holder of the lock (e.g. Homebrew's `ensure`), plus a listing and an
/// Waiting out another holder of the lock (e.g. a catalog check), plus a listing and an
/// install of its own.
static var timeout: Duration { .seconds(3 * 3600) }
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@ public struct CommandLineToolsFacts: FactNamespace {
/// CLT is missing, such as after someone deletes it, and anything declared behind
/// `if installed != nil` is then removed: gate only resources whose `remove` does nothing.
/// To repair a resource after a deletion, `@Monitor` `isInstalled` instead.
@FactEntry(schedule: .every(.seconds(15 * 60)), timeout: .seconds(80 * 60), read: {
///
/// Read every minute: a `stat` and one `pkgutil`. A read that comes due during an install
/// waits it out, so the value lands as soon as the install ends, and whatever waits on CLT
/// (`Homebrew`) goes ahead then.
@FactEntry(schedule: .every(.seconds(60)), timeout: .seconds(80 * 60), read: {
try await CommandLineToolsReads.installed()
})
public var installed: CLTVersion?
Expand Down
67 changes: 67 additions & 0 deletions Sources/Astrolabe/BuiltIn/Homebrew/Homebrew.swift
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) }
}
35 changes: 35 additions & 0 deletions Sources/Astrolabe/BuiltIn/Homebrew/HomebrewFact.swift
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 Sources/Astrolabe/BuiltIn/Homebrew/HomebrewInstaller.swift
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 }
Comment thread
underthestars-zhy marked this conversation as resolved.
Resolved

// 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)"
}
}
}
Loading
Loading