Skip to content
Open
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
1 change: 1 addition & 0 deletions docs/structured-output.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ Codes: `EMULATOR_NOT_CONFIGURED` (no AWS container configured), `EMULATOR_NOT_RU
"error": null
}
```
When the installed version is current but its bundled-extension set is incomplete, `updateAvailable` is `true` with `currentVersion` equal to `latestVersion` (modulo the `v` prefix) and the check shape additionally carries `"repairBundled": true`. The key is absent otherwise, and is what distinguishes a same-version repair from an ordinary upgrade. It belongs to the check shape only: an applied update reports the shape below regardless of what triggered it.
```json
{
"schemaVersion": 1,
Expand Down
12 changes: 10 additions & 2 deletions internal/output/envelope_sink.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,21 @@ func (s *EnvelopeSink) Emit(event Event) {
s.data["currentVersion"] = e.CurrentVersion
s.data["latestVersion"] = e.LatestVersion
s.data["updateAvailable"] = e.Available
// Only present when true, so the pre-existing shapes are unchanged. It
// is what lets a JSON consumer tell a same-version bundled-set repair
// from an ordinary upgrade; the version fields alone cannot (they
// differ only by the "v" prefix in the repair case).
if e.RepairBundled {
s.data["repairBundled"] = true
}
case UpdateAppliedEvent:
// An UpdateCheckedEvent always precedes this on the apply path (Check
// fires it unconditionally now, for the plain-text "Update available"
// line), so clear the keys it set rather than leaving stale
// latestVersion/updateAvailable alongside the applied-update shape.
// line), so clear every key it set rather than leaving stale check
// fields alongside the applied-update shape.
delete(s.data, "latestVersion")
delete(s.data, "updateAvailable")
delete(s.data, "repairBundled")
s.data["currentVersion"] = e.CurrentVersion
s.data["updatedVersion"] = e.UpdatedVersion
s.data["updated"] = true
Expand Down
40 changes: 40 additions & 0 deletions internal/output/envelope_sink_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,26 @@ func TestEnvelopeSink_UpdateCheckedEvent(t *testing.T) {
if _, hasApplied := data["updated"]; hasApplied {
t.Fatalf("did not expect an 'updated' key from UpdateCheckedEvent: %+v", data)
}
if _, hasRepair := data["repairBundled"]; hasRepair {
t.Fatalf("repairBundled must be absent on an ordinary check: %+v", data)
}
}

// TestEnvelopeSink_UpdateCheckedRepairBundled covers the same-version
// bundled-set repair: the version fields differ only by the "v" prefix, so
// the repairBundled key is the only way a JSON consumer can tell a repair
// from an ordinary upgrade.
func TestEnvelopeSink_UpdateCheckedRepairBundled(t *testing.T) {
t.Parallel()

sink := NewEnvelopeSink(FormatJSON)
sink.Emit(UpdateCheckedEvent{CurrentVersion: "2.3.0", LatestVersion: "v2.3.0", Available: true, RepairBundled: true})

envelope := sink.Result("update", nil)
data := envelope.Data.(map[string]any)
if data["updateAvailable"] != true || data["repairBundled"] != true {
t.Fatalf("unexpected data: %+v", data)
}
}

// TestEnvelopeSink_UpdateCheckedEnvelopeJSON pins the full serialized
Expand Down Expand Up @@ -337,3 +357,23 @@ func TestSlugify(t *testing.T) {
}
}
}

// TestEnvelopeSink_UpdateAppliedClearsRepairBundled: the applied-update shape
// documents no repairBundled key, so the cleanup that already scrubs
// latestVersion and updateAvailable must scrub it too.
func TestEnvelopeSink_UpdateAppliedClearsRepairBundled(t *testing.T) {
t.Parallel()

sink := NewEnvelopeSink(FormatJSON)
sink.Emit(UpdateCheckedEvent{CurrentVersion: "2.3.0", LatestVersion: "v2.3.0", Available: true, RepairBundled: true})
sink.Emit(UpdateAppliedEvent{CurrentVersion: "2.3.0", UpdatedVersion: "v2.3.0", Method: "binary"})

envelope := sink.Result("update", nil)
data := envelope.Data.(map[string]any)
if _, has := data["repairBundled"]; has {
t.Fatalf("repairBundled must not leak into the applied-update shape: %+v", data)
}
if data["updated"] != true {
t.Fatalf("expected updated: true, got %+v", data)
}
}
14 changes: 10 additions & 4 deletions internal/output/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -206,15 +206,21 @@ type EmulatorStatusEvent struct {
}

// UpdateCheckedEvent reports the result of an update check. It always fires
// once per Check call — DevBuild, then Available, discriminate which of the
// three possible outcomes (dev build skipped the check / already up to date /
// an update is available) the formatter should render. LatestVersion is empty
// when DevBuild is true (the check never ran).
// once per Check call. DevBuild, then RepairBundled, then Available,
// discriminate which of the four possible outcomes (dev build skipped the
// check / already up to date / the installed set is incomplete and is being
// repaired / an update is available) the formatter should render.
// LatestVersion is empty when DevBuild is true (the check never ran).
type UpdateCheckedEvent struct {
CurrentVersion string
LatestVersion string
Available bool
DevBuild bool
// RepairBundled reports that the check found the installed version current
// but its bundled-extension set incomplete, so the update reinstalls the
// same version rather than reporting "already up to date". Implies
// Available, and CurrentVersion equals LatestVersion.
RepairBundled bool
}

// UpdateAppliedEvent reports that an update was downloaded and installed.
Expand Down
5 changes: 5 additions & 0 deletions internal/output/plain_format.go
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,11 @@ func formatUpdateChecked(e UpdateCheckedEvent) string {
switch {
case e.DevBuild:
return "> Note: Running a development build, skipping update check"
case e.RepairBundled:
// States only the finding: this line also renders under --check, where
// nothing is reinstalled. The apply path narrates the reinstall itself
// (see update.Update).
return fmt.Sprintf("Bundled extensions are missing from this install (version %s)", e.CurrentVersion)
case !e.Available:
return fmt.Sprintf("> Note: Already up to date (%s)", e.CurrentVersion)
default:
Expand Down
6 changes: 6 additions & 0 deletions internal/output/plain_format_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,12 @@ func TestFormatEventLine(t *testing.T) {
want: "Update available: 2.2.1 → 2.3.0",
wantOK: true,
},
{
name: "update checked event repairing bundled set",
event: UpdateCheckedEvent{CurrentVersion: "2.3.0", LatestVersion: "v2.3.0", Available: true, RepairBundled: true},
want: "Bundled extensions are missing from this install (version 2.3.0)",
wantOK: true,
},
{
name: "update applied event",
event: UpdateAppliedEvent{CurrentVersion: "2.2.1", UpdatedVersion: "2.3.0", Method: "homebrew"},
Expand Down
91 changes: 91 additions & 0 deletions internal/update/bundled.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package update

import (
"errors"
"io/fs"
"os"
"path/filepath"
goruntime "runtime"

"github.com/localstack/lstk/internal/extension"
"github.com/localstack/lstk/internal/log"
"github.com/localstack/lstk/internal/version"
)

// missingBundledMembers reports which members of the bundled set this release
// ships are absent from the install directory, so `lstk update` can repair an
// incomplete install instead of short-circuiting on the version alone.
//
// It exists for one specific situation. A user crossing the transition on the
// binary channel is updated by their *old* lstk, which installs only the lstk
// binary and ignores the archive's other members, leaving a current binary
// with no bundled extensions. They cannot fix that by updating again, because
// applyUpdate always jumps straight to the newest release: they are already on
// it, so a version comparison reports "already up to date" until another
// release ships, potentially a week later.
//
// The directory probed is extension.BundledDir, by definition the directory
// the resolver reads bundled extensions from, so the completeness check and
// resolution can never look in different places.
//
// It returns nil, leaving the version comparison as the sole trigger exactly
// as before, in two cases that matter:
//
// - The release ships no bundle (version.BundledSet is empty). Every
// pre-bundling release, and any rollback to one, is therefore unaffected.
// - lstk was installed by Homebrew or npm, where the package manager replaces
// the whole package and so owns set completeness itself. Repairing there
// would mean shelling out to the package manager for a version it has
// already installed.
func missingBundledMembers() []string {
expected := version.BundledSet()
if len(expected) == 0 {
return nil
}
if DetectInstallMethod().Method != InstallBinary {
return nil
}
dir := extension.BundledDir(log.Nop())
if dir == "" {
return nil
}
return missingSetMembers(dir, expected)
}

// missingSetMembers returns the expected names that are absent from dir, in
// the order they were expected. "Absent" means missing or unusable: a name
// that stats but cannot run (a directory, or a binary without its exec bit)
// leaves the user exactly as stranded as no file at all, so it triggers the
// same repair. A stat failure that is not absence (a permission or I/O error)
// counts as present instead, because treating it as missing would re-download
// the whole release on every run over a transient error.
func missingSetMembers(dir string, expected []string) []string {
var missing []string
for _, name := range expected {
info, err := os.Stat(filepath.Join(dir, name))
switch {
case errors.Is(err, fs.ErrNotExist):
missing = append(missing, name)
case err != nil:
continue
case !usableSetMember(info, name):
missing = append(missing, name)
}
}
return missing
}

// usableSetMember mirrors what extension resolution will accept (the
// resolver's side of the rule is isExecutableFile in internal/extension): a
// regular file, with an exec bit on Unix unless it is the descriptions file.
// On Windows executability is carried by the name, which the stamped set
// already includes.
func usableSetMember(info os.FileInfo, name string) bool {
if !info.Mode().IsRegular() {
return false
}
if name == descriptionsFileName || goruntime.GOOS == "windows" {
return true
}
return info.Mode().Perm()&0o111 != 0
}
Loading
Loading