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
24 changes: 12 additions & 12 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ builds:
- CGO_ENABLED=0
flags: [-trimpath]
ldflags:
- -s -w -X github.com/zeropsio/zcli/src/version.version={{ .Tag }} -X github.com/zeropsio/zcli/src/version.channel=manual
- -s -w -X github.com/zeropsio/zcli/src/upgrade.version={{ .Tag }} -X github.com/zeropsio/zcli/src/upgrade.channel=manual

- id: raw-linux-386
main: ./cmd/zcli/main.go
Expand All @@ -31,7 +31,7 @@ builds:
- CGO_ENABLED=0
flags: [-trimpath]
ldflags:
- -s -w -X github.com/zeropsio/zcli/src/version.version={{ .Tag }} -X github.com/zeropsio/zcli/src/version.channel=manual
- -s -w -X github.com/zeropsio/zcli/src/upgrade.version={{ .Tag }} -X github.com/zeropsio/zcli/src/upgrade.channel=manual

- id: raw-darwin-amd64
main: ./cmd/zcli/main.go
Expand All @@ -42,7 +42,7 @@ builds:
- CGO_ENABLED=0
flags: [-trimpath]
ldflags:
- -s -w -X github.com/zeropsio/zcli/src/version.version={{ .Tag }} -X github.com/zeropsio/zcli/src/version.channel=manual
- -s -w -X github.com/zeropsio/zcli/src/upgrade.version={{ .Tag }} -X github.com/zeropsio/zcli/src/upgrade.channel=manual

- id: raw-darwin-arm64
main: ./cmd/zcli/main.go
Expand All @@ -53,7 +53,7 @@ builds:
- CGO_ENABLED=0
flags: [-trimpath]
ldflags:
- -s -w -X github.com/zeropsio/zcli/src/version.version={{ .Tag }} -X github.com/zeropsio/zcli/src/version.channel=manual
- -s -w -X github.com/zeropsio/zcli/src/upgrade.version={{ .Tag }} -X github.com/zeropsio/zcli/src/upgrade.channel=manual

- id: raw-windows-amd64
main: ./cmd/zcli/main.go
Expand All @@ -64,7 +64,7 @@ builds:
- CGO_ENABLED=0
flags: [-trimpath]
ldflags:
- -s -w -X github.com/zeropsio/zcli/src/version.version={{ .Tag }} -X github.com/zeropsio/zcli/src/version.channel=manual
- -s -w -X github.com/zeropsio/zcli/src/upgrade.version={{ .Tag }} -X github.com/zeropsio/zcli/src/upgrade.channel=manual

# --- npm: published as @zerops/zcli, refuses self-update (channel=npm) ---
- id: npm-linux-amd64
Expand All @@ -76,7 +76,7 @@ builds:
- CGO_ENABLED=0
flags: [-trimpath]
ldflags:
- -s -w -X github.com/zeropsio/zcli/src/version.version={{ .Tag }} -X github.com/zeropsio/zcli/src/version.channel=npm
- -s -w -X github.com/zeropsio/zcli/src/upgrade.version={{ .Tag }} -X github.com/zeropsio/zcli/src/upgrade.channel=npm

- id: npm-linux-386
main: ./cmd/zcli/main.go
Expand All @@ -87,7 +87,7 @@ builds:
- CGO_ENABLED=0
flags: [-trimpath]
ldflags:
- -s -w -X github.com/zeropsio/zcli/src/version.version={{ .Tag }} -X github.com/zeropsio/zcli/src/version.channel=npm
- -s -w -X github.com/zeropsio/zcli/src/upgrade.version={{ .Tag }} -X github.com/zeropsio/zcli/src/upgrade.channel=npm

- id: npm-darwin-amd64
main: ./cmd/zcli/main.go
Expand All @@ -98,7 +98,7 @@ builds:
- CGO_ENABLED=0
flags: [-trimpath]
ldflags:
- -s -w -X github.com/zeropsio/zcli/src/version.version={{ .Tag }} -X github.com/zeropsio/zcli/src/version.channel=npm
- -s -w -X github.com/zeropsio/zcli/src/upgrade.version={{ .Tag }} -X github.com/zeropsio/zcli/src/upgrade.channel=npm

