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
11 changes: 11 additions & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ import (
"github.com/spf13/cobra"

"github.com/open-source-cloud/devstack/internal/alias"
"github.com/open-source-cloud/devstack/internal/selfupdate"
"github.com/open-source-cloud/devstack/internal/version"
"github.com/open-source-cloud/devstack/internal/xdg"
)

// GlobalOpts holds the global flags every command renders through.
Expand Down Expand Up @@ -54,6 +56,15 @@ func NewRootCmd(opts Options) *cobra.Command {
setupLogging(cmd.ErrOrStderr(), g)
return nil
},
// Fail-silent, throttled update notice on stderr (spec 14). Skipped for
// --json/--quiet and dev builds; at most one network check per day.
PersistentPostRun: func(cmd *cobra.Command, _ []string) {
if g.JSON || g.Quiet {
return
}
selfupdate.Notifier{Current: version.Version, CacheDir: xdg.CacheHome()}.
Notify(cmd.Context(), cmd.ErrOrStderr())
},
}

pf := root.PersistentFlags()
Expand Down
127 changes: 127 additions & 0 deletions internal/selfupdate/notify.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package selfupdate

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

"golang.org/x/mod/semver"
)

// This file is the background update notifier (spec 14): a throttled, fail-silent
// check that prints a one-line notice when a newer release exists. It hits the
// network at most once per TTL (cached under the XDG cache dir), never blocks a
// command for long, never errors out, and stays silent for dev builds, --json/
// --quiet output, or when DEVSTACK_NO_UPDATE_NOTIFIER is set.

const (
notifierTTL = 24 * time.Hour
notifierTimeout = 2 * time.Second
notifierDisableEnv = "DEVSTACK_NO_UPDATE_NOTIFIER"
notifierCacheFile = "update-check.json"
)

type notifyCache struct {
CheckedAt time.Time `json:"checkedAt"`
Latest string `json:"latest"`
}

// Notifier performs a throttled, fail-silent "is there a newer release?" check.
type Notifier struct {
Current string // the running version
CacheDir string // the devstack cache dir (cache file written directly inside)
Now func() time.Time // injectable clock (default time.Now)
CheckFn func(ctx context.Context) (string, error) // returns the latest tag (default LatestTag)
}

// Notify writes an update notice to w if a newer release is available. It is
// best-effort: any error (no network, bad cache, rate limit) silently yields no
// notice. Returns true iff a notice was written (for tests).
func (n Notifier) Notify(ctx context.Context, w io.Writer) bool {
if os.Getenv(notifierDisableEnv) != "" {
return false
}
if n.Current == "" || IsDevBuild(n.Current) {
return false // dev/dirty builds have no clean version to compare
}
latest := n.latest(ctx)
if latest == "" {
return false
}
if semver.IsValid(n.Current) && semver.IsValid(latest) && semver.Compare(latest, n.Current) > 0 {
fmt.Fprintf(w, "\nA new devstack release is available: %s → %s (run `devstack self update`)\n", n.Current, latest)
return true
}
return false
}

// latest returns the latest tag from a fresh cache, or refreshes via CheckFn
// (bounded + fail-silent) when the cache is stale/absent.
func (n Notifier) latest(ctx context.Context) string {
now := n.now()
if c, ok := n.readCache(); ok && !c.CheckedAt.IsZero() && now.Sub(c.CheckedAt) < notifierTTL {
return c.Latest // fresh cache → no network
}
cctx, cancel := context.WithTimeout(ctx, notifierTimeout)
defer cancel()
latest, err := n.check(cctx)
if err != nil || latest == "" {
return ""
}
n.writeCache(notifyCache{CheckedAt: now, Latest: latest})
return latest
}

func (n Notifier) now() time.Time {
if n.Now != nil {
return n.Now()
}
return time.Now()
}

func (n Notifier) check(ctx context.Context) (string, error) {
if n.CheckFn != nil {
return n.CheckFn(ctx)
}
return LatestTag(ctx)
}

func (n Notifier) cachePath() string {
if n.CacheDir == "" {
return ""
}
return filepath.Join(n.CacheDir, notifierCacheFile)
}

