Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion internal/archtest/baseline/no-direct-exec.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# Each line is <file>:<line> of a known existing violation.
# Regenerate: ARCHTEST_UPDATE_BASELINE=1 go test ./internal/archtest/...
internal/auth/login.go:191
internal/brew/brew_install.go:369
internal/brew/brew_install.go:400
internal/cli/snapshot.go:21
internal/diff/compare.go:247
internal/diff/compare.go:253
Expand Down
55 changes: 43 additions & 12 deletions internal/brew/brew_install.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,15 +152,35 @@ func InstallWithProgress(cliPkgs, caskPkgs []string, dryRun bool) (installedForm
return installedFormulae, installedCasks, preErr
}

// Pre-flight HEAD all download URLs (formula bottles + cask downloads)
// upfront so the progress bar can be byte-proportional across the WHOLE
// install — formula and cask share one continuous bar instead of two
// algorithms with a jump in between. Bounded so a slow CDN can't stall
// the install phase. Unknown sizes (HEAD failures) contribute 0 and
// simply don't advance the bar for that package.
sizeCtx, sizeCancel := context.WithTimeout(context.Background(), 10*time.Second)
formulaSizes := FetchFormulaSizes(sizeCtx, newCli)
caskSizes := FetchCaskSizes(sizeCtx, newCask)
sizeCancel()

var installTotalBytes int64
for _, s := range formulaSizes {
installTotalBytes += s
}
for _, s := range caskSizes {
installTotalBytes += s
}

progress := ui.NewStickyProgress(len(newCli) + len(newCask))
progress.SetSkipped(skipped)
progress.SetTotalBytes(installTotalBytes)
progress.Start()

var allFailed []failedJob

