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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,23 @@ Carafe can check whether a formula is installed and whether it meets a minimum v
/opt/macadmins/bin/carafe check <formula> [--min-version=<version>] [--skip-not-installed]
```

### Caching

When running many `check` commands in quick succession (e.g. from multiple Munki `installcheck_script` entries), Carafe caches the output of `brew info --json --installed` on disk for 60 seconds by default. This means only the first `check` call invokes Homebrew; all subsequent calls within the TTL window are served from the cache, significantly reducing the time for a full Munki check run.

The cache is stored at `/var/root/.carafe/brew_info_cache_arm64.json` (Apple Silicon) or `/var/root/.carafe/brew_info_cache_x86_64.json` (Intel). The directory is created with mode `0700` so only root can read or write cache files, preventing symlink and injection attacks.

To disable caching:
```bash
/opt/macadmins/bin/carafe check <formula> --no-cache
```

To use a custom cache TTL:
```bash
/opt/macadmins/bin/carafe check <formula> --cache-ttl=30s
/opt/macadmins/bin/carafe check <formula> --cache-ttl=2m
```

### Munki-specific exit codes

Munki expects an exit code of 0 to indicate that installation is required, and 1 to indicate that no action is needed when using `installcheck_script`. With `--munki-installcheck`, `carafe check` exits 0 if the formula is not installed or fails the `--min-version` check, and 1 if it is installed and meets the requirement.
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.0.1
1.1.0
90 changes: 90 additions & 0 deletions brew/cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package brew

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"

"github.com/macadmins/carafe/exec"
)

const (
// cacheDir is owned by root (mode 0700) so non-root users cannot read,
// write, or pre-create files inside it, preventing symlink/injection attacks.
cacheDir = "/var/root/.carafe"
cacheFileArm64 = cacheDir + "/brew_info_cache_arm64.json"
cacheFileX86_64 = cacheDir + "/brew_info_cache_x86_64.json"
)

// cachePath returns the cache file path for the given brew executable path.
func cachePath(brewPath string) string {
if brewPath == "/opt/homebrew/bin/brew" {
return cacheFileArm64
}
return cacheFileX86_64
}

// infoOutputCached is like infoOutput but uses a filesystem cache of
// `brew info --json --installed` to avoid calling brew once per formula.
// The cache at cacheFile is refreshed when it is older than ttl.
// On any cache error it falls back to a direct brew call.
// If the formula is not present in the installed cache it falls back to a
// direct brew call rather than synthesising a "not installed" response, so
// that typos and unresolved aliases are still caught by brew.
func infoOutputCached(c exec.CarafeConfig, item, cacheFile string, ttl time.Duration) (string, error) {
formulas, err := loadOrRefreshCache(c, cacheFile, ttl)
if err != nil {
return infoOutput(c, item)
}

for _, f := range formulas {
if f.Name == item {
b, jsonErr := json.Marshal([]HomebrewFormula{f})
if jsonErr != nil {
return infoOutput(c, item)
}
return string(b), nil
}
}

// Formula not found in the installed list. Fall back to a direct brew call
// so that typos or aliases are handled correctly (brew will error on an
// unknown name rather than silently reporting it as not-installed).
return infoOutput(c, item)
}

// loadOrRefreshCache returns the list of installed Homebrew formulas, reading
// from cacheFile when it is younger than ttl, or running `brew info --json
// --installed` and writing a fresh cache otherwise.
func loadOrRefreshCache(c exec.CarafeConfig, cacheFile string, ttl time.Duration) ([]HomebrewFormula, error) {
info, err := os.Stat(cacheFile)
if err == nil && time.Since(info.ModTime()) < ttl {
data, readErr := os.ReadFile(cacheFile)
if readErr == nil {
var formulas []HomebrewFormula
if jsonErr := json.Unmarshal(data, &formulas); jsonErr == nil {
return formulas, nil
}
}
}

out, err := c.RunBrew([]string{"info", "--json", "--installed"})
if err != nil {
return nil, fmt.Errorf("brew info --installed: %w", err)
}

var formulas []HomebrewFormula
if err := json.Unmarshal([]byte(out), &formulas); err != nil {
return nil, fmt.Errorf("parse brew info output: %w", err)
}

// Best-effort write; ignore errors so that a missing or unwritable cache
// directory never breaks the check — we just skip caching that run.
if mkErr := os.MkdirAll(filepath.Dir(cacheFile), 0700); mkErr == nil {
_ = os.WriteFile(cacheFile, []byte(out), 0600)
}

return formulas, nil
}
188 changes: 188 additions & 0 deletions brew/cache_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
package brew

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/macadmins/carafe/cudo"
"github.com/macadmins/carafe/exec"
"github.com/macadmins/carafe/shell/testshell"
)