func (n Notifier) readCache() (notifyCache, bool) {
p := n.cachePath()
if p == "" {
return notifyCache{}, false
}
b, err := os.ReadFile(p)
if err != nil {
return notifyCache{}, false
}
var c notifyCache
if err := json.Unmarshal(b, &c); err != nil {
return notifyCache{}, false
}
return c, true
}

func (n Notifier) writeCache(c notifyCache) {
p := n.cachePath()
if p == "" {
return
}
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
return // best-effort
}
if b, err := json.Marshal(c); err == nil {
_ = os.WriteFile(p, b, 0o644)
}
}
99 changes: 99 additions & 0 deletions internal/selfupdate/notify_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package selfupdate

import (
"context"
"errors"
"strings"
"testing"
"time"
)

func TestNotifyAvailable(t *testing.T) {
calls := 0
n := Notifier{
Current: "v0.1.0",
CacheDir: t.TempDir(),
Now: func() time.Time { return time.Unix(1000, 0) },
CheckFn: func(context.Context) (string, error) { calls++; return "v0.2.0", nil },
}
var w strings.Builder
if !n.Notify(context.Background(), &w) {
t.Fatal("expected an update notice")
}
if !strings.Contains(w.String(), "v0.1.0 → v0.2.0") {
t.Errorf("notice = %q", w.String())
}
if calls != 1 {
t.Errorf("checked %d times, want 1", calls)
}
}

func TestNotifyUpToDate(t *testing.T) {
n := Notifier{
Current: "v0.2.0",
CacheDir: t.TempDir(),
Now: func() time.Time { return time.Unix(1000, 0) },
CheckFn: func(context.Context) (string, error) { return "v0.2.0", nil },
}
var w strings.Builder
if n.Notify(context.Background(), &w) || w.Len() != 0 {
t.Errorf("no notice when current == latest, got %q", w.String())
}
}

func TestNotifyThrottledByCache(t *testing.T) {
dir := t.TempDir()
calls := 0
mk := func(now time.Time) Notifier {
return Notifier{
Current: "v0.1.0", CacheDir: dir,
Now: func() time.Time { return now },
CheckFn: func(context.Context) (string, error) { calls++; return "v0.2.0", nil },
}
}
// First call hits the network and caches.
mk(time.Unix(0, 0)).Notify(context.Background(), &strings.Builder{})
// Second call within the TTL uses the cache (no network).
mk(time.Unix(int64((notifierTTL-time.Minute).Seconds()), 0)).Notify(context.Background(), &strings.Builder{})
if calls != 1 {
t.Errorf("checked %d times within TTL, want 1 (cached)", calls)
}
// After the TTL it refreshes.
mk(time.Unix(int64((notifierTTL+time.Hour).Seconds()), 0)).Notify(context.Background(), &strings.Builder{})
if calls != 2 {
t.Errorf("checked %d times after TTL, want 2 (refreshed)", calls)
}
}

func TestNotifyFailSilent(t *testing.T) {
n := Notifier{
Current: "v0.1.0", CacheDir: t.TempDir(),
Now: func() time.Time { return time.Unix(1000, 0) },
CheckFn: func(context.Context) (string, error) { return "", errors.New("no network") },
}
var w strings.Builder
if n.Notify(context.Background(), &w) || w.Len() != 0 {
t.Errorf("network failure must be silent, got %q", w.String())
}
}

func TestNotifyDevBuildSilent(t *testing.T) {
n := Notifier{
Current: "v0.1.0-11-gabc1234", CacheDir: t.TempDir(),
CheckFn: func(context.Context) (string, error) { t.Fatal("dev build must not check"); return "", nil },
}
if n.Notify(context.Background(), &strings.Builder{}) {
t.Error("dev build should never notify")
}
}

func TestNotifyDisabledByEnv(t *testing.T) {
t.Setenv(notifierDisableEnv, "1")
n := Notifier{
Current: "v0.1.0", CacheDir: t.TempDir(),
CheckFn: func(context.Context) (string, error) { t.Fatal("must not check when disabled"); return "", nil },
}
if n.Notify(context.Background(), &strings.Builder{}) {
t.Error("notifier disabled by env should do nothing")
}
}
Loading