- id: npm-darwin-arm64
main: ./cmd/zcli/main.go
Expand All @@ -109,7 +109,7 @@ builds:
- CGO_ENABLED=0
flags: [-trimpath]
ldflags:
- -s -w -X github.com/zeropsio/zcli/src/version.version={{ .Tag }} -X github.com/zeropsio/zcli/src/version.channel=npm
- -s -w -X github.com/zeropsio/zcli/src/upgrade.version={{ .Tag }} -X github.com/zeropsio/zcli/src/upgrade.channel=npm

- id: npm-windows-amd64
main: ./cmd/zcli/main.go
Expand All @@ -120,7 +120,7 @@ builds:
- CGO_ENABLED=0
flags: [-trimpath]
ldflags:
- -s -w -X github.com/zeropsio/zcli/src/version.version={{ .Tag }} -X github.com/zeropsio/zcli/src/version.channel=npm
- -s -w -X github.com/zeropsio/zcli/src/upgrade.version={{ .Tag }} -X github.com/zeropsio/zcli/src/upgrade.channel=npm

# --- deb: dpkg-installed to /usr/local/bin/zcli, refuses self-update.
# binary is named "zcli" (not arch-suffixed) so the package installs a plain
Expand All @@ -135,7 +135,7 @@ builds:
- CGO_ENABLED=0
flags: [-trimpath]
ldflags:
- -s -w -X github.com/zeropsio/zcli/src/version.version={{ .Tag }} -X github.com/zeropsio/zcli/src/version.channel=deb
- -s -w -X github.com/zeropsio/zcli/src/upgrade.version={{ .Tag }} -X github.com/zeropsio/zcli/src/upgrade.channel=deb

- id: deb-386
main: ./cmd/zcli/main.go
Expand All @@ -146,7 +146,7 @@ builds:
- CGO_ENABLED=0
flags: [-trimpath]
ldflags:
- -s -w -X github.com/zeropsio/zcli/src/version.version={{ .Tag }} -X github.com/zeropsio/zcli/src/version.channel=deb
- -s -w -X github.com/zeropsio/zcli/src/upgrade.version={{ .Tag }} -X github.com/zeropsio/zcli/src/upgrade.channel=deb

