Documentation
¶
Overview ¶
Package updater implements `brainjar upgrade` — in-process self-update that fetches the latest release from R2, verifies the cosign bundle against a pinned identity, and atomically swaps the running binary. Consumes the outputs of .github/workflows/release.yml; the URL contract documented below is the implicit API between that pipeline and this package.
See docs/design-m9-upgrade.md for the full spec.
Index ¶
- Constants
- Variables
- func CanInstall() error
- func ExpectedSHA256(checksumsTxt []byte, filename string) (string, error)
- func ExtractBrainjarBinary(r io.Reader, dst io.Writer) error
- func Install(src io.Reader, t *ResolvedTarget) error
- func TarballName() (string, error)
- func VerifyBundle(bundleJSON, checksumsTxt []byte) error
- type Fetcher
- type Options
- type ResolvedTarget
- type Result
Constants ¶
const ( // DefaultBaseURL is the CDN prefix every upgrade reaches under. // Override via Options.BaseURL in tests. DefaultBaseURL = "https://get.brainjar.sh/brainjar" // LatestPath is the pointer file listing the current release tag. // Plain UTF-8, a single tag (e.g. "v0.3.2"), max 32 bytes. LatestPath = "latest" ChecksumsFile = "checksums.txt" ChecksumsBundleFile = "checksums.txt.sigstore.json" )
Source-of-truth constants. The release workflow and this package must agree on every item below; if the pipeline changes any of these paths/shapes, the matching update lands here in the same PR.
const ( ExpectedSANRegex = `` /* 130-byte string literal not displayed */ ExpectedIssuer = "https://token.actions.githubusercontent.com" )
Identity pins. The cosign bundle must verify against these; a mismatch means someone other than our release workflow signed the checksums, and we refuse to install regardless of whether the sha256 lines up.
The SAN accepts two legitimate triggers:
- refs/heads/main — the release-please flow triggers the release workflow via `workflow_dispatch` from main, so the OIDC cert's ref is `refs/heads/main`. This is what every production release has been signed under to date.
- refs/tags/vX.Y.Z — the same workflow also triggers on tag pushes; future releases via that path get tag refs. Allow them so the pin survives a trigger change.
Feature branches, PR refs, and any other ref are rejected. An attacker who can push to main or create a v* tag can already release; this pin doesn't narrow that threat, it just confines "signed by our release workflow" to "signed while running from main or from a release tag".
The issuer is an exact match. GitHub Actions OIDC has a stable token endpoint; anything else in that field is forged or misconfigured.
Variables ¶
var ( ErrUsage = errors.New("updater: usage") ErrNetwork = errors.New("updater: network failure") ErrVerify = errors.New("updater: verification failure") ErrInstall = errors.New("updater: install failure") ErrUnsupported = errors.New("updater: platform unsupported") )
Sentinel errors — the CLI layer maps each to a specific exit code.
var ErrUnsupportedPlatform = errors.New("updater: no release artifact for this platform")
ErrUnsupportedPlatform is returned when the current runtime isn't in the release matrix. Mapped to exit 2 at the CLI layer.
var ErrVerification = errors.New("updater: verification failed")
ErrVerification is the sentinel wrapper returned for every verification failure. The CLI layer checks errors.Is(err, ErrVerification) to map to exit 4.
var PackageManagerSignals = []struct { Prefix string Name string Hint string }{ {"/opt/homebrew/", "Homebrew", "run `brew upgrade brainjar` instead"}, {"/usr/local/Cellar/", "Homebrew", "run `brew upgrade brainjar` instead"}, {"/home/linuxbrew/.linuxbrew/", "Homebrew", "run `brew upgrade brainjar` instead"}, {"/nix/store/", "Nix", "update via your flake / profile instead"}, {"/var/lib/flatpak/", "Flatpak", "run `flatpak update` instead"}, }
PackageManagerSignals maps a resolved-install-path prefix to the pretty name of the package manager that commonly installs there. We surface a stderr warning (not a hard error) so users who upgraded the wrong binary aren't surprised when their package manager later clobbers our write.
apt and rpm are deliberately absent: their /usr/bin placement collides with manual installs on enough distros that detection produces too many false warnings.
var SupportedPlatforms = [][2]string{
{"linux", "amd64"},
{"linux", "arm64"},
{"darwin", "amd64"},
{"darwin", "arm64"},
}
SupportedPlatforms is the (GOOS, GOARCH) matrix the release pipeline publishes for. Held here so a build-time sanity check catches drift from .github/workflows/release.yml.
Functions ¶
func CanInstall ¶
func CanInstall() error
CanInstall reports whether the current platform supports atomic binary swap. Unix is supported; see platform_windows.go for the refusal path.
func ExpectedSHA256 ¶
ExpectedSHA256 returns the lowercase-hex sha256 recorded in checksumsTxt for filename, or an error when the file is missing or multiply-listed. Trailing whitespace on lines is tolerated; empty lines are skipped. Any malformed line is a pipeline bug and fails the whole parse — we don't silently accept "mostly" valid checksum files.
func ExtractBrainjarBinary ¶
ExtractBrainjarBinary reads a gzipped tar from r and streams the single `brainjar` entry into dst. Enforces:
- exactly one entry, matching binaryNameInTarball exactly
- regular file (no symlinks, hardlinks, devices)
- path has no directory component, no "..", not absolute
- uncompressed size ≤ maxBinaryBytes
Anything else is a verification failure; the caller treats the error as tampering, not a malformed-but-okay tarball.
func Install ¶
func Install(src io.Reader, t *ResolvedTarget) error
Install performs the atomic swap. src is an already-opened reader positioned at the start of the new binary (typically the tarball- extracted file). The old binary is preserved at <base>.old on a best-effort basis to enable a manual rollback.
Flow:
- Stage: create .brainjar.new.<pid> in t.Dir with O_EXCL, copy, fsync, chmod 0o755.
- Preserve: best-effort rename of current binary to <base>.old.
- Swap: os.Rename(staging, t.Path) — the one atomic step.
- Durable: fsync the directory so the rename survives a crash.
Any error unwinds best-effort: staging file is removed so we don't leak hidden files into the user's install dir.
func TarballName ¶
TarballName returns the per-platform artifact filename for the current runtime, or an error when runtime is outside the matrix.
func VerifyBundle ¶
VerifyBundle verifies a cosign bundle (checksums.txt.sigstore.json) against the pinned identity AND that the signed message is the sha256 of checksumsTxt. Returns nil on success; any verification failure is wrapped with context so the CLI layer can surface a specific message rather than a generic "verification failed".
The trusted root is fetched via TUF from the sigstore public-good repository. Cached on disk by sigstore-go's TUF client — the first run needs network, subsequent runs are offline.
Types ¶
type Fetcher ¶
Fetcher turns the URL layout into concrete HTTP requests. It holds an *http.Client (injectable for tests) and a base URL (injectable so tests can point at httptest.Server without touching DNS).
func NewFetcher ¶
func NewFetcher() *Fetcher
NewFetcher returns a Fetcher with the production base URL and a generous 30-second timeout. Tests construct the struct literal with a custom BaseURL and httptest client.
type Options ¶
type Options struct {
CurrentVersion string // pkg/version.Version
Check bool // --check: report + exit, no download
Force bool // --force: re-install even when versions match
Fetcher *Fetcher // nil → NewFetcher()
}
Options configure a single upgrade invocation. Zero value is valid: CurrentVersion "" is treated as "dev", no flags set, a production-configured Fetcher is created automatically.
type ResolvedTarget ¶
type ResolvedTarget struct {
Path string // resolved — the file we rename onto
Linked string // the symlink the user invoked, or "" if none
Dir string // filepath.Dir(Path) — where the staging file goes
Base string // filepath.Base(Path) — usually "brainjar"
}
ResolvedTarget holds the canonical install path (after following symlinks) plus whichever symlink the user actually invoked, if different. Both are surfaced in error messages so the user can both fix the right thing (chmod the real dir) and understand why the error names a path they didn't type.
func ResolveExecutable ¶
func ResolveExecutable() (*ResolvedTarget, error)
ResolveExecutable wraps os.Executable + filepath.EvalSymlinks so the caller gets a useful error attributed to the right layer. Returns both the invoked path and the resolved path; Linked is only non-empty when the two differ.
func (*ResolvedTarget) PackageManagerWarning ¶
func (t *ResolvedTarget) PackageManagerWarning() string
PackageManagerWarning returns a single-line stderr-friendly warning if the resolved path lives under a package-manager prefix, otherwise empty string. No trailing newline — caller appends.
func (*ResolvedTarget) PermissionHint ¶
func (t *ResolvedTarget) PermissionHint(probeErr error) error
PermissionHint returns a multi-line error message for an install dir we can't write to. Discloses the symlink indirection when it exists so the user can chmod the right directory rather than getting confused by a path they didn't type.
func (*ResolvedTarget) PreflightWritable ¶
func (t *ResolvedTarget) PreflightWritable() error
PreflightWritable checks that we can create + rename inside t.Dir before the caller spends time on a 5 MiB download. Creates a tiny probe file with O_EXCL, removes it immediately. Returns a rich error via PermissionHint on failure.
type Result ¶
type Result struct {
Current string `json:"current"`
Latest string `json:"latest"`
UpdateAvailable bool `json:"update_available"`
Upgraded bool `json:"upgraded"`
InstalledPath string `json:"installed_path,omitempty"`
PackageWarning string `json:"package_warning,omitempty"`
}
Result is the structured summary returned to the CLI layer. Rendered as JSON with the `json:` tags or as a one-line text message, depending on the caller.