if len(newCli) > 0 {
progress.SetPhase(ui.PhaseFormula)
failed := runSerialInstallWithProgress(newCli, progress)
failed := runSerialInstallWithProgress(newCli, formulaSizes, progress)
failedSet := make(map[string]bool, len(failed))
for _, f := range failed {
failedSet[f.name] = true
Expand All @@ -174,7 +194,7 @@ func InstallWithProgress(cliPkgs, caskPkgs []string, dryRun bool) (installedForm
}

if len(newCask) > 0 {
caskInstalled, caskFailed := installCasksWithProgress(newCask, progress)
caskInstalled, caskFailed := installCasksWithProgress(newCask, caskSizes, progress)
installedCasks = append(installedCasks, caskInstalled...)
allFailed = append(allFailed, caskFailed...)
}
Expand All @@ -188,21 +208,24 @@ func InstallWithProgress(cliPkgs, caskPkgs []string, dryRun bool) (installedForm
return installedFormulae, installedCasks, nil
}

// installCasksWithProgress installs cask packages one by one with progress reporting.
// Returns the successfully installed cask names and any failed jobs.
func installCasksWithProgress(pkgs []string, progress *ui.StickyProgress) (installed []string, failed []failedJob) {
// installCasksWithProgress installs cask packages one by one with progress
// reporting. Sizes are pre-fetched by the caller (InstallWithProgress) so
// the bar's byte total can span formula + cask phases. Returns successful
// installs and failed jobs.
func installCasksWithProgress(pkgs []string, sizes map[string]int64, progress *ui.StickyProgress) (installed []string, failed []failedJob) {
progress.SetPhase(ui.PhaseCask)

// Pre-flight HEAD all cask URLs to learn download sizes. Bounded so a
// slow CDN doesn't hold up the whole install phase.
sizeCtx, sizeCancel := context.WithTimeout(context.Background(), 10*time.Second)
sizes := FetchCaskSizes(sizeCtx, pkgs)
sizeCancel()

for _, pkg := range pkgs {
progress.SetCurrent(pkg)
progress.PrintLine(" Installing %s...", pkg)

// Seed totalBytes for this cask immediately so the head shows
// "0B/<size>" instead of "—" for the ~500ms before the cache
// tracker's first poll lands. Casks with unknown size pass 0 and
// the head's bytes column stays "—" (correct — we have nothing
// to show).
progress.SetCurrentBytes(0, sizes[pkg])

// Start tracker for this cask (skip if size unknown — no bar, just spinner).
// trackerDone closes only after the goroutine has emitted its final
// reading, so we never let a stale byte value bleed into the next cask.
Expand Down Expand Up @@ -235,6 +258,10 @@ func installCasksWithProgress(pkgs []string, progress *ui.StickyProgress) (insta
progress.IncrementWithStatus(errMsg == "")
duration := ui.FormatDuration(elapsed)
if errMsg == "" {
// Advance the byte-based bar by this cask's known size. Casks
// with unknown sizes (HEAD failed) pass 0 here — they advance the
// count but not the bar's byte fill.
progress.AddCompletedBytes(sizes[pkg])
progress.PrintLine(" %s %s", ui.Green("✔ "+pkg), ui.Cyan("("+duration+")"))
installed = append(installed, pkg)
} else {
Expand Down Expand Up @@ -315,7 +342,7 @@ func handleFailedJobs(failed []failedJob) {
}
}

func runSerialInstallWithProgress(pkgs []string, progress *ui.StickyProgress) []failedJob {
func runSerialInstallWithProgress(pkgs []string, sizes map[string]int64, progress *ui.StickyProgress) []failedJob {
if len(pkgs) == 0 {
return nil
}
Expand All @@ -330,6 +357,10 @@ func runSerialInstallWithProgress(pkgs []string, progress *ui.StickyProgress) []
progress.IncrementWithStatus(errMsg == "")
duration := ui.FormatDuration(elapsed)
if errMsg == "" {
// Advance the byte-based bar by this formula's bottle size.
// Formulae with unknown sizes pass 0; the bar just doesn't move
// for them but the count still advances.
progress.AddCompletedBytes(sizes[pkg])
progress.PrintLine(" %s %s", ui.Green("✔ "+job.name), ui.Cyan("("+duration+")"))
continue
}
Expand Down
24 changes: 15 additions & 9 deletions internal/brew/install_progress_behavior_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,12 @@ fi
if [ "$1" = "list" ] && [ "$2" = "--cask" ]; then
exit 0
fi
if [ "$1" = "info" ] && [ "$3" = "--cask" ]; then
# Cask size pre-fetch — return JSON with token + URL so FetchCaskSizes
if [ "$1" = "info" ] && [ "$2" = "--json=v2" ] && [ "$3" = "--cask" ]; then
# Cask size pre-fetch — return v2 envelope with token + URL so FetchCaskSizes
# has something to parse. The URL points nowhere reachable; HEAD will
# fail and size stays 0, which is fine for this test.
cat <<'EOF'
[{"token":"firefox","url":"http://127.0.0.1:1/firefox.dmg"}]
{"formulae":[],"casks":[{"token":"firefox","url":"http://127.0.0.1:1/firefox.dmg"}]}
EOF
exit 0
fi
Expand Down Expand Up @@ -107,19 +107,24 @@ exit 0
var formulaInfo, caskInfo []string
for _, line := range strings.Split(strings.TrimSpace(string(logContent)), "\n") {
switch {
case strings.HasPrefix(line, "info --json --cask"):
case strings.HasPrefix(line, "info --json=v2 --cask"):
caskInfo = append(caskInfo, line)
case strings.HasPrefix(line, "info --json"):
formulaInfo = append(formulaInfo, line)
}
}

require.Len(t, formulaInfo, 1, "formula alias resolution should be a single batch")
assert.Equal(t, "info --json postgresql kubectl", formulaInfo[0])
assert.NotContains(t, formulaInfo[0], "firefox")
// Two formula info calls are expected: one for alias resolution, one for
// size pre-fetch (FetchFormulaSizes). Both share the same arg shape —
// they're separate consumers of the same brew metadata.
require.Len(t, formulaInfo, 2, "expected one alias-resolution + one size pre-fetch call")
for _, line := range formulaInfo {
assert.Equal(t, "info --json postgresql kubectl", line)
assert.NotContains(t, line, "firefox", "formula info must never include cask names")
}

require.Len(t, caskInfo, 1, "cask size pre-fetch should be a single batch")
assert.Equal(t, "info --json --cask firefox", caskInfo[0])
assert.Equal(t, "info --json=v2 --cask firefox", caskInfo[0])
}

func TestInstallWithProgress_RetrySuccessTracksCanonicalNames(t *testing.T) {
Expand Down Expand Up @@ -192,6 +197,7 @@ exit 0

logContent, err := os.ReadFile(logPath)
require.NoError(t, err)
assert.Equal(t, 1, strings.Count(string(logContent), "info --json foo"))
// Two `info --json foo` calls: alias resolution + size pre-fetch.
assert.Equal(t, 2, strings.Count(string(logContent), "info --json foo"))
assert.Equal(t, 2, strings.Count(string(logContent), "install foo"))
}
105 changes: 100 additions & 5 deletions internal/brew/sizecheck.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,21 @@ import (
"github.com/openbootdotdev/openboot/internal/httputil"
)

// caskInfo is the subset of `brew info --json --cask` we care about.
// caskInfo is the subset of `brew info --json=v2 --cask` we care about.
type caskInfo struct {
Token string `json:"token"`
URL string `json:"url"`
}

// FetchCaskSizes resolves each cask's download URL via `brew info --json
// caskInfoEnvelope matches the v2 JSON schema: a top-level object with
// "formulae" and "casks" arrays. (v1 returned a flat top-level array, but
// brew rejects bare `--json` together with `--cask`, so we have to ask for
// v2 explicitly — see https://docs.brew.sh/Manpage `brew info`.)
type caskInfoEnvelope struct {
Casks []caskInfo `json:"casks"`
}

// FetchCaskSizes resolves each cask's download URL via `brew info --json=v2
// --cask`, then issues HEAD requests in parallel to read Content-Length.
// Returns a map[caskName]bytes; missing entries default to 0 (caller treats
// as "size unknown — no live bytes display").
Expand All @@ -26,16 +34,17 @@ func FetchCaskSizes(ctx context.Context, casks []string) map[string]int64 {
return result
}

args := append([]string{"info", "--json", "--cask"}, casks...)
args := append([]string{"info", "--json=v2", "--cask"}, casks...)
output, err := currentRunner().Output(args...)
if err != nil {
return result
}

var entries []caskInfo
if err := json.Unmarshal(output, &entries); err != nil {
var env caskInfoEnvelope
if err := json.Unmarshal(output, &env); err != nil {
return result
}
entries := env.Casks

client := &http.Client{Timeout: 8 * time.Second}
var mu sync.Mutex
Expand Down Expand Up @@ -69,3 +78,89 @@ func headContentLength(ctx context.Context, client *http.Client, url string) int
}
return resp.ContentLength
}

// ghcrAnonymousToken is the well-known token GHCR accepts for anonymous
// public reads — base64 "A". brew uses the same token internally for bottle
// HEAD/GET against ghcr.io.
const ghcrAnonymousToken = "QQ=="

// formulaInfo is the subset of `brew info --json <names>` we care about.
// The v1 default schema returns a top-level array. Bottle URLs are nested
// per-platform; we pick any one — sizes for the same formula are within
// a few percent across platforms, accurate enough for bar progress.
type formulaInfo struct {
Name string `json:"name"`
Bottle struct {
Stable struct {
Files map[string]struct {
URL string `json:"url"`
} `json:"files"`
} `json:"stable"`
} `json:"bottle"`
}

// FetchFormulaSizes resolves each formula's bottle URL via `brew info --json`
// and HEADs it (with the GHCR anonymous Bearer token) to read Content-Length.
// Same fallback as FetchCaskSizes: missing entries default to 0.
func FetchFormulaSizes(ctx context.Context, formulae []string) map[string]int64 {
result := make(map[string]int64, len(formulae))
if len(formulae) == 0 {
return result
}

args := append([]string{"info", "--json"}, formulae...)
output, err := currentRunner().Output(args...)
if err != nil {
return result
}

var entries []formulaInfo
if err := json.Unmarshal(output, &entries); err != nil {
return result
}

client := &http.Client{Timeout: 8 * time.Second}
var mu sync.Mutex
var wg sync.WaitGroup
for _, e := range entries {
url := pickBottleURL(e)
if url == "" {
result[e.Name] = 0
continue
}
wg.Add(1)
go func(name, url string) {
defer wg.Done()
size := headBottleContentLength(ctx, client, url)
mu.Lock()
result[name] = size
mu.Unlock()
}(e.Name, url)
}
wg.Wait()
return result
}

// pickBottleURL returns any bottle URL from the formula's stable files map.
// Iteration order is unspecified but acceptable: bottles for the same formula
// have near-identical sizes across platforms.
func pickBottleURL(f formulaInfo) string {
for _, file := range f.Bottle.Stable.Files {
if file.URL != "" {
return file.URL
}
}
return ""
}

func headBottleContentLength(ctx context.Context, client *http.Client, url string) int64 {
resp, err := httputil.HeadWithBearer(ctx, client, url, ghcrAnonymousToken)
if err != nil {
return 0
}
defer resp.Body.Close() //nolint:errcheck // best-effort body close
if resp.ContentLength < 0 {
return 0
}
return resp.ContentLength
}
55 changes: 51 additions & 4 deletions internal/brew/sizecheck_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,12 @@ func TestFetchCaskSizesReturnsBytesPerCask(t *testing.T) {
defer srv.Close()

withFakeBrew(t, func(args []string) ([]byte, error) {
// Expect: brew info --json --cask docker rectangle
if len(args) >= 3 && args[0] == "info" && args[1] == "--json" && args[2] == "--cask" {
// Real brew rejects bare --json with --cask (it prints help text);
// the correct invocation is --json=v2 with a nested {"casks":[...]}
// envelope. Mirror that exactly here so tests reflect reality.
if len(args) >= 3 && args[0] == "info" && args[1] == "--json=v2" && args[2] == "--cask" {
return []byte(fmt.Sprintf(
`[{"token":"docker","url":"%s/docker.dmg"},{"token":"rectangle","url":"%s/rectangle.dmg"}]`,
`{"formulae":[],"casks":[{"token":"docker","url":"%s/docker.dmg"},{"token":"rectangle","url":"%s/rectangle.dmg"}]}`,
srv.URL, srv.URL,
)), nil
}
Expand All @@ -40,10 +42,55 @@ func TestFetchCaskSizesReturnsBytesPerCask(t *testing.T) {

func TestFetchCaskSizesSkipsHeadFailures(t *testing.T) {
withFakeBrew(t, func(args []string) ([]byte, error) {
return []byte(`[{"token":"broken","url":"http://127.0.0.1:1/nope"}]`), nil
return []byte(`{"formulae":[],"casks":[{"token":"broken","url":"http://127.0.0.1:1/nope"}]}`), nil
})

sizes := FetchCaskSizes(context.Background(), []string{"broken"})
// Failed HEAD leaves entry at 0 (caller treats as "unknown size").
assert.Equal(t, int64(0), sizes["broken"])
}

func TestFetchFormulaSizesReturnsBytesPerFormula(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// GHCR HEAD requires the anonymous Bearer token; refuse otherwise so
// the test pins both the URL plumbing and the auth header.
if r.Header.Get("Authorization") != "Bearer QQ==" {
w.WriteHeader(http.StatusUnauthorized)
return
}
switch r.URL.Path {
case "/jq.tar.gz":
w.Header().Set("Content-Length", "424157")
case "/ripgrep.tar.gz":
w.Header().Set("Content-Length", "2300000")
}
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()

withFakeBrew(t, func(args []string) ([]byte, error) {
// Formula info uses bare --json (v1, top-level array).
if len(args) >= 2 && args[0] == "info" && args[1] == "--json" {
return []byte(fmt.Sprintf(
`[{"name":"jq","bottle":{"stable":{"files":{"arm64_sequoia":{"url":"%s/jq.tar.gz"}}}}},`+
`{"name":"ripgrep","bottle":{"stable":{"files":{"arm64_sequoia":{"url":"%s/ripgrep.tar.gz"}}}}}]`,
srv.URL, srv.URL,
)), nil
}
return nil, fmt.Errorf("unexpected args: %v", args)
})

sizes := FetchFormulaSizes(context.Background(), []string{"jq", "ripgrep"})
assert.Equal(t, int64(424157), sizes["jq"])
assert.Equal(t, int64(2300000), sizes["ripgrep"])
}

func TestFetchFormulaSizesSkipsMissingBottle(t *testing.T) {
withFakeBrew(t, func(args []string) ([]byte, error) {
// No bottle.stable.files — formula has no bottle (e.g. head-only).
return []byte(`[{"name":"nobottle","bottle":{"stable":{"files":{}}}}]`), nil
})

sizes := FetchFormulaSizes(context.Background(), []string{"nobottle"})
assert.Equal(t, int64(0), sizes["nobottle"])
}
13 changes: 13 additions & 0 deletions internal/httputil/head.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,16 @@ func Head(ctx context.Context, client *http.Client, url string) (*http.Response,
}
return Do(client, req)
}

// HeadWithBearer is Head with an Authorization: Bearer <token> header. Used
// for Homebrew bottle blobs hosted on ghcr.io, which return 401 to anonymous
// HEAD requests but accept the well-known anonymous token "QQ==" (base64
// "A") for public reads — the same trick brew uses internally.
func HeadWithBearer(ctx context.Context, client *http.Client, url, token string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil)
if err != nil {
return nil, fmt.Errorf("build HEAD request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+token)
return Do(client, req)
}
Loading
Loading