archives:
- id: raw
Expand Down
16 changes: 13 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,23 @@ DEV_VERSION := $(shell git rev-parse --abbrev-ref HEAD):$(shell git describe --t
# -gcflags disables inlining and optimizations so the binary is dlv-friendly.
DEV_BUILD := go build \
-gcflags='all=-l -N' \
-ldflags='-X "github.com/zeropsio/zcli/src/version.version=$(DEV_VERSION)"'
-ldflags='-X "github.com/zeropsio/zcli/src/upgrade.version=$(DEV_VERSION)"'

# Production build flags mirror what .goreleaser.yaml uses for release builds:
# optimized, stripped, version from `git describe`, paths trimmed.
PROD_VERSION := $(shell git describe --tags 2>/dev/null)
# git describe stamps commits past a tag as `vX.Y.Z-N-gHASH`, which semver
# parses as a *pre-release* (ranking below vX.Y.Z) and makes `zcli upgrade
# --check` warn that the released tag is newer than a build ahead of it.
# Rewriting `-N-gHASH` to `+N.gHASH` moves it into build metadata, which
# semver.Compare ignores - so a local build past v1.0.67 ties with the
# released v1.0.67 instead of comparing older.
PROD_VERSION := $(shell git describe --tags 2>/dev/null | sed -E 's/^(v[0-9]+\.[0-9]+\.[0-9]+)-([0-9]+)-(g[0-9a-f]+)$$/\1+\2.\3/')
# Override on the command line for package-manager builds, e.g.
# `make install CHANNEL=brew`. Default matches the `manual` artifact set
# in .goreleaser.yaml (what install.sh produces).
CHANNEL ?= manual
PROD_BUILD := go build -trimpath \
-ldflags='-s -w -X github.com/zeropsio/zcli/src/version.version=$(PROD_VERSION)'
-ldflags='-s -w -X github.com/zeropsio/zcli/src/upgrade.version=$(PROD_VERSION) -X github.com/zeropsio/zcli/src/upgrade.channel=$(CHANNEL)'

# Self-documenting help. Targets are listed in the order they appear here;
# their description is the text after the '##' on the recipe line.
Expand Down
34 changes: 34 additions & 0 deletions src/cmd/integration_harness_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"

"github.com/stretchr/testify/require"
"github.com/zeropsio/zcli/src/cliStorage"
Expand Down Expand Up @@ -63,6 +65,10 @@ func newFixture(t *testing.T) *fixture {
// Keep the background version check off the real network. Tests that
// exercise the version API re-point this at a registered handler.
t.Setenv(constants.VersionApiUrlEnvVar, server.URL+"/__version_check__")
// Route release-asset lookups (used by tag verification on --version)
// at the test server. Tests opt in to specific tags via stubReleaseTag;
// unregistered tags hit the default 404 handler.
t.Setenv(constants.ReleasesURLEnvVar, server.URL+"/__releases__/%s/%s")

return &fixture{
t: t,
Expand Down Expand Up @@ -101,6 +107,34 @@ func (f *fixture) SeedScopedLogin(token, projectID string) {
require.NoError(f.t, os.WriteFile(f.DataPath, b, 0o600), "write seed data")
}

// stubReleaseTag tells the fixture's release-asset endpoint to return 200
// for any path under the given tag, so upgrade.PlanUpgrade's tag-existence
// HEAD check succeeds. Unregistered tags fall through to the default mux
// 404, which is what `release does not exist` lookups expect.
func (f *fixture) stubReleaseTag(tag string) {
f.Mux.HandleFunc(fmt.Sprintf("/__releases__/%s/", tag), func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
}

// stubLatestCache seeds the on-disk version cache so
// upgrade.Upgrader.CachedLatest() returns the given tag without contacting
// the API. The cache file layout (cacheEntry / apiResponse) lives in the
// upgrade package and isn't exported; reproducing the JSON shape here keeps
// integration tests independent of that internal type. If that shape
// changes, this helper needs to follow.
func (f *fixture) stubLatestCache(tag string) {
f.t.Helper()
cachePath := filepath.Join(filepath.Dir(f.DataPath), constants.VersionCacheFileName)
entry := map[string]any{
"fetched_at": time.Now().Format(time.RFC3339Nano),
"response": map[string]any{"tag_name": tag},
}
b, err := json.Marshal(entry)
require.NoError(f.t, err, "marshal version cache")
require.NoError(f.t, os.WriteFile(cachePath, b, 0o644), "write version cache")
}

// HandleJSON registers an exact-path handler returning the given status and
// JSON-encoded body.
func (f *fixture) HandleJSON(path string, status int, body any) {
Expand Down
143 changes: 130 additions & 13 deletions src/cmd/upgrade.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,77 @@ package cmd

import (
"context"
"errors"
"fmt"
"time"

"github.com/zeropsio/zcli/src/cmdBuilder"
"github.com/zeropsio/zcli/src/errorsx"
"github.com/zeropsio/zcli/src/upgrade"
"github.com/zeropsio/zcli/src/uxBlock"
"github.com/zeropsio/zcli/src/uxBlock/models/prompt"
"github.com/zeropsio/zcli/src/uxBlock/models/selector"
"github.com/zeropsio/zcli/src/uxBlock/models/table"
"github.com/zeropsio/zcli/src/uxBlock/styles"
"github.com/zeropsio/zcli/src/uxHelpers"
getVersion "github.com/zeropsio/zcli/src/version"
)

func upgradeCmd() *cmdBuilder.Cmd {
return cmdBuilder.NewCmd().
Use("upgrade").
Short("Upgrade zcli to the latest release.").
HelpFlag("Help for the upgrade command.").
BoolFlag("check", false, "Print current and latest version, then exit. 0 = up to date, 1 = behind, 2 = error.").
BoolFlag("check", false, "Print current and latest version, then exit. 0 = up to date, 1 = behind, 2 = error, 3 = target requires install.sh.").
BoolFlag("yes", false, "Skip the confirmation prompt.").
BoolFlag("no-cache", false, "Bypass the on-disk version cache and resolve `latest` directly from the release API.").
BoolFlag("pick-version", false, "Open an interactive picker listing every release. Pre-v1.1.0 entries are shown but disabled (use install.sh for those).").
BoolFlag("include-pre-release", false, "Include pre-release/rc tags in the --pick-version picker (default: stable releases only).").
StringFlag("version", "", "Install a specific release tag instead of the latest.").
StringFlag("download-timeout", "", "Overall timeout for the binary download (Go duration, e.g. '5m', '90s'). 0 disables the timeout. Default 2m.").
GuestRunFunc(func(ctx context.Context, cmdData *cmdBuilder.GuestCmdData) error {
check := cmdData.Params.GetBool("check")
yes := cmdData.Params.GetBool("yes")
noCache := cmdData.Params.GetBool("no-cache")
pickVersion := cmdData.Params.GetBool("pick-version")
includePrerelease := cmdData.Params.GetBool("include-pre-release")
targetVersion := cmdData.Params.GetString("version")
downloadTimeoutRaw := cmdData.Params.GetString("download-timeout")

plan, err := getVersion.PlanUpgrade(ctx, getVersion.UpgradeOptions{TargetVersion: targetVersion})
if pickVersion && targetVersion != "" {
return errors.New("--pick-version and --version are mutually exclusive")
}

upgrader := upgrade.NewUpgrader()
// --download-timeout uses StringFlag because cmdBuilder has no DurationFlag; parse manually and leave Upgrader's default in place when unset.
if downloadTimeoutRaw != "" {
d, err := time.ParseDuration(downloadTimeoutRaw)
if err != nil {
return fmt.Errorf("invalid --download-timeout %q: %w", downloadTimeoutRaw, err)
}
upgrader = upgrader.WithDownloadTimeout(d)
}
if pickVersion {
picked, err := pickReleaseInteractive(ctx, upgrader, includePrerelease)
if err != nil {
return err
}
// Pre-v1.1.0 picks can't be applied by `zcli upgrade`; show the
// install.sh fallback as a warning and stop. The picker keeps these
// rows enabled (vs. disabled) so the user gets here intentionally
// and the tag is already in their hands when they want to paste it
// into install.sh.
if !picked.SelfUpgradable {
cmdData.UxBlocks.PrintWarningText(upgrade.InstallScriptHint(picked.Tag))
return nil
}
targetVersion = picked.Tag
}
plan, err := upgrader.PlanUpgrade(ctx, upgrade.Options{
TargetVersion: targetVersion,
NoCache: noCache,
})
if err != nil {
// --check is a scripting interface, so its errors translate to a fixed exit code instead of bubbling up to the styled error printer.
if check {
cmdData.Stderr.Printf("error: %s\n", err)
return errorsx.NewExitError(2)
Expand All @@ -35,24 +81,46 @@ func upgradeCmd() *cmdBuilder.Cmd {
}

if check {
cmdData.Stdout.Printf("Current: %s\nLatest: %s\n", plan.Current, plan.Target)
if plan.Current == plan.Target {
cmdData.Stdout.Printf("Current: %s\n", plan.Current())
if targetVersion != "" {
// PlanUpgrade only fetched the user-supplied target; look up the actual latest separately so the user sees all three lines.
cmdData.Stdout.Printf("Target: %s\n", plan.Target())
latest, _ := upgrader.LatestTag(ctx, noCache)
if latest != "" {
cmdData.Stdout.Printf("Latest: %s\n", latest)
}
} else {
cmdData.Stdout.Printf("Latest: %s\n", plan.Target())
}
if err := plan.RequireSelfUpgradable(); err != nil {
cmdData.UxBlocks.PrintWarningText(err.Error())
return errorsx.NewExitError(3)
}
if !plan.NeedsUpgrade() {
return nil
}
return errorsx.NewExitError(1)
}

if err := getVersion.RequireSelfUpdatable(); err != nil {
// Channel gate before target gate: a package-managed install can't be helped by either, and pointing at the package manager is more actionable
// than pointing at install.sh.
if err := upgrader.RequireSelfUpdatable(); err != nil {
return err
}

if err := plan.RequireSelfUpgradable(); err != nil {
return err
}

if plan.Current == plan.Target && targetVersion == "" {
cmdData.Stdout.Printf("zcli is already on %s.\n", plan.Current)
// Both conditions matter: without --version, NeedsUpgrade==false means "nothing to do". With --version the user pinned a specific tag (often a
// downgrade), so proceed even when NeedsUpgrade returns false.
if !plan.NeedsUpgrade() && targetVersion == "" {
cmdData.Stdout.Printf("zcli is already on %s.\n", plan.Current())
return nil
}

if !yes {
question := fmt.Sprintf("Current: %s\nTarget: %s\n\nUpdate?", plan.Current, plan.Target)
question := fmt.Sprintf("Current: %s\nTarget: %s\n\nUpdate?", plan.Current(), plan.Target())
confirmed, err := uxHelpers.YesNoPrompt(
ctx,
question,
Expand All @@ -72,12 +140,61 @@ func upgradeCmd() *cmdBuilder.Cmd {
cmdData.UxBlocks,
[]uxHelpers.Process{{
F: func(ctx context.Context, _ *uxHelpers.Process) error {
return getVersion.Upgrade(ctx, plan)
return upgrader.Apply(ctx, plan)
},
RunningMessage: fmt.Sprintf("Downloading and installing %s", plan.Target),
ErrorMessageMessage: fmt.Sprintf("Upgrade to %s failed", plan.Target),
SuccessMessage: fmt.Sprintf("Updated to %s. Run `zcli version` to confirm.", plan.Target),
RunningMessage: fmt.Sprintf("Downloading and installing %s", plan.Target()),
ErrorMessageMessage: fmt.Sprintf("Upgrade to %s failed", plan.Target()),
SuccessMessage: fmt.Sprintf("Updated to %s. Run `zcli version` to confirm.", plan.Target()),
}},
)
})
}

// pickReleaseInteractive fetches the release list and runs a selector TUI
// over it. Every entry is selectable, including pre-v1.1.0 ones: those
// rows still get a "requires install.sh" label so users see what they're
// picking, and the caller handles the post-pick branch (print the
// install.sh hint instead of going through Apply). Page size is capped at
// 15 rows so long histories paginate predictably instead of swallowing
// the whole terminal.
func pickReleaseInteractive(ctx context.Context, upgrader upgrade.Upgrader, includePrerelease bool) (upgrade.Release, error) {
releases, err := upgrader.AvailableReleases(ctx, includePrerelease)
if err != nil {
return upgrade.Release{}, err
}
if len(releases) == 0 {
return upgrade.Release{}, errors.New("no releases available")
}

header := table.NewRowFromStrings("Version", "Status")
body := table.NewBody()
for _, r := range releases {
status := "stable"
switch {
case !r.SelfUpgradable:
status = "requires install.sh"
case r.Prerelease:
status = "pre-release"
}
body.AddRow(table.NewRowFromStrings(r.Tag, status))
}

idx, err := uxBlock.Run(
selector.NewRoot(
ctx,
body,
selector.WithLabel("Pick a release to install"),
selector.WithHeader(header),
selector.WithSetEnableFiltering(true),
selector.WithMaxRowsPerPage(15),
),
selector.GetOneSelectedFunc,
)
if err != nil {
return upgrade.Release{}, err
}
if idx < 0 || idx >= len(releases) {
return upgrade.Release{}, errors.New("invalid release selection")
}
return releases[idx], nil
}
Loading