func newTestConfig(outputs ...string) exec.CarafeConfig {
return exec.CarafeConfig{
Arch: "arm64",
CUSudo: &cudo.CUSudo{
CurrentUser: "testuser",
Platform: "darwin",
OSFunc: &cudo.MockOSFunc{},
UserHome: "/Users/testuser",
Executor: testshell.OutputExecutor(outputs...),
},
}
}

func TestLoadOrRefreshCache_CacheMiss(t *testing.T) {
cacheFile := filepath.Join(t.TempDir(), "cache.json")
c := newTestConfig(TestInfoAllOutput)

formulas, err := loadOrRefreshCache(c, cacheFile, 60*time.Second)
require.NoError(t, err)
assert.NotEmpty(t, formulas)

// Cache file should have been written
_, statErr := os.Stat(cacheFile)
assert.NoError(t, statErr)
}

func TestLoadOrRefreshCache_CacheHit(t *testing.T) {
cacheFile := filepath.Join(t.TempDir(), "cache.json")

// Write a fresh cache file manually
cachedFormulas := []HomebrewFormula{{Name: "htop", Installed: []Installed{{Version: "3.3.0"}}}}
data, _ := json.Marshal(cachedFormulas)
require.NoError(t, os.WriteFile(cacheFile, data, 0600))

// Executor should NOT be called because the cache is fresh
c := newTestConfig() // no outputs configured – would error if brew were called

formulas, err := loadOrRefreshCache(c, cacheFile, 60*time.Second)
require.NoError(t, err)
require.Len(t, formulas, 1)
assert.Equal(t, "htop", formulas[0].Name)
}

func TestLoadOrRefreshCache_CacheStale(t *testing.T) {
cacheFile := filepath.Join(t.TempDir(), "cache.json")

// Write a cache file and backdate its mtime to make it stale
staleFormulas := []HomebrewFormula{{Name: "old", Installed: []Installed{{Version: "0.1"}}}}
data, _ := json.Marshal(staleFormulas)
require.NoError(t, os.WriteFile(cacheFile, data, 0600))
past := time.Now().Add(-2 * time.Minute)
require.NoError(t, os.Chtimes(cacheFile, past, past))

c := newTestConfig(TestInfoAllOutput)

formulas, err := loadOrRefreshCache(c, cacheFile, 60*time.Second)
require.NoError(t, err)
// Should have refreshed; the stale "old" formula should not be returned
for _, f := range formulas {
assert.NotEqual(t, "old", f.Name)
}
}

func TestLoadOrRefreshCache_BrewError(t *testing.T) {
cacheFile := filepath.Join(t.TempDir(), "cache.json")
c := exec.CarafeConfig{
Arch: "arm64",
CUSudo: &cudo.CUSudo{
CurrentUser: "testuser",
Platform: "darwin",
OSFunc: &cudo.MockOSFunc{},
UserHome: "/Users/testuser",
Executor: testshell.NewExecutor(testshell.AlwaysError(fmt.Errorf("brew failed"))),
},
}

_, err := loadOrRefreshCache(c, cacheFile, 60*time.Second)
assert.Error(t, err)
}

