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
8 changes: 8 additions & 0 deletions docs/architecture/architecture-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,14 @@ Plan actions:
- run
- warn

## Planning inspections

Planning resources share Homebrew inventories and asdf version/plugin inventory
checks within one resource build. Each new plan builds a fresh resource list;
there is no persistent cache. Per-plugin asdf version checks remain live.
Apply and upgrade use uncached resources, and Homebrew update rechecks target
the selected package rather than scanning the entire installed inventory.

## Error philosophy

Errors should include:
Expand Down
57 changes: 52 additions & 5 deletions internal/cli/upgrade_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,10 +128,10 @@ func TestUpgradeAppliesManagedHomebrewUpgrades(t *testing.T) {
}}
upgradeRunner := &fakeApplyRunner{responses: []fakeApplyResponse{
{result: applyCommandResult("brew", []string{"list", "--formula", "git"}, 0)},
{result: applyResultWithStdout("brew", []string{"outdated", "--formula", "--quiet"}, "git\n")},
{result: applyResultWithStdout("brew", []string{"outdated", "--formula", "--quiet", "git"}, "git\n")},
{result: applyCommandResult("brew", []string{"upgrade", "git"}, 0)},
{result: applyCommandResult("brew", []string{"list", "--cask", "ghostty"}, 0)},
{result: applyResultWithStdout("brew", []string{"outdated", "--cask", "--quiet"}, "ghostty\n")},
{result: applyResultWithStdout("brew", []string{"outdated", "--cask", "--quiet", "ghostty"}, "ghostty\n")},
{result: applyCommandResult("brew", []string{"upgrade", "--cask", "ghostty"}, 0)},
}}
configPath := writeCLIConfigFile(t, `version: 1
Expand Down Expand Up @@ -181,14 +181,61 @@ brew:
})
expectApplyRunnerCalls(t, upgradeRunner.calls, []applyCommandCall{
{name: "brew", args: []string{"list", "--formula", "git"}},
{name: "brew", args: []string{"outdated", "--formula", "--quiet"}},
{name: "brew", args: []string{"outdated", "--formula", "--quiet", "git"}},
{name: "brew", args: []string{"upgrade", "git"}},
{name: "brew", args: []string{"list", "--cask", "ghostty"}},
{name: "brew", args: []string{"outdated", "--cask", "--quiet"}},
{name: "brew", args: []string{"outdated", "--cask", "--quiet", "ghostty"}},
{name: "brew", args: []string{"upgrade", "--cask", "ghostty"}},
})
}

func TestUpgradeRechecksEachPlannedTargetBeforeMutation(t *testing.T) {
for _, typ := range []string{"formula", "cask"} {
for _, fail := range []bool{false, true} {
name := typ + "/current"
if fail {
name = typ + "/inspection-failure"
}
t.Run(name, func(t *testing.T) {
section := "packages"
if typ == "cask" {
section = "casks"
}
configPath := writeCLIConfigFile(t, "version: 1\nbrew:\n "+section+": [example]\n")
planRunner := &fakeApplyRunner{responses: []fakeApplyResponse{
{result: applyResultWithStdout("brew", nil, "example\n")},
{result: applyResultWithStdout("brew", nil, "example\n")},
}}
fresh := fakeApplyResponse{result: applyResultWithStdout("brew", nil, "")}
if fail {
fresh.err = applyCommandError("brew", []string{"outdated", "--" + typ, "--quiet", "example"}, 1)
}
upgradeRunner := &fakeApplyRunner{responses: []fakeApplyResponse{
{result: applyCommandResult("brew", nil, 0)},
fresh,
}}
var stdout, stderr bytes.Buffer
code := runWithCLIExecRunners(t, []string{"upgrade", "--config", configPath, "--json"}, nil, &stdout, &stderr, planRunner, upgradeRunner)
wantCode, wantAction := exitOK, "noop"
if fail {
wantCode, wantAction = exitApplyFailure, "fail"
}
if code != wantCode {
t.Fatalf("exit = %d, want %d; stdout: %s; stderr: %s", code, wantCode, stdout.String(), stderr.String())
}
report := decodeStatusJSON(t, stdout.String()).Upgrade
if report == nil || len(report.Items) != 1 || report.Items[0].Action != wantAction || report.Summary.Changed != 0 {
t.Fatalf("report = %+v, want %s without mutation", report, wantAction)
}
expectApplyRunnerCalls(t, upgradeRunner.calls, []applyCommandCall{
{name: "brew", args: []string{"list", "--" + typ, "example"}},
{name: "brew", args: []string{"outdated", "--" + typ, "--quiet", "example"}},
})
})
}
}
}

func TestUpgradeOnlyBrewSkipsCaskChecks(t *testing.T) {
runner := &fakeApplyRunner{responses: []fakeApplyResponse{
{result: applyResultWithStdout("brew", []string{"list", "--formula", "--quiet"}, "git\n")},
Expand Down Expand Up @@ -323,7 +370,7 @@ func TestUpgradeJSONReportsUpgradeResults(t *testing.T) {
}}
upgradeRunner := &fakeApplyRunner{responses: []fakeApplyResponse{
{result: applyCommandResult("brew", []string{"list", "--formula", "git"}, 0)},
{result: applyResultWithStdout("brew", []string{"outdated", "--formula", "--quiet"}, "git\n")},
{result: applyResultWithStdout("brew", []string{"outdated", "--formula", "--quiet", "git"}, "git\n")},
{result: applyCommandResult("brew", []string{"upgrade", "git"}, 0)},
}}
configPath := writeCLIConfigFile(t, `version: 1
Expand Down
4 changes: 4 additions & 0 deletions internal/resources/asdf.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ type ASDFPluginResource struct {
updateBeforeInstall bool
versions []string
runner platform.Runner
planningRunner platform.Runner
}

var _ engine.Resource = ASDFPluginResource{}
Expand Down Expand Up @@ -69,6 +70,9 @@ func (resource ASDFPluginResource) Status(ctx context.Context) (engine.StatusRes
if err := resource.validate(); err != nil {
return resource.status(engine.StateFailed, err.Error()), err
}
if resource.planningRunner != nil {
resource.runner = resource.planningRunner
}
if err := resource.checkASDF(ctx); err != nil {
return resource.status(engine.StateFailed, "asdf is required before checking plugins"), err
}
Expand Down
58 changes: 58 additions & 0 deletions internal/resources/asdf_inventory.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package resources

import (
"context"
"errors"
"sync"

"github.com/vwall/kitout/internal/platform"
)

// asdfPlanningInventory shares read-only prerequisites across one planning build.
// Per-plugin version queries and all apply commands use the underlying live runner.
type asdfPlanningInventory struct {
runner platform.Runner
mu sync.Mutex
version asdfInventoryResult
plugins asdfInventoryResult
}

type asdfInventoryResult struct {
loaded bool
result platform.CommandResult
err error
}

func newASDFPlanningInventory(runner platform.Runner) *asdfPlanningInventory {
return &asdfPlanningInventory{runner: runner}
}

func (inventory *asdfPlanningInventory) Run(ctx context.Context, name string, args ...string) (platform.CommandResult, error) {
if err := ctx.Err(); err != nil {
return platform.CommandResult{}, err
}
var cached *asdfInventoryResult
if name == "asdf" {
if len(args) == 1 && args[0] == "--version" {
cached = &inventory.version
} else if len(args) == 3 && args[0] == "plugin" && args[1] == "list" && args[2] == "--urls" {
cached = &inventory.plugins
}
}
if cached == nil {
return inventory.runner.Run(ctx, name, args...)
}
inventory.mu.Lock()
defer inventory.mu.Unlock()
if err := ctx.Err(); err != nil {
return platform.CommandResult{}, err
}
if cached.loaded {
return cached.result, cached.err
}
result, err := inventory.runner.Run(ctx, name, args...)
if ctx.Err() == nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
*cached = asdfInventoryResult{loaded: true, result: result, err: err}
}
return result, err
}
87 changes: 87 additions & 0 deletions internal/resources/asdf_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package resources

import (
"context"
"errors"
"os"
"path/filepath"
"testing"
Expand Down Expand Up @@ -423,3 +424,89 @@ func writeToolVersions(t *testing.T, contents string) string {
}
return path
}

func TestASDFPlanningInventoryApplyReadsFreshState(t *testing.T) {
runner := &fakeRunner{responses: []fakeResponse{
{}, {result: resultWithStdout("asdf", nil, "ruby https://example.com/ruby.git\n")}, {result: resultWithStdout("asdf", nil, "1.0\n")},
{}, {result: resultWithStdout("asdf", nil, "ruby https://example.com/changed.git\n")},
}}
resource := NewASDFPlugin("ruby", "https://example.com/ruby.git", []string{"1.0"}, runner)
resource.planningRunner = newASDFPlanningInventory(runner)
status, err := resource.Status(context.Background())
if err != nil || status.State != engine.StateSatisfied {
t.Fatalf("Status = %+v, %v", status, err)
}
result, err := resource.Apply(context.Background())
if !containsError(err, "configured URL does not match installed URL") || result.Changed {
t.Fatalf("Apply = %+v, %v; want fresh URL rejection", result, err)
}
if len(runner.calls) != 5 {
t.Fatalf("commands = %d, want 5", len(runner.calls))
}
}

func TestASDFPlanningInventorySharesErrorsAndHonorsCancellation(t *testing.T) {
for _, stage := range []string{"version", "plugins"} {
t.Run(stage, func(t *testing.T) {
wantErr := errors.New("inventory failed")
responses := []fakeResponse{{err: wantErr}}
if stage == "plugins" {
responses = append([]fakeResponse{{}}, responses...)
}
runner := &fakeRunner{responses: responses}
resource := NewASDFPlugin("ruby", "https://example.com/ruby.git", nil, runner)
resource.planningRunner = newASDFPlanningInventory(runner)
for i := 0; i < 2; i++ {
status, err := resource.Status(context.Background())
if !errors.Is(err, wantErr) || status.State != engine.StateFailed {
t.Fatalf("Status = %+v, %v", status, err)
}
}
if len(runner.calls) != len(responses) {
t.Fatalf("commands = %d, want %d", len(runner.calls), len(responses))
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := resource.Status(ctx); !errors.Is(err, context.Canceled) {
t.Fatalf("canceled Status error = %v", err)
}
if len(runner.calls) != len(responses) {
t.Fatal("canceled status executed commands")
}
})
}
}

func TestASDFPlanningInventoryDoesNotCacheCancellation(t *testing.T) {
runner := &fakeRunner{responses: []fakeResponse{{err: context.Canceled}, {}, {}}}
inventory := newASDFPlanningInventory(runner)
if _, err := inventory.Run(context.Background(), "asdf", "--version"); !errors.Is(err, context.Canceled) {
t.Fatalf("first call error = %v", err)
}
if _, err := inventory.Run(context.Background(), "asdf", "--version"); err != nil {
t.Fatal(err)
}
fresh := newASDFPlanningInventory(runner)
if _, err := fresh.Run(context.Background(), "asdf", "--version"); err != nil {
t.Fatal(err)
}
if len(runner.calls) != 3 {
t.Fatalf("commands = %d, want retry and fresh inventory query", len(runner.calls))
}
}

func TestASDFPublicConstructorStatusRemainsLive(t *testing.T) {
runner := &fakeRunner{responses: []fakeResponse{{}, {}, {}, {result: resultWithStdout("asdf", nil, "ruby https://example.com/ruby.git\n")}, {result: resultWithStdout("asdf", nil, "1.0\n")}}}
resource := NewASDFPlugin("ruby", "https://example.com/ruby.git", []string{"1.0"}, runner)
first, err := resource.Status(context.Background())
if err != nil || first.State != engine.StateMissing {
t.Fatalf("first Status = %+v, %v", first, err)
}
second, err := resource.Status(context.Background())
if err != nil || second.State != engine.StateSatisfied {
t.Fatalf("second Status = %+v, %v", second, err)
}
if len(runner.calls) != 5 {
t.Fatalf("commands = %d, want 5", len(runner.calls))
}
}
11 changes: 5 additions & 6 deletions internal/resources/brew.go
Original file line number Diff line number Diff line change
Expand Up @@ -294,11 +294,10 @@ func newDirectBrewOutdatedChecker(runner platform.Runner) directBrewOutdatedChec

func (checker directBrewOutdatedChecker) Contains(ctx context.Context, name string) (bool, error) {
result, err := checker.runner.Run(ctx, "brew", "outdated", "--formula", "--quiet", name)
if strings.TrimSpace(result.Stdout) != "" {
return true, nil
}
if err == nil || isExitCode(err, 1) {
return false, nil
outdated := strings.TrimSpace(result.Stdout) != ""
// A targeted query exits 1 for outdated packages, but failures also exit 1.
if err != nil && !(isExitCode(err, 1) && outdated) {
return false, err
}
return false, err
return outdated, nil
}
24 changes: 15 additions & 9 deletions internal/resources/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
)

// Build converts a validated config into executable resources in stable order.
// Call it for each new plan: shared inventory snapshots belong to this build.
func Build(cfg config.Config, runner platform.Runner) []engine.Resource {
return build(cfg, runner, true)
}
Expand All @@ -20,14 +21,15 @@ func BuildUncached(cfg config.Config, runner platform.Runner) []engine.Resource
return build(cfg, runner, false)
}

func build(cfg config.Config, runner platform.Runner, batchHomebrew bool) []engine.Resource {
func build(cfg config.Config, runner platform.Runner, batchPlanning bool) []engine.Resource {
resources := make([]engine.Resource, 0, resourceCount(cfg))
brewOutdated := newBrewOutdatedCache(runner)
caskOutdated := newCaskOutdatedCache(runner)
asdfInventory := newASDFPlanningInventory(runner)
var brewTapInstalled brewTapInstalledChecker = newDirectBrewTapInstalledChecker(runner)
var brewInstalled brewInstalledChecker = newDirectBrewInstalledChecker(runner)
var caskInstalled caskInstalledChecker = newDirectCaskInstalledChecker(runner)
if batchHomebrew {
if batchPlanning {
brewTapInstalled = newBrewTapInstalledCache(runner)
brewInstalled = newBrewInstalledCache(runner)
caskInstalled = newCaskInstalledCache(runner)
Expand All @@ -48,8 +50,8 @@ func build(cfg config.Config, runner platform.Runner, batchHomebrew bool) []engi
for _, name := range cfg.Brew.Packages {
installed := brewInstalled
var outdated brewOutdatedChecker = brewOutdated
if !batchHomebrew {
outdated = newBrewOutdatedCache(runner)
if !batchPlanning {
outdated = newDirectBrewOutdatedChecker(runner)
}
if isFullyQualifiedBrewFormula(name) {
installed = newDirectBrewInstalledChecker(runner)
Expand All @@ -58,21 +60,25 @@ func build(cfg config.Config, runner platform.Runner, batchHomebrew bool) []engi
resources = append(resources, newBrewPackage(name, runner, installed, outdated))
}
for _, plugin := range cfg.ASDF.Plugins {
resources = append(resources, NewASDFPluginWithOptions(
resource := NewASDFPluginWithOptions(
plugin.Name,
plugin.URL,
plugin.Versions,
ASDFPluginOptions{UpdateBeforeInstall: plugin.UpdateBeforeInstall},
runner,
))
)
if batchPlanning {
resource.planningRunner = asdfInventory
}
resources = append(resources, resource)
}
for _, item := range cfg.ASDF.ToolVersions {
resources = append(resources, NewASDFToolVersions(item.Path, item.Tools))
}
for _, name := range cfg.Brew.Casks {
outdated := caskOutdated
if !batchHomebrew {
outdated = newCaskOutdatedCache(runner)
var outdated caskOutdatedChecker = caskOutdated
if !batchPlanning {
outdated = newDirectCaskOutdatedChecker(runner)
}
resources = append(resources, newCask(name, runner, caskInstalled, outdated))
}
Expand Down
18 changes: 18 additions & 0 deletions internal/resources/cask.go
Original file line number Diff line number Diff line change
Expand Up @@ -266,3 +266,21 @@ func (cache *caskOutdatedCache) load(ctx context.Context) {
cache.loadErr = err
}
}

type directCaskOutdatedChecker struct {
runner platform.Runner
}

func newDirectCaskOutdatedChecker(runner platform.Runner) directCaskOutdatedChecker {
return directCaskOutdatedChecker{runner: runner}
}

func (checker directCaskOutdatedChecker) Contains(ctx context.Context, name string) (bool, error) {
result, err := checker.runner.Run(ctx, "brew", "outdated", "--cask", "--quiet", name)
outdated := strings.TrimSpace(result.Stdout) != ""
// A targeted query exits 1 for outdated packages, but failures also exit 1.
if err != nil && !(isExitCode(err, 1) && outdated) {
return false, err
}
return outdated, nil
}
Loading
Loading