diff --git a/doc/ovhcloud_upgrade.md b/doc/ovhcloud_upgrade.md new file mode 100644 index 00000000..7a778918 --- /dev/null +++ b/doc/ovhcloud_upgrade.md @@ -0,0 +1,38 @@ +## ovhcloud upgrade + +Upgrade OVHcloud CLI to the latest version + +``` +ovhcloud upgrade [flags] +``` + +### Options + +``` + -h, --help help for upgrade + -y, --yes Skip confirmation prompt +``` + +### Options inherited from parent commands + +``` + -d, --debug Activate debug mode (will log all HTTP requests details) + -e, --ignore-errors Ignore errors in API calls when it is not fatal to the execution + -o, --output string Output format: json, yaml, interactive, or a custom format expression (using https://github.com/PaesslerAG/gval syntax) + Examples: + --output json + --output yaml + --output interactive + --output 'id' (to extract a single field) + --output 'nested.field.subfield' (to extract a nested field) + --output '[id, "name"]' (to extract multiple fields as an array) + --output '{"newKey": oldKey, "otherKey": nested.field}' (to extract and rename fields in an object) + --output 'name+","+type' (to extract and concatenate fields in a string) + --output '(nbFieldA + nbFieldB) * 10' (to compute values from numeric fields) + --profile string Use a specific profile from the configuration file +``` + +### SEE ALSO + +* [ovhcloud](ovhcloud.md) - CLI to manage your OVHcloud services + diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 6baaa424..9e88d490 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -5,13 +5,14 @@ package cmd import ( + "context" "encoding/json" "fmt" "log" - "net/http" "os" "runtime" "sync/atomic" + "time" "github.com/spf13/cobra" "github.com/spf13/pflag" @@ -21,6 +22,7 @@ import ( "github.com/ovh/ovhcloud-cli/internal/display" "github.com/ovh/ovhcloud-cli/internal/flags" httplib "github.com/ovh/ovhcloud-cli/internal/http" + "github.com/ovh/ovhcloud-cli/internal/upgrade" "github.com/ovh/ovhcloud-cli/internal/version" ) @@ -45,6 +47,7 @@ var ( wasmHiddenCommands = []string{ "login", "config", + "upgrade", } ) @@ -152,25 +155,14 @@ Examples: return } - const latestURL = "https://github.com/ovh/ovhcloud-cli/releases/latest" - req, err := http.NewRequest("GET", latestURL, nil) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + tag, err := upgrade.LatestTag(ctx) if err != nil { return } - req.Header.Set("Accept", "application/json") - resp, err := http.DefaultClient.Do(req) - if err != nil { - return - } - defer resp.Body.Close() - var data struct { - TagName string `json:"tag_name"` - } - if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { - return - } - if data.TagName != "" && data.TagName != version.Version { - message := fmt.Sprintf("A new version of ovhcloud-cli is available: %s (current: %s)", data.TagName, version.Version) + if tag != "" && tag != version.Version { + message := fmt.Sprintf("A new version of ovhcloud-cli is available: %s (current: %s). Run `ovhcloud upgrade` to update.", tag, version.Version) newVersionMessage.Store(&message) } }() diff --git a/internal/cmd/upgrade.go b/internal/cmd/upgrade.go new file mode 100644 index 00000000..99920d76 --- /dev/null +++ b/internal/cmd/upgrade.go @@ -0,0 +1,115 @@ +// SPDX-FileCopyrightText: 2025 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "bufio" + "context" + "errors" + "fmt" + "os" + "runtime" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/ovh/ovhcloud-cli/internal/upgrade" + "github.com/ovh/ovhcloud-cli/internal/version" +) + +const installDocURL = "https://github.com/ovh/ovhcloud-cli#installation" + +// wrapPermissionError augments permission-denied errors with a suggestion to +// retry with elevated privileges or reinstall following the documentation. +func wrapPermissionError(err error) error { + if err == nil { + return nil + } + if errors.Is(err, os.ErrPermission) { + return fmt.Errorf("%w\n\nRetry with sudo, or reinstall following the documentation: %s", err, installDocURL) + } + return err +} + +var upgradeAssumeYes bool + +func init() { + if runtime.GOARCH == "wasm" && runtime.GOOS == "js" { + return + } + + upgradeCmd := &cobra.Command{ + Use: "upgrade", + Short: "Upgrade OVHcloud CLI to the latest version", + RunE: runUpgrade, + } + upgradeCmd.Flags().BoolVarP(&upgradeAssumeYes, "yes", "y", false, "Skip confirmation prompt") + // Skip parent PersistentPreRun (no API client needed). + upgradeCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {} + + rootCmd.AddCommand(upgradeCmd) +} + +func runUpgrade(cmd *cobra.Command, _ []string) error { + if version.Version == "undefined" { + return errors.New("upgrade is not available in development builds") + } + + ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second) + defer cancel() + + tag, err := upgrade.LatestTag(ctx) + if err != nil { + return err + } + + if tag == version.Version { + fmt.Fprintf(cmd.OutOrStdout(), "Already on latest (%s)\n", tag) + return nil + } + + method := upgrade.DetectInstallMethod() + switch method { + case upgrade.MethodBrew: + fmt.Fprintf(cmd.OutOrStdout(), "Upgrade via Homebrew:\n\n brew upgrade --cask ovh/tap/ovhcloud-cli\n\n") + return nil + case upgrade.MethodGoInstall: + fmt.Fprintf(cmd.OutOrStdout(), "Upgrade via go install:\n\n go install github.com/ovh/ovhcloud-cli/cmd/ovhcloud@latest\n\n") + return nil + } + + if runtime.GOOS == "windows" { + fmt.Fprintf(cmd.OutOrStdout(), "Automatic upgrade on Windows is not supported. Download the latest release from:\n\n https://github.com/ovh/ovhcloud-cli/releases/tag/%s\n\n", tag) + return nil + } + + exe, err := upgrade.ResolveExecutable() + if err != nil { + return err + } + + if err := upgrade.CheckWritable(exe); err != nil { + return wrapPermissionError(err) + } + + if !upgradeAssumeYes { + fmt.Fprintf(cmd.OutOrStdout(), "Replace %s (%s) with %s? [y/N] ", exe, version.Version, tag) + reader := bufio.NewReader(cmd.InOrStdin()) + line, _ := reader.ReadString('\n') + answer := strings.TrimSpace(strings.ToLower(line)) + if answer != "y" && answer != "yes" { + fmt.Fprintln(cmd.OutOrStdout(), "Aborted.") + return nil + } + } + + fmt.Fprintf(cmd.OutOrStdout(), "Downloading %s...\n", tag) + if err := upgrade.SelfReplace(ctx, tag, exe); err != nil { + return wrapPermissionError(err) + } + fmt.Fprintf(cmd.OutOrStdout(), "Installed %s at %s\n", tag, exe) + return nil +} diff --git a/internal/upgrade/detect.go b/internal/upgrade/detect.go new file mode 100644 index 00000000..04335b6c --- /dev/null +++ b/internal/upgrade/detect.go @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2025 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +package upgrade + +import ( + "os" + "path/filepath" + "strings" +) + +// DetectInstallMethod inspects the running binary's path and environment to +// guess how the CLI was installed. +func DetectInstallMethod() Method { + exePath, err := os.Executable() + if err != nil { + return MethodBinary + } + if resolved, err := filepath.EvalSymlinks(exePath); err == nil { + exePath = resolved + } + return detect(exePath, os.Getenv("GOBIN"), os.Getenv("GOPATH"), os.Getenv("HOME")) +} + +func detect(exePath, gobin, gopath, home string) Method { + if strings.Contains(exePath, "/Cellar/") || strings.Contains(exePath, "/Caskroom/") { + return MethodBrew + } + + dir := filepath.Dir(exePath) + if gobin != "" && dir == gobin { + return MethodGoInstall + } + if gopath != "" && dir == filepath.Join(gopath, "bin") { + return MethodGoInstall + } + if home != "" && dir == filepath.Join(home, "go", "bin") { + return MethodGoInstall + } + + return MethodBinary +} diff --git a/internal/upgrade/detect_test.go b/internal/upgrade/detect_test.go new file mode 100644 index 00000000..92285255 --- /dev/null +++ b/internal/upgrade/detect_test.go @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: 2025 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +package upgrade + +import "testing" + +func TestDetect(t *testing.T) { + cases := []struct { + name string + exePath string + gobin string + gopath string + home string + want Method + }{ + { + name: "homebrew cellar", + exePath: "/opt/homebrew/Cellar/ovhcloud-cli/1.2.3/bin/ovhcloud", + home: "/Users/alice", + want: MethodBrew, + }, + { + name: "homebrew caskroom", + exePath: "/usr/local/Caskroom/ovhcloud-cli/1.2.3/ovhcloud", + home: "/Users/alice", + want: MethodBrew, + }, + { + name: "go install via GOBIN", + exePath: "/home/alice/go-bin/ovhcloud", + gobin: "/home/alice/go-bin", + home: "/home/alice", + want: MethodGoInstall, + }, + { + name: "go install via GOPATH", + exePath: "/home/alice/gopath/bin/ovhcloud", + gopath: "/home/alice/gopath", + home: "/home/alice", + want: MethodGoInstall, + }, + { + name: "go install via default $HOME/go/bin", + exePath: "/home/alice/go/bin/ovhcloud", + home: "/home/alice", + want: MethodGoInstall, + }, + { + name: "install.sh in ~/.local/bin", + exePath: "/home/alice/.local/bin/ovhcloud", + home: "/home/alice", + want: MethodBinary, + }, + { + name: "manual /usr/local/bin", + exePath: "/usr/local/bin/ovhcloud", + home: "/home/alice", + want: MethodBinary, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := detect(tc.exePath, tc.gobin, tc.gopath, tc.home) + if got != tc.want { + t.Fatalf("detect(%q) = %v, want %v", tc.exePath, got, tc.want) + } + }) + } +} diff --git a/internal/upgrade/install.go b/internal/upgrade/install.go new file mode 100644 index 00000000..76b0e99d --- /dev/null +++ b/internal/upgrade/install.go @@ -0,0 +1,150 @@ +// SPDX-FileCopyrightText: 2025 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +package upgrade + +import ( + "archive/tar" + "compress/gzip" + "context" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "time" +) + +const downloadBaseURL = "https://github.com/ovh/ovhcloud-cli/releases/download" + +// CheckWritable verifies that the running process can replace the binary at +// targetPath by probing the parent directory. os.Rename only requires write +// permission on the parent directory, not on the target file itself. +func CheckWritable(targetPath string) error { + dir := filepath.Dir(targetPath) + probe, err := os.CreateTemp(dir, ".ovhcloud-upgrade-check-*") + if err != nil { + return fmt.Errorf("cannot write to %s: %w", dir, err) + } + probePath := probe.Name() + probe.Close() + os.Remove(probePath) + return nil +} + +// SelfReplace downloads the release asset for the given tag and replaces the +// binary at targetPath in place. Only supported on linux and darwin. +// Callers should resolve symlinks before invoking so the rename targets the +// real binary. +func SelfReplace(ctx context.Context, tag, targetPath string) error { + if runtime.GOOS == "windows" { + return errors.New("self-replace not supported on windows") + } + client := &http.Client{Timeout: 60 * time.Second} + return selfReplace(ctx, client, downloadBaseURL, tag, targetPath, runtime.GOOS, runtime.GOARCH) +} + +// ResolveExecutable returns the path of the current binary with symlinks +// resolved. Callers use the resolved path consistently for permission checks, +// user-facing messages, and the rename target. +func ResolveExecutable() (string, error) { + exe, err := os.Executable() + if err != nil { + return "", fmt.Errorf("locate current binary: %w", err) + } + if resolved, err := filepath.EvalSymlinks(exe); err == nil { + exe = resolved + } + return exe, nil +} + +func assetName(goos, goarch string) string { + osName := strings.ToUpper(goos[:1]) + goos[1:] + var arch string + switch goarch { + case "amd64": + arch = "x86_64" + case "386": + arch = "i386" + default: + arch = goarch + } + return fmt.Sprintf("ovhcloud-cli_%s_%s.tar.gz", osName, arch) +} + +func selfReplace(ctx context.Context, client *http.Client, baseURL, tag, targetPath, goos, goarch string) error { + asset := assetName(goos, goarch) + url := fmt.Sprintf("%s/%s/%s", strings.TrimRight(baseURL, "/"), tag, asset) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("download %s: %w", asset, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download %s: HTTP %d", asset, resp.StatusCode) + } + + // Extract binary in the same directory as the target so os.Rename stays on + // one filesystem. + targetDir := filepath.Dir(targetPath) + tmp, err := os.CreateTemp(targetDir, ".ovhcloud-upgrade-*") + if err != nil { + return fmt.Errorf("create temp file: %w", err) + } + tmpPath := tmp.Name() + defer func() { + if _, statErr := os.Stat(tmpPath); statErr == nil { + os.Remove(tmpPath) + } + }() + + if err := extractBinary(resp.Body, tmp); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close temp file: %w", err) + } + if err := os.Chmod(tmpPath, 0o755); err != nil { + return fmt.Errorf("chmod temp file: %w", err) + } + if err := os.Rename(tmpPath, targetPath); err != nil { + return fmt.Errorf("replace %s: %w", targetPath, err) + } + return nil +} + +func extractBinary(r io.Reader, out io.Writer) error { + gz, err := gzip.NewReader(r) + if err != nil { + return fmt.Errorf("gzip reader: %w", err) + } + defer gz.Close() + + tr := tar.NewReader(gz) + for { + hdr, err := tr.Next() + if err == io.EOF { + return errors.New("ovhcloud binary not found in archive") + } + if err != nil { + return fmt.Errorf("tar next: %w", err) + } + if filepath.Base(hdr.Name) != "ovhcloud" { + continue + } + if _, err := io.Copy(out, tr); err != nil { + return fmt.Errorf("extract binary: %w", err) + } + return nil + } +} diff --git a/internal/upgrade/install_test.go b/internal/upgrade/install_test.go new file mode 100644 index 00000000..e6ddf923 --- /dev/null +++ b/internal/upgrade/install_test.go @@ -0,0 +1,142 @@ +// SPDX-FileCopyrightText: 2025 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +package upgrade + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func buildTarGz(t *testing.T, files map[string][]byte) []byte { + t.Helper() + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + for name, content := range files { + hdr := &tar.Header{ + Name: name, + Mode: 0o755, + Size: int64(len(content)), + } + if err := tw.WriteHeader(hdr); err != nil { + t.Fatal(err) + } + if _, err := tw.Write(content); err != nil { + t.Fatal(err) + } + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := gz.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func TestSelfReplace(t *testing.T) { + tarball := buildTarGz(t, map[string][]byte{ + "ovhcloud": []byte("NEW_BINARY_CONTENTS"), + }) + + var requestedPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestedPath = r.URL.Path + w.Header().Set("Content-Type", "application/gzip") + w.Write(tarball) + })) + defer server.Close() + + target := filepath.Join(t.TempDir(), "ovhcloud") + if err := os.WriteFile(target, []byte("OLD"), 0o755); err != nil { + t.Fatal(err) + } + + err := selfReplace(context.Background(), server.Client(), server.URL, "v1.4.2", target, "linux", "amd64") + if err != nil { + t.Fatalf("selfReplace: %v", err) + } + + if !strings.HasSuffix(requestedPath, "/v1.4.2/ovhcloud-cli_Linux_x86_64.tar.gz") { + t.Errorf("requested %q, want suffix /v1.4.2/ovhcloud-cli_Linux_x86_64.tar.gz", requestedPath) + } + + got, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + if string(got) != "NEW_BINARY_CONTENTS" { + t.Errorf("target contents = %q, want NEW_BINARY_CONTENTS", got) + } + + info, err := os.Stat(target) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o755 { + t.Errorf("mode = %v, want 0755", info.Mode().Perm()) + } +} + +func TestAssetName(t *testing.T) { + cases := []struct { + goos, goarch string + want string + }{ + {"linux", "amd64", "ovhcloud-cli_Linux_x86_64.tar.gz"}, + {"linux", "arm64", "ovhcloud-cli_Linux_arm64.tar.gz"}, + {"linux", "386", "ovhcloud-cli_Linux_i386.tar.gz"}, + {"darwin", "amd64", "ovhcloud-cli_Darwin_x86_64.tar.gz"}, + {"darwin", "arm64", "ovhcloud-cli_Darwin_arm64.tar.gz"}, + } + for _, tc := range cases { + t.Run(tc.goos+"_"+tc.goarch, func(t *testing.T) { + got := assetName(tc.goos, tc.goarch) + if got != tc.want { + t.Errorf("assetName(%s,%s) = %q, want %q", tc.goos, tc.goarch, got, tc.want) + } + }) + } +} + +func TestCheckWritable(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "ovhcloud") + if err := os.WriteFile(target, []byte("x"), 0o755); err != nil { + t.Fatal(err) + } + + if err := CheckWritable(target); err != nil { + t.Fatalf("writable target: got err %v, want nil", err) + } +} + +func TestSelfReplaceHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "not found", http.StatusNotFound) + })) + defer server.Close() + + target := filepath.Join(t.TempDir(), "ovhcloud") + os.WriteFile(target, []byte("OLD"), 0o755) + + err := selfReplace(context.Background(), server.Client(), server.URL, "v1.4.2", target, "linux", "amd64") + if err == nil { + t.Fatal("expected error, got nil") + } + + got, _ := os.ReadFile(target) + if string(got) != "OLD" { + t.Errorf("target clobbered on error: %q", got) + } +} diff --git a/internal/upgrade/install_unix_test.go b/internal/upgrade/install_unix_test.go new file mode 100644 index 00000000..208a28e3 --- /dev/null +++ b/internal/upgrade/install_unix_test.go @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2025 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +//go:build !windows + +package upgrade + +import ( + "os" + "path/filepath" + "testing" +) + +func TestCheckWritableReadOnlyDir(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("running as root, permission checks bypassed") + } + + readOnlyDir := filepath.Join(t.TempDir(), "ro") + if err := os.Mkdir(readOnlyDir, 0o755); err != nil { + t.Fatal(err) + } + roTarget := filepath.Join(readOnlyDir, "ovhcloud") + if err := os.WriteFile(roTarget, []byte("x"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Chmod(readOnlyDir, 0o555); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chmod(readOnlyDir, 0o755) }) + + if err := CheckWritable(roTarget); err == nil { + t.Fatal("read-only dir: got nil, want error") + } +} diff --git a/internal/upgrade/method.go b/internal/upgrade/method.go new file mode 100644 index 00000000..58b9b4c8 --- /dev/null +++ b/internal/upgrade/method.go @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2025 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +package upgrade + +// Method describes how the CLI was installed. +type Method int + +const ( + // MethodBinary is a standalone binary (install.sh, manual download, or unknown). + MethodBinary Method = iota + // MethodBrew is a Homebrew cask install. + MethodBrew + // MethodGoInstall is a `go install` install. + MethodGoInstall +) + +func (m Method) String() string { + switch m { + case MethodBrew: + return "brew" + case MethodGoInstall: + return "go install" + default: + return "binary" + } +} diff --git a/internal/upgrade/release.go b/internal/upgrade/release.go new file mode 100644 index 00000000..96b433e9 --- /dev/null +++ b/internal/upgrade/release.go @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: 2025 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +package upgrade + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "time" +) + +const latestReleaseURL = "https://github.com/ovh/ovhcloud-cli/releases/latest" + +// LatestTag returns the tag_name of the latest GitHub release of ovhcloud-cli. +func LatestTag(ctx context.Context) (string, error) { + client := &http.Client{Timeout: 30 * time.Second} + return latestTag(ctx, client, latestReleaseURL) +} + +func latestTag(ctx context.Context, client *http.Client, url string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", fmt.Errorf("build request: %w", err) + } + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("fetch release metadata: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("release metadata: HTTP %d", resp.StatusCode) + } + + var data struct { + TagName string `json:"tag_name"` + } + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return "", fmt.Errorf("decode release metadata: %w", err) + } + if data.TagName == "" { + return "", errors.New("release metadata: empty tag_name") + } + return data.TagName, nil +} diff --git a/internal/upgrade/release_test.go b/internal/upgrade/release_test.go new file mode 100644 index 00000000..089f20e5 --- /dev/null +++ b/internal/upgrade/release_test.go @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: 2025 OVH SAS +// +// SPDX-License-Identifier: Apache-2.0 + +package upgrade + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func TestLatestTag(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Accept"); got != "application/json" { + t.Errorf("Accept header = %q, want application/json", got) + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"tag_name":"v1.4.2","name":"v1.4.2"}`)) + })) + defer server.Close() + + tag, err := latestTag(context.Background(), server.Client(), server.URL) + if err != nil { + t.Fatalf("latestTag: %v", err) + } + if tag != "v1.4.2" { + t.Fatalf("tag = %q, want v1.4.2", tag) + } +} + +func TestLatestTagHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "nope", http.StatusInternalServerError) + })) + defer server.Close() + + _, err := latestTag(context.Background(), server.Client(), server.URL) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestLatestTagMissingField(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{}`)) + })) + defer server.Close() + + _, err := latestTag(context.Background(), server.Client(), server.URL) + if err == nil { + t.Fatal("expected error for missing tag_name, got nil") + } +}