func TestInfoOutputCached_FormulaFound(t *testing.T) {
cacheFile := filepath.Join(t.TempDir(), "cache.json")

// Pre-populate cache with htop installed
cachedFormulas := []HomebrewFormula{{Name: "htop", Installed: []Installed{{Version: "3.3.0"}}}}
data, _ := json.Marshal(cachedFormulas)
require.NoError(t, os.WriteFile(cacheFile, data, 0600))

c := newTestConfig() // brew should not be called

out, err := infoOutputCached(c, "htop", cacheFile, 60*time.Second)
require.NoError(t, err)

var result []HomebrewFormula
require.NoError(t, json.Unmarshal([]byte(out), &result))
require.Len(t, result, 1)
assert.Equal(t, "htop", result[0].Name)
require.Len(t, result[0].Installed, 1)
assert.Equal(t, "3.3.0", result[0].Installed[0].Version)
}

func TestInfoOutputCached_FormulaNotInstalled(t *testing.T) {
cacheFile := filepath.Join(t.TempDir(), "cache.json")

// Cache has no entry for wget; the function should fall back to a direct
// brew call rather than synthesising a "not installed" response.
cachedFormulas := []HomebrewFormula{{Name: "htop", Installed: []Installed{{Version: "3.3.0"}}}}
data, _ := json.Marshal(cachedFormulas)
require.NoError(t, os.WriteFile(cacheFile, data, 0600))

// The fallback brew call returns the not-installed JSON fixture.
c := newTestConfig(TestInfoNotInstalledOutput)

out, err := infoOutputCached(c, "wget", cacheFile, 60*time.Second)
require.NoError(t, err)
assert.Equal(t, TestInfoNotInstalledOutput, out)
}

func TestLoadOrRefreshCache_CorruptedCacheFile(t *testing.T) {
cacheFile := filepath.Join(t.TempDir(), "cache.json")

// Write corrupt JSON
require.NoError(t, os.WriteFile(cacheFile, []byte("not valid json {{{"), 0600))

c := newTestConfig(TestInfoAllOutput)

// Should refresh rather than fail
formulas, err := loadOrRefreshCache(c, cacheFile, 60*time.Second)
require.NoError(t, err)
assert.NotEmpty(t, formulas)
}

func TestGetInfoOutput_NoCaching(t *testing.T) {
c := newTestConfig(TestInfoInstalledOutput)
out, err := getInfoOutput(c, "htop", 0)
require.NoError(t, err)
assert.Equal(t, TestInfoInstalledOutput, out)
}

func TestGetInfoOutput_NegativeTTL(t *testing.T) {
// Negative TTL should behave like 0 (no cache)
c := newTestConfig(TestInfoInstalledOutput)
out, err := getInfoOutput(c, "htop", -1*time.Second)
require.NoError(t, err)
assert.Equal(t, TestInfoInstalledOutput, out)
}

func TestGetInfoOutput_WithCache(t *testing.T) {
cacheFile := filepath.Join(t.TempDir(), "cache.json")

cachedFormulas := []HomebrewFormula{{Name: "htop", Installed: []Installed{{Version: "3.3.0"}}}}
data, _ := json.Marshal(cachedFormulas)
require.NoError(t, os.WriteFile(cacheFile, data, 0600))

// Redirect the cache by using infoOutputCached directly (getInfoOutput uses the real cache path,
// so we test it via infoOutputCached with a custom path here)
c := newTestConfig()
out, err := infoOutputCached(c, "htop", cacheFile, 60*time.Second)
require.NoError(t, err)

var result []HomebrewFormula
require.NoError(t, json.Unmarshal([]byte(out), &result))
assert.Equal(t, "htop", result[0].Name)
assert.Equal(t, "3.3.0", result[0].Installed[0].Version)
}

