diff --git a/Dockerfile b/Dockerfile index 71814c26b..bb8102961 100644 --- a/Dockerfile +++ b/Dockerfile @@ -75,6 +75,10 @@ RUN apk add --no-cache ca-certificates docker-cli && \ ARG TARGETOS TARGETARCH ENV DOCKER_MCP_IN_CONTAINER=1 ENV TERM=xterm-256color +# Disable the once-a-day GitHub release check inside the container image: +# images are tagged immutably and can't self-upgrade, so the hint would be +# misleading and waste a network round-trip on every `run`. +ENV DOCKER_AGENT_DISABLE_VERSION_CHECK=1 COPY --from=docker/mcp-gateway:v2 /docker-mcp /usr/local/lib/docker/cli-plugins/ COPY --from=builder-linux /binaries/docker-agent-$TARGETOS-$TARGETARCH /docker-agent USER docker-agent diff --git a/cmd/root/run.go b/cmd/root/run.go index 4832ee2d4..831817a0a 100644 --- a/cmd/root/run.go +++ b/cmd/root/run.go @@ -29,6 +29,7 @@ import ( "github.com/docker/docker-agent/pkg/tui" "github.com/docker/docker-agent/pkg/tui/styles" "github.com/docker/docker-agent/pkg/userconfig" + "github.com/docker/docker-agent/pkg/version/check" ) type runExecFlags struct { @@ -145,6 +146,13 @@ func (f *runExecFlags) runRunCommand(cmd *cobra.Command, args []string) (command }() } + // Kick off a best-effort, background check for a newer release. Results + // are cached on disk so the TUI status bar (and a future `version` call) + // can surface an upgrade hint without blocking on a network call. Only + // `run`/`exec` triggers this; other subcommands never reach out to + // GitHub. Disabled by setting DOCKER_AGENT_DISABLE_VERSION_CHECK=1. + check.RefreshAsync(ctx) + if f.sandbox { return runInSandbox(ctx, cmd, args, &f.runConfig, f.sandboxTemplate, f.sbx) } diff --git a/cmd/root/version.go b/cmd/root/version.go index 61c509f4b..9c8bdc562 100644 --- a/cmd/root/version.go +++ b/cmd/root/version.go @@ -7,6 +7,7 @@ import ( "github.com/docker/docker-agent/pkg/cli" "github.com/docker/docker-agent/pkg/telemetry" "github.com/docker/docker-agent/pkg/version" + "github.com/docker/docker-agent/pkg/version/check" ) func newVersionCmd() *cobra.Command { @@ -33,4 +34,12 @@ func runVersionCommand(cmd *cobra.Command, args []string) { } out.Printf("%s version %s\n", commandName, version.Version) out.Printf("Commit: %s\n", version.Commit) + + // Best-effort upgrade hint based on the cached result of the last + // `docker agent run`. We never reach out to GitHub from this subcommand; + // if the cache is empty (e.g. `run` has never been used) the hint is + // simply not shown. + if latest := check.LatestCached(version.Version); latest != "" { + out.Printf("\nA newer version is available: %s\nRelease notes: https://github.com/docker/docker-agent/releases/tag/%s\n", latest, latest) + } } diff --git a/docs/configuration/overview/index.md b/docs/configuration/overview/index.md index ff718448d..d58d1c6c0 100644 --- a/docs/configuration/overview/index.md +++ b/docs/configuration/overview/index.md @@ -161,6 +161,12 @@ API keys and secrets are read from environment variables — never stored in con | `DOCKER_AGENT_AUTO_INSTALL` | Set to `false` to disable automatic tool installation | | `DOCKER_AGENT_TOOLS_DIR` | Override the base directory for installed tools (default: `~/.cagent/tools/`) | +**Update Notifications:** + +| Variable | Description | +| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `DOCKER_AGENT_DISABLE_VERSION_CHECK` | Set to `1`/`true` to disable the once-a-day GitHub release check used to surface an upgrade hint in the TUI status bar and the `version` subcommand. Only `docker agent run` ever performs the check. | +
⚠️ Important
diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index 016195a6d..d8b51c61b 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -305,7 +305,7 @@ func New(ctx context.Context, spawner SessionSpawner, initialApp *app.App, initi m.chatPage = initialChatPage // Initialize status bar (pass m as help provider) - m.statusBar = statusbar.New(m, statusbar.WithTitle(m.appName+" "+m.appVersion)) + m.statusBar = statusbar.New(m, statusbar.WithTitle(buildStatusBarTitle(m.appName, m.appVersion))) // Add the initial session to the supervisor sv.AddSession(ctx, initialApp, initialApp.Session(), initialWorkingDir, cleanup) diff --git a/pkg/tui/upgrade_hint.go b/pkg/tui/upgrade_hint.go new file mode 100644 index 000000000..5f5ea6ee2 --- /dev/null +++ b/pkg/tui/upgrade_hint.go @@ -0,0 +1,20 @@ +package tui + +import ( + "github.com/docker/docker-agent/pkg/version/check" +) + +// buildStatusBarTitle returns the right-side string of the status bar: +// " ", optionally suffixed with "(update available: vX.Y.Z)" +// when a newer release tag has been observed in the local cache. +// +// Only cached results are consulted so the TUI never blocks on I/O at +// startup; the cache is refreshed in the background by `docker agent run` +// (see cmd/root/run.go). +func buildStatusBarTitle(appName, appVersion string) string { + base := appName + " " + appVersion + if latest := check.LatestCached(appVersion); latest != "" { + return base + " (update available: " + latest + ")" + } + return base +} diff --git a/pkg/tui/upgrade_hint_test.go b/pkg/tui/upgrade_hint_test.go new file mode 100644 index 000000000..370c200b9 --- /dev/null +++ b/pkg/tui/upgrade_hint_test.go @@ -0,0 +1,35 @@ +package tui + +import ( + "strings" + "testing" + + "gotest.tools/v3/assert" + + "github.com/docker/docker-agent/pkg/version/check" +) + +func TestBuildStatusBarTitle(t *testing.T) { + t.Run("no upgrade", func(t *testing.T) { + check.SeedCacheForTest(t, "v1.0.0") + assert.Equal(t, "docker agent v1.0.0", buildStatusBarTitle("docker agent", "v1.0.0")) + }) + + t.Run("upgrade available", func(t *testing.T) { + check.SeedCacheForTest(t, "v1.2.3") + got := buildStatusBarTitle("docker agent", "v1.0.0") + assert.Assert(t, strings.Contains(got, "docker agent v1.0.0")) + assert.Assert(t, strings.Contains(got, "update available: v1.2.3")) + }) + + t.Run("dev build is silent", func(t *testing.T) { + check.SeedCacheForTest(t, "v1.2.3") + assert.Equal(t, "docker agent dev", buildStatusBarTitle("docker agent", "dev")) + }) + + t.Run("disabled is silent", func(t *testing.T) { + check.SeedCacheForTest(t, "v1.2.3") + t.Setenv(check.DisableEnvVar, "1") + assert.Equal(t, "docker agent v1.0.0", buildStatusBarTitle("docker agent", "v1.0.0")) + }) +} diff --git a/pkg/version/check/check.go b/pkg/version/check/check.go new file mode 100644 index 000000000..148f88912 --- /dev/null +++ b/pkg/version/check/check.go @@ -0,0 +1,255 @@ +package check + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/docker/docker-agent/pkg/paths" +) + +// DisableEnvVar is the environment variable that disables the version check +// when set to a truthy value (1, true, yes, on, …). +const DisableEnvVar = "DOCKER_AGENT_DISABLE_VERSION_CHECK" + +const ( + cacheTTL = 24 * time.Hour + fetchTimeout = 5 * time.Second + releasesURL = "https://api.github.com/repos/docker/docker-agent/releases/latest" + cacheFileName = "version-check.json" +) + +// LatestCached returns the latest known release tag if it is strictly newer +// than current, or "" otherwise. +// +// The function never reaches out to the network — it only consults the local +// cache populated by [RefreshAsync]. It also returns "" when the check is +// disabled or when current is "dev" (development build). +func LatestCached(current string) string { + if disabled() || current == "" || current == "dev" { + return "" + } + entry, _ := readCache() + if !IsNewer(entry.LatestVersion, current) { + return "" + } + return entry.LatestVersion +} + +// RefreshAsync triggers a background refresh of the on-disk cache when it is +// stale, returning immediately. Errors are logged at debug level and +// otherwise ignored. +// +// The returned channel is closed once the goroutine completes. Tests use it +// to deterministically wait for completion; production callers can ignore it. +func RefreshAsync(ctx context.Context) <-chan struct{} { + done := make(chan struct{}) + + if disabled() { + close(done) + return done + } + if entry, _ := readCache(); entry.fresh(time.Now()) { + close(done) + return done + } + + go func() { + defer close(done) + + fetchCtx, cancel := context.WithTimeout(ctx, fetchTimeout) + defer cancel() + + tag, err := fetchLatestTag(fetchCtx, releasesURL) + if err != nil { + slog.Debug("Version check fetch failed", "error", err) + return + } + if err := writeCache(tag); err != nil { + slog.Debug("Version check cache write failed", "error", err) + } + }() + + return done +} + +// disabled reports whether the version check has been turned off via +// [DisableEnvVar]. +func disabled() bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv(DisableEnvVar))) { + case "1", "true", "yes", "on": + return true + } + return false +} + +// fetchLatestTag returns the `tag_name` field of the latest stable release. +// The endpoint is parameterised to keep the function unit-testable. +func fetchLatestTag(ctx context.Context, url string) (string, error) { + // Use a custom client with timeout and redirect limit to prevent + // SSRF/redirect loops. The context timeout in RefreshAsync is a backstop; + // this client-level timeout ensures the request doesn't hang. + client := &http.Client{ + Timeout: fetchTimeout, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 3 { + return errors.New("stopped after 3 redirects") + } + return nil + }, + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) + if err != nil { + return "", err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return "", fmt.Errorf("unexpected status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + var payload struct { + TagName string `json:"tag_name"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&payload); err != nil { + return "", fmt.Errorf("decode release payload: %w", err) + } + if payload.TagName == "" { + return "", errors.New("release payload missing tag_name") + } + return payload.TagName, nil +} + +// cacheEntry is the JSON payload persisted to disk. +type cacheEntry struct { + LatestVersion string `json:"latest_version"` + CheckedAt int64 `json:"checked_at"` +} + +// fresh reports whether the entry is still within [cacheTTL]. +func (e cacheEntry) fresh(now time.Time) bool { + return e.CheckedAt > 0 && now.Sub(time.Unix(e.CheckedAt, 0)) < cacheTTL +} + +// cachePath returns the absolute path of the cache file. +func cachePath() string { + return filepath.Join(paths.GetCacheDir(), cacheFileName) +} + +// readCache returns the cached entry, or a zero entry if the file is missing +// or unreadable. The file is small enough that we do not worry about partial +// reads: if Unmarshal fails, callers simply see "no cache" for one call. +func readCache() (cacheEntry, error) { + data, err := os.ReadFile(cachePath()) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return cacheEntry{}, nil + } + return cacheEntry{}, err + } + var entry cacheEntry + if err := json.Unmarshal(data, &entry); err != nil { + return cacheEntry{}, err + } + return entry, nil +} + +// writeCache persists the given release tag along with the current timestamp. +// It uses atomic write-and-rename to prevent corruption from concurrent writes. +func writeCache(latest string) error { + if err := os.MkdirAll(paths.GetCacheDir(), 0o755); err != nil { + return fmt.Errorf("create cache dir: %w", err) + } + data, err := json.Marshal(cacheEntry{LatestVersion: latest, CheckedAt: time.Now().Unix()}) + if err != nil { + return err + } + + // Write to a temporary file first, then atomically rename it to prevent + // corruption if multiple processes write concurrently. + tmpPath := cachePath() + ".tmp" + if err := os.WriteFile(tmpPath, data, 0o600); err != nil { + return err + } + return os.Rename(tmpPath, cachePath()) +} + +// IsNewer reports whether the semver-like tag latest is strictly greater than +// current. The comparison is intentionally tolerant: +// +// - A leading "v" is stripped from both sides. +// - Build metadata ("+meta") is ignored. +// - A pre-release ("-rc.1") sorts strictly older than the same release. +// - Components that fail to parse as integers are treated as 0, so +// malformed inputs simply do not trigger a notification. +// - Empty strings or "dev" never compare as newer. +func IsNewer(latest, current string) bool { + if latest == "" || current == "" || current == "dev" || latest == "dev" { + return false + } + + la, lpre := splitVersion(latest) + cu, cpre := splitVersion(current) + + if cmp := compareNumeric(la, cu); cmp != 0 { + return cmp > 0 + } + // Equal numeric parts: a release outranks a pre-release of the same + // version (1.2.3 > 1.2.3-rc.1). Otherwise treat as equal. + return lpre == "" && cpre != "" +} + +// splitVersion strips a leading "v", drops "+build" metadata, and splits off +// any "-prerelease" suffix. For example "v1.2.3-rc.1+meta" → ("1.2.3", "rc.1"). +func splitVersion(v string) (numeric, pre string) { + v = strings.TrimPrefix(v, "v") + if i := strings.Index(v, "+"); i >= 0 { + v = v[:i] + } + if num, p, ok := strings.Cut(v, "-"); ok { + return num, p + } + return v, "" +} + +// compareNumeric compares dotted numeric strings ("1.2.3") component by +// component, returning -1, 0 or +1. Missing trailing components are treated +// as zero so "1.2" == "1.2.0". +func compareNumeric(a, b string) int { + ap := strings.Split(a, ".") + bp := strings.Split(b, ".") + for i := range max(len(ap), len(bp)) { + var ai, bi int + if i < len(ap) { + ai, _ = strconv.Atoi(ap[i]) + } + if i < len(bp) { + bi, _ = strconv.Atoi(bp[i]) + } + if ai != bi { + if ai > bi { + return 1 + } + return -1 + } + } + return 0 +} diff --git a/pkg/version/check/check_test.go b/pkg/version/check/check_test.go new file mode 100644 index 000000000..0e053babe --- /dev/null +++ b/pkg/version/check/check_test.go @@ -0,0 +1,229 @@ +package check + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "gotest.tools/v3/assert" +) + +func TestIsNewer(t *testing.T) { + tests := []struct { + name string + latest string + current string + want bool + }{ + {"patch newer", "v1.2.4", "v1.2.3", true}, + {"minor newer", "v1.3.0", "v1.2.9", true}, + {"major newer", "v2.0.0", "v1.99.0", true}, + {"equal", "v1.2.3", "v1.2.3", false}, + {"older", "v1.2.2", "v1.2.3", false}, + {"prefix v optional", "1.2.4", "v1.2.3", true}, + {"missing components", "v1.3", "v1.2.9", true}, + {"release beats prerelease", "v1.2.3", "v1.2.3-rc.1", true}, + {"prerelease loses to release", "v1.2.3-rc.1", "v1.2.3", false}, + {"build metadata ignored", "v1.2.4+abcdef", "v1.2.3", true}, + {"both prerelease equal numeric", "v1.2.3-rc.2", "v1.2.3-rc.1", false}, + {"empty latest", "", "v1.2.3", false}, + {"empty current", "v1.2.3", "", false}, + {"dev current never upgrades", "v1.2.3", "dev", false}, + {"dev latest never upgrades", "dev", "v1.2.3", false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, IsNewer(tc.latest, tc.current)) + }) + } +} + +func TestFetchLatestTag(t *testing.T) { + tests := []struct { + name string + handler http.HandlerFunc + wantTag string + wantErrSub string + }{ + { + name: "success", + handler: func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "application/vnd.github+json", r.Header.Get("Accept")) + _ = json.NewEncoder(w).Encode(map[string]any{"tag_name": "v9.9.9"}) + }, + wantTag: "v9.9.9", + }, + { + name: "http error", + handler: func(w http.ResponseWriter, _ *http.Request) { http.Error(w, "rate limited", http.StatusForbidden) }, + wantErrSub: "unexpected status 403", + }, + { + name: "malformed payload", + handler: func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("{not json")) }, + wantErrSub: "decode release payload", + }, + { + name: "missing tag", + handler: func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"foo":"bar"}`)) }, + wantErrSub: "missing tag_name", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + srv := httptest.NewServer(tc.handler) + t.Cleanup(srv.Close) + + tag, err := fetchLatestTag(t.Context(), srv.URL) + if tc.wantErrSub != "" { + assert.ErrorContains(t, err, tc.wantErrSub) + return + } + assert.NilError(t, err) + assert.Equal(t, tc.wantTag, tag) + }) + } +} + +func TestCacheRoundTrip(t *testing.T) { + SeedCacheForTest(t, "") + + assert.NilError(t, writeCache("v1.2.3")) + + // Cache file ended up where we expect. + _, err := os.Stat(cachePath()) + assert.NilError(t, err) + + got, err := readCache() + assert.NilError(t, err) + assert.Equal(t, "v1.2.3", got.LatestVersion) + assert.Assert(t, got.fresh(time.Now())) +} + +func TestReadCache_MissingReturnsZero(t *testing.T) { + SeedCacheForTest(t, "") + + got, err := readCache() + assert.NilError(t, err) + assert.Equal(t, cacheEntry{}, got) +} + +func TestReadCache_CorruptReturnsZero(t *testing.T) { + SeedCacheForTest(t, "") + assert.NilError(t, os.WriteFile(filepath.Join(filepath.Dir(cachePath()), cacheFileName), []byte("not-json"), 0o600)) + + got, _ := readCache() + assert.Equal(t, cacheEntry{}, got) +} + +func TestCacheFreshness(t *testing.T) { + now := time.Now() + assert.Assert(t, cacheEntry{CheckedAt: now.Add(-1 * time.Hour).Unix()}.fresh(now), "1h-old entry should be fresh") + assert.Assert(t, !cacheEntry{CheckedAt: now.Add(-48 * time.Hour).Unix()}.fresh(now), "48h-old entry should be stale") + assert.Assert(t, !cacheEntry{}.fresh(now), "zero entry should be stale") +} + +func TestLatestCached(t *testing.T) { + t.Run("empty cache returns empty", func(t *testing.T) { + SeedCacheForTest(t, "") + assert.Equal(t, "", LatestCached("v1.0.0")) + }) + + t.Run("cache newer than current returns latest", func(t *testing.T) { + SeedCacheForTest(t, "v9.9.9") + assert.Equal(t, "v9.9.9", LatestCached("v1.0.0")) + }) + + t.Run("cache older than current returns empty", func(t *testing.T) { + SeedCacheForTest(t, "v1.0.0") + assert.Equal(t, "", LatestCached("v9.9.9")) + }) + + t.Run("dev current never reports upgrade", func(t *testing.T) { + SeedCacheForTest(t, "v9.9.9") + assert.Equal(t, "", LatestCached("dev")) + }) + + t.Run("disabled returns empty even when cache has upgrade", func(t *testing.T) { + SeedCacheForTest(t, "v9.9.9") + t.Setenv(DisableEnvVar, "1") + assert.Equal(t, "", LatestCached("v1.0.0")) + }) +} + +func TestRefreshAsync_Disabled(t *testing.T) { + SeedCacheForTest(t, "") + t.Setenv(DisableEnvVar, "true") + + <-RefreshAsync(t.Context()) + + got, err := readCache() + assert.NilError(t, err) + assert.Equal(t, "", got.LatestVersion) +} + +func TestRefreshAsync_FreshCacheSkipsRefresh(t *testing.T) { + SeedCacheForTest(t, "v1.2.3") + before, err := readCache() + assert.NilError(t, err) + + <-RefreshAsync(t.Context()) + + after, err := readCache() + assert.NilError(t, err) + assert.Equal(t, before, after, "fresh cache must not be touched") +} + +func TestDisabled(t *testing.T) { + for _, val := range []string{"1", "true", "True", "YES", "on"} { + t.Run("truthy/"+val, func(t *testing.T) { + t.Setenv(DisableEnvVar, val) + assert.Assert(t, disabled()) + }) + } + for _, val := range []string{"", "0", "false", "no", "off", "anything-else"} { + t.Run("falsy/"+val, func(t *testing.T) { + t.Setenv(DisableEnvVar, val) + assert.Assert(t, !disabled()) + }) + } +} + +func TestFetchLatestTag_RedirectLimit(t *testing.T) { + redirectCount := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + redirectCount++ + if redirectCount <= 5 { + http.Redirect(w, r, "/redirect", http.StatusFound) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"tag_name": "v1.0.0"}) + })) + t.Cleanup(srv.Close) + + _, err := fetchLatestTag(t.Context(), srv.URL) + assert.ErrorContains(t, err, "stopped after 3 redirects") +} + +func TestRefreshAsync_ConcurrentCalls(t *testing.T) { + SeedCacheForTest(t, "") + + const numCalls = 10 + var wg sync.WaitGroup + wg.Add(numCalls) + + for range numCalls { + go func() { + defer wg.Done() + <-RefreshAsync(t.Context()) + }() + } + + wg.Wait() + // If we get here without panicking or racing, the test passes +} diff --git a/pkg/version/check/testing.go b/pkg/version/check/testing.go new file mode 100644 index 000000000..7fe0f9b97 --- /dev/null +++ b/pkg/version/check/testing.go @@ -0,0 +1,29 @@ +package check + +import ( + "testing" + + "github.com/docker/docker-agent/pkg/paths" +) + +// SeedCacheForTest points the cache directory at a per-test temp dir and +// pre-populates it with the given release tag (or leaves it empty if latest +// is ""). It is intended for unit tests in other packages that want to +// observe [LatestCached] returning a deterministic value without hitting the +// network. +// +// The cache directory override is restored on test cleanup. +func SeedCacheForTest(tb testing.TB, latest string) { + tb.Helper() + + prev := paths.GetCacheDir() + paths.SetCacheDir(tb.TempDir()) + tb.Cleanup(func() { paths.SetCacheDir(prev) }) + + if latest == "" { + return + } + if err := writeCache(latest); err != nil { + tb.Fatalf("seed version cache: %v", err) + } +}