Skip to content
Draft
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
39 changes: 38 additions & 1 deletion internal/tui/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"github.com/tailscale/aperture-cli/internal/clients"
"github.com/tailscale/aperture-cli/internal/config"
"github.com/tailscale/aperture-cli/internal/menu"
"github.com/tailscale/aperture-cli/internal/updatecheck"
)

type step int
Expand Down Expand Up @@ -89,10 +90,17 @@ type model struct {
bridgeLogCh chan string
bridgeLogs []string
bridgeCancel context.CancelFunc

updateVersion string
updateURL string
}

func (m *model) Init() tea.Cmd {
return m.activateEndpointCmd(m.g.ActiveEndpoint())
activate := m.activateEndpointCmd(m.g.ActiveEndpoint())
if !updatecheck.ValidVersion(m.buildVersion) {
return activate
}
return tea.Batch(activate, checkForUpdateCmd())
}

// preflightResult is emitted when the /api/providers check completes.
Expand All @@ -113,6 +121,20 @@ type bridgeLogMsg string
type bridgeLogDoneMsg struct{}
type quitMsg struct{ Err error }

type updateCheckResult struct {
release updatecheck.Release
err error
}

func checkForUpdateCmd() tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
release, err := updatecheck.Latest(ctx, &http.Client{Timeout: 2 * time.Second})
return updateCheckResult{release: release, err: err}
}
}

func runPreflight(host string) tea.Cmd {
return func() tea.Msg {
provs, err := fetchProviders(host)
Expand Down Expand Up @@ -286,6 +308,13 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.bridgeLogCh = nil
return m, nil

case updateCheckResult:
if msg.err == nil && updatecheck.IsNewer(m.buildVersion, msg.release.Version) {
m.updateVersion = msg.release.Version
m.updateURL = msg.release.URL
}
return m, nil

case quitMsg:
if msg.Err != nil {
m.errMsg = "Error shutting down bridges: " + msg.Err.Error()
Expand Down Expand Up @@ -649,6 +678,14 @@ func (m *model) viewMenu() string {
sb.WriteString("\n")
sb.WriteString(dimStyle.Render("Aperture " + m.buildVersion))
sb.WriteString("\n")
if m.updateVersion != "" {
notice := "Update available: " + m.updateVersion
if m.updateURL != "" {
notice += " " + m.updateURL
}
sb.WriteString(greenStyle.Render(notice))
sb.WriteString("\n")
}
}
return sb.String()
}
Expand Down
42 changes: 42 additions & 0 deletions internal/tui/tui_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/tailscale/aperture-cli/internal/clients"
"github.com/tailscale/aperture-cli/internal/config"
"github.com/tailscale/aperture-cli/internal/menu"
"github.com/tailscale/aperture-cli/internal/updatecheck"
)

// fakeClient is a minimal clients.Client for TUI tests.
Expand Down Expand Up @@ -125,6 +126,47 @@ func TestRootMenu_NoQuickSelectWhenReplayNil(t *testing.T) {
}
}

func TestUpdateNoticeShownOnRootMenu(t *testing.T) {
withFakeClients(t, []clients.Client{&fakeClient{name: "A", installed: true}})
m := &model{g: &config.Global{}, buildVersion: "v0.0.7", step: stepMenu}
m.resetStack(m.rootMenu())

m.Update(updateCheckResult{release: updatecheck.Release{
Version: "v0.0.8",
URL: "https://github.com/tailscale/aperture-cli/releases/tag/v0.0.8",
}})

view := m.View()
if !strings.Contains(view, "Update available: v0.0.8") {
t.Fatalf("view missing update notice:\n%s", view)
}
if !strings.Contains(view, "releases/tag/v0.0.8") {
t.Fatalf("view missing release URL:\n%s", view)
}
}

func TestUpdateNoticeHiddenForCurrentOrDevelopmentBuild(t *testing.T) {
withFakeClients(t, []clients.Client{&fakeClient{name: "A", installed: true}})
tests := []struct {
name string
buildVersion string
}{
{name: "current", buildVersion: "v0.0.8"},
{name: "development", buildVersion: "B42"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := &model{g: &config.Global{}, buildVersion: tt.buildVersion, step: stepMenu}
m.resetStack(m.rootMenu())
m.Update(updateCheckResult{release: updatecheck.Release{Version: "v0.0.8"}})
if view := m.View(); strings.Contains(view, "Update available") {
t.Fatalf("unexpected update notice:\n%s", view)
}
})
}
}