func TestCachePath(t *testing.T) {
assert.Equal(t, cacheFileArm64, cachePath("/opt/homebrew/bin/brew"))
assert.Equal(t, cacheFileX86_64, cachePath("/usr/local/bin/brew"))
}
19 changes: 15 additions & 4 deletions brew/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"os"
"time"

"github.com/macadmins/carafe/exec"
)
Expand All @@ -15,7 +16,9 @@ type CheckResult struct {
Name string `json:"name"`
}

func Check(c exec.CarafeConfig, item, minVersion string, munkiInstallsCheck, skipNotInstalled bool) (int, error) {
// Check verifies whether item is installed and optionally meets minVersion.
// cacheTTL controls how long the brew info cache is valid; pass 0 to disable caching.
func Check(c exec.CarafeConfig, item, minVersion string, munkiInstallsCheck, skipNotInstalled bool, cacheTTL time.Duration) (int, error) {
pass := 0
fail := 1

Expand All @@ -40,7 +43,7 @@ func Check(c exec.CarafeConfig, item, minVersion string, munkiInstallsCheck, ski
}
}

output, err := infoOutput(c, item)
output, err := getInfoOutput(c, item, cacheTTL)
if err != nil {
return fail, err
}
Expand Down Expand Up @@ -72,8 +75,8 @@ func Check(c exec.CarafeConfig, item, minVersion string, munkiInstallsCheck, ski
result.Version = version

if minVersion != "" {
// does it meet the minimum version?
meetsMinimum, err := VersionMeetsOrExceedsMinimum(c, item, minVersion)
// Use the already-fetched output to avoid a second brew call.
meetsMinimum, err := meetsMinimumFromOutput(output, item, minVersion)
if err != nil {
return pass, err
}
Expand All @@ -90,6 +93,14 @@ func Check(c exec.CarafeConfig, item, minVersion string, munkiInstallsCheck, ski
return pass, printResultJSON(result)
}

// getInfoOutput fetches brew info JSON for item, using the disk cache when cacheTTL > 0.
func getInfoOutput(c exec.CarafeConfig, item string, cacheTTL time.Duration) (string, error) {
if cacheTTL > 0 {
return infoOutputCached(c, item, cachePath(c.GetBrewPath()), cacheTTL)
}
return infoOutput(c, item)
}

func printResultJSON(result CheckResult) error {
// Print the result as JSON
b, err := json.MarshalIndent(result, "", " ")
Expand Down
4 changes: 2 additions & 2 deletions brew/check_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,8 @@ func TestCheck(t *testing.T) {
},
}

// Call the function under test
result, err := Check(c, tt.item, tt.minVersion, tt.munkiInstallsCheck, tt.skipNotInstalled)
// Call the function under test (cacheTTL=0 disables caching)
result, err := Check(c, tt.item, tt.minVersion, tt.munkiInstallsCheck, tt.skipNotInstalled, 0)

// Assert the error
if tt.expectedError {
Expand Down
9 changes: 7 additions & 2 deletions brew/info.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,13 @@ func VersionMeetsOrExceedsMinimum(c exec.CarafeConfig, item, minimumVersion stri
if err != nil { // couldn't get the state, return true to be safe
return true, err
}
return meetsMinimumFromOutput(out, item, minimumVersion)
}

isInstalled, err := installed(out)
// meetsMinimumFromOutput performs the version comparison using already-fetched
// brew info JSON output, avoiding a second brew call.
func meetsMinimumFromOutput(output, item, minimumVersion string) (bool, error) {
isInstalled, err := installed(output)
if err != nil {
return true, err
}
Expand All @@ -108,7 +113,7 @@ func VersionMeetsOrExceedsMinimum(c exec.CarafeConfig, item, minimumVersion stri
return true, nil // not installed, so it meets the minimum
}

installedVersion, err := getVersion(out)
installedVersion, err := getVersion(output)
if err != nil {
return true, err
}
Expand Down
Loading
Loading