Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
## Cursor Cloud specific instructions

### What this is
Astrolabe is a **macOS-only** Swift Package (SwiftPM, `swift-tools-version: 6.2`,
Astrolabe is a **macOS-only** Swift Package (SwiftPM, `swift-tools-version: 6.3`,
`platforms: [.macOS(.v15)]`). It is a declarative macOS configuration framework that
runs as a LaunchDaemon and reconciles system state (Homebrew, launchd, Jamf, system
settings). It ships two libraries (`Astrolabe`, `AstrolabeUtils`) plus example
Expand All @@ -19,7 +19,8 @@ run on macOS 15+**. The package's own targets import macOS-only modules with no
`RecordStore.swift`), plus runtime use of `launchd` and `launchctl`.

Consequence on the Linux VM:
- `swift package resolve` and `swift package describe` **work**.
- `swift package resolve` and `swift package describe` **work** with Swift 6.3+
(see the toolchain note below).
- `swift build` compiles the entire third-party dependency graph (NIO, gRPC,
swift-crypto/BoringSSL, OpenTelemetry, ArgumentParser, etc.) successfully, then
**fails** on the first Astrolabe target with `error: no such module 'Darwin'`.
Expand All @@ -29,8 +30,10 @@ Consequence on the Linux VM:
that is the explicit task — it is an environment limitation, not a repo bug.

### Toolchain on the Linux VM
- Swift **6.2.4** is installed via `swiftly` (matches `swift-tools-version: 6.2`).
It is on `PATH` for login shells. In a non-login/non-interactive context, call it
- The package needs Swift **6.3+** (`swift-tools-version: 6.3`; `@Resource` emits
`@section`/`@used`, SE-0492). The VM snapshot was set up with Swift **6.2.4** via
`swiftly`, which can't read the manifest: install a 6.3+ toolchain with `swiftly`
first. It is on `PATH` for login shells. In a non-login/non-interactive context, call it
by absolute path `"$HOME/.local/share/swiftly/bin/swift"` or source
`"$HOME/.local/share/swiftly/env.sh"` first.
- C++ dependencies (swift-nio-ssl's bundled BoringSSL) require C++ stdlib headers.
Expand Down
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
}
}
2 changes: 0 additions & 2 deletions Examples/StagingEngine/StagingApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,4 @@ struct StagingApp: Astrolabe {
StagingFile(path: "\(StateFile.directory)/\(name)", contents: contents)
}
}

static var resourceTypes: [any Resource.Type] { [StagingFile.self] }
}
3 changes: 2 additions & 1 deletion Examples/StagingEngine/StagingFile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import Astrolabe
import Foundation

/// A file with fixed contents. Staging only: not part of the library.
struct StagingFile: Resource {
@Resource
struct StagingFile {
static let kind = "staging.file"
let path: String
let contents: String
Expand Down
11 changes: 10 additions & 1 deletion Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 13 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// swift-tools-version: 6.2
// swift-tools-version: 6.3
// The swift-tools-version declares the minimum version of Swift required to build this package.

import CompilerPluginSupport
Expand All @@ -19,6 +19,7 @@ let package = Package(
],
dependencies: [
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"),
.package(url: "https://github.com/groue/Semaphore.git", from: "0.1.0"),
.package(url: "https://github.com/photon-hq/SignozSwift.git", branch: "main"),
.package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "1.0.0"),
.package(url: "https://github.com/swiftlang/swift-syntax.git", from: "602.0.0"),
Expand All @@ -40,6 +41,7 @@ let package = Package(
"AstrolabeUtils",
"AstrolabeMacros",
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "Semaphore", package: "Semaphore"),
.product(name: "SignozSwift", package: "SignozSwift"),
.product(name: "Subprocess", package: "swift-subprocess"),
]
Expand Down Expand Up @@ -68,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"
),
]
)
55 changes: 51 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,56 @@
> turns it into a declaration of `id@hash` resources, and makes the Mac match; `records.json`
> keeps the last successful snapshot per id. A pass that reads a fact before its first read has
> landed acts on nothing, so nothing is removed or rolled out on a default. The foundation ships
> `Component`, `Resource`, `@Fact`, the engine, and the records — no built-in facts or resources
> yet. See `Examples/StagingEngine` for a complete app, and run `sudo <binary> --dry-run` to
> print the diff without acting.
> `Component`, `Resource`, `@Fact`, the engine, and the records. See `Examples/StagingEngine` for
> a complete app, and run `sudo <binary> --dry-run` to print the diff without acting.
>
> Built in so far (ENG-3739): `CommandLineTools()` installs the Xcode Command Line Tools through
> `softwareupdate` when they're missing, headlessly, and never updates them.
> `CommandLineTools(version: .latest)` also keeps them at the newest Apple offers this macOS:
> it checks the catalog every 6 hours, but never on the first read after launch. Neither
> downgrades or uninstalls. Two facts sit under `\.commandLineTools`: `installed`, the version on
> the Mac, and `latestAvailable`, the newest Apple offers or the installed version when that's
> newer. Hash `installed` into a resource that must redo work after a CLT update. It reads `nil`
> whenever 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.
>
> `@Monitor` (ENG-3751) sits next to `@Fact`: it reads a fact into a resource when `body` builds
> it, and when that value changes the engine reruns `rollout` toward the same hash, without
> cancelling one in flight. `CommandLineTools` monitors `\.commandLineTools.isInstalled`, so a
> deleted CLT is installed again, while an update made outside Astrolabe starts nothing. Under
> `.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`.

Expand Down Expand Up @@ -317,7 +364,7 @@ System probes (SIP status, MDM enrollment) read the machine through a subprocess
## Requirements

- macOS 15+
- Swift 6.2+
- Swift 6.3+

Astrolabe spawns child processes through [swift-subprocess](https://github.com/swiftlang/swift-subprocess), which it pulls in transitively. Consumer packages that need to shell out should depend on it directly -- Astrolabe's `ProcessRunner` is internal.

Expand Down
11 changes: 3 additions & 8 deletions Sources/Astrolabe/Astrolabe.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,15 @@ import Foundation
/// StagingFile(path: "/tmp/astrolabe-staging/\(name)", contents: contents)
/// }
/// }
///
/// static var resourceTypes: [any Resource.Type] { [StagingFile.self] }
/// }
/// ```
///
/// Resource types aren't listed: `@Resource` records each one in the binary, and the engine
/// finds them all at launch.
@MainActor
public protocol Astrolabe: Component, SendableMetatype {
init()

/// Every resource type the app can declare, so records written by an earlier run can be
/// decoded after a restart even if `body` doesn't declare that type yet. Types seen in a
/// declaration are registered too. Default: none.
static var resourceTypes: [any Resource.Type] { get }

/// Telemetry sink for operational events.
///
/// Default: `NoopAstrolabeTelemetry()` — Astrolabe sends no telemetry.
Expand All @@ -39,7 +35,6 @@ public protocol Astrolabe: Component, SendableMetatype {
}

extension Astrolabe {
public static var resourceTypes: [any Resource.Type] { [] }
public static var telemetry: AstrolabeTelemetry { NoopAstrolabeTelemetry() }

/// Entry point called by the Swift runtime when this type is marked `@main`.
Expand Down
Loading