func TestMenuEngine_PushPop(t *testing.T) {
sub := &menu.Menu{
Title: "Sub",
Expand Down
117 changes: 117 additions & 0 deletions internal/updatecheck/updatecheck.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// Package updatecheck reports when a newer stable Aperture CLI release is available.
package updatecheck

import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"strconv"
"strings"
)

const (
latestReleaseURL = "https://api.github.com/repos/tailscale/aperture-cli/releases/latest"
maxResponseBytes = 1 << 20
)

var stableVersionPattern = regexp.MustCompile(`^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$`)

// Release describes the latest stable GitHub release.
type Release struct {
Version string
URL string
}

// Latest fetches the latest stable Aperture CLI release from GitHub.
func Latest(ctx context.Context, client *http.Client) (Release, error) {
if client == nil {
client = http.DefaultClient
}
return latestFromURL(ctx, client, latestReleaseURL)
}

func latestFromURL(ctx context.Context, client *http.Client, endpoint string) (Release, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return Release{}, err
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
req.Header.Set("User-Agent", "aperture-cli")

resp, err := client.Do(req)
if err != nil {
return Release{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return Release{}, fmt.Errorf("latest release request returned status %d", resp.StatusCode)
}

body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1))
if err != nil {
return Release{}, err
}
if len(body) > maxResponseBytes {
return Release{}, fmt.Errorf("latest release response exceeds %d bytes", maxResponseBytes)
}

var payload struct {
TagName string `json:"tag_name"`
HTMLURL string `json:"html_url"`
}
if err := json.Unmarshal(body, &payload); err != nil {
return Release{}, fmt.Errorf("decode latest release response: %w", err)
}

release := Release{
Version: strings.TrimSpace(payload.TagName),
URL: strings.TrimSpace(payload.HTMLURL),
}
if !ValidVersion(release.Version) {
return Release{}, fmt.Errorf("latest release has invalid version %q", release.Version)
}
return release, nil
}

// ValidVersion reports whether version is a stable three component semantic version.
func ValidVersion(version string) bool {
return stableVersionPattern.MatchString(version)
}

// IsNewer reports whether latest is a newer stable version than current.
func IsNewer(current, latest string) bool {
currentParts, ok := versionParts(current)
if !ok {
return false
}
latestParts, ok := versionParts(latest)
if !ok {
return false
}
for i := range currentParts {
if latestParts[i] != currentParts[i] {
return latestParts[i] > currentParts[i]
}
}
return false
}

func versionParts(version string) ([3]uint64, bool) {
match := stableVersionPattern.FindStringSubmatch(version)
if match == nil {
return [3]uint64{}, false
}
var parts [3]uint64
for i := range parts {
part, err := strconv.ParseUint(match[i+1], 10, 64)
if err != nil {
return [3]uint64{}, false
}
parts[i] = part
}
return parts, true
}
88 changes: 88 additions & 0 deletions internal/updatecheck/updatecheck_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package updatecheck

import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)

func TestIsNewer(t *testing.T) {
tests := []struct {
name string
current string
latest string
want bool
}{
{name: "patch", current: "v1.2.3", latest: "v1.2.4", want: true},
{name: "minor", current: "v1.2.9", latest: "v1.3.0", want: true},
{name: "major", current: "v1.9.9", latest: "v2.0.0", want: true},
{name: "same", current: "v1.2.3", latest: "v1.2.3", want: false},
{name: "older", current: "v1.2.3", latest: "v1.2.2", want: false},
{name: "development build", current: "B42", latest: "v1.2.3", want: false},
{name: "prerelease", current: "v1.2.3-beta.1", latest: "v1.2.3", want: false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsNewer(tt.current, tt.latest); got != tt.want {
t.Fatalf("IsNewer(%q, %q) = %v, want %v", tt.current, tt.latest, got, tt.want)
}
})
}
}

func TestLatestFromURL(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Accept"); got != "application/vnd.github+json" {
t.Errorf("Accept = %q", got)
}
if got := r.Header.Get("X-GitHub-Api-Version"); got != "2022-11-28" {
t.Errorf("X-GitHub-Api-Version = %q", got)
}
if got := r.Header.Get("User-Agent"); got != "aperture-cli" {
t.Errorf("User-Agent = %q", got)
}
_, _ = w.Write([]byte(`{"tag_name":"v0.0.8","html_url":"https://github.com/tailscale/aperture-cli/releases/tag/v0.0.8"}`))
}))
t.Cleanup(server.Close)

release, err := latestFromURL(context.Background(), server.Client(), server.URL)
if err != nil {
t.Fatal(err)
}
if release.Version != "v0.0.8" {
t.Errorf("Version = %q", release.Version)
}
if release.URL != "https://github.com/tailscale/aperture-cli/releases/tag/v0.0.8" {
t.Errorf("URL = %q", release.URL)
}
}

func TestLatestFromURLRejectsInvalidResponses(t *testing.T) {
tests := []struct {
name string
status int
body string
}{
{name: "status", status: http.StatusServiceUnavailable, body: `{}`},
{name: "malformed JSON", status: http.StatusOK, body: `{`},
{name: "invalid version", status: http.StatusOK, body: `{"tag_name":"latest"}`},
{name: "oversized", status: http.StatusOK, body: strings.Repeat("x", maxResponseBytes+1)},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(tt.status)
_, _ = w.Write([]byte(tt.body))
}))
t.Cleanup(server.Close)

if _, err := latestFromURL(context.Background(), server.Client(), server.URL); err == nil {
t.Fatal("expected an error")
}
})
}
}