diff --git a/cmd/output/interactive_printer.go b/cmd/output/interactive_printer.go index b300382..1f52d39 100644 --- a/cmd/output/interactive_printer.go +++ b/cmd/output/interactive_printer.go @@ -104,6 +104,27 @@ func (p *interactivePrinter) Print(v any) error { w(" git commit: %s\n", t.GitCommit) w(" build date: %s\n", t.BuildDate) w(" go: %s %s %s\n", t.GoVersion, t.Compiler, t.Platform) + case UpdateResult: + switch t.Status { + case UpdateStatusUpToDate: + w("%s\n", styleGreen.Render("cloudctl is up to date ("+t.CurrentVersion+").")) + case UpdateStatusAvailable: + w("%s %s %s\n", + styleYellow.Render("A new version is available:"), + styleBold.Render(t.LatestVersion), + styleFaint.Render("(current: "+t.CurrentVersion+")"), + ) + w("Run %s to install it.\n", styleBold.Render("cloudctl update")) + case UpdateStatusUpdated: + w("%s %s %s %s\n", + styleGreen.Render("cloudctl updated:"), + t.CurrentVersion, + styleGreen.Render("->"), + styleBold.Render(t.LatestVersion), + ) + default: + w("cloudctl update status: %s (current: %s, latest: %s)\n", t.Status, t.CurrentVersion, t.LatestVersion) + } default: w("%v\n", v) } diff --git a/cmd/output/plain_printer.go b/cmd/output/plain_printer.go index 73a3475..5688dc7 100644 --- a/cmd/output/plain_printer.go +++ b/cmd/output/plain_printer.go @@ -87,6 +87,19 @@ func (p *plainPrinter) Print(v any) error { w(" build date: %s\n", t.BuildDate) w(" go: %s %s %s\n", t.GoVersion, t.Compiler, t.Platform) + case UpdateResult: + switch t.Status { + case UpdateStatusUpToDate: + w("cloudctl is up to date (%s).\n", t.CurrentVersion) + case UpdateStatusAvailable: + w("A new version is available: %s (current: %s)\n", t.LatestVersion, t.CurrentVersion) + w("Run `cloudctl update` to install it.\n") + case UpdateStatusUpdated: + w("cloudctl updated: %s -> %s\n", t.CurrentVersion, t.LatestVersion) + default: + w("cloudctl update status: %s (current: %s, latest: %s)\n", t.Status, t.CurrentVersion, t.LatestVersion) + } + default: w("%v\n", v) } diff --git a/cmd/output/types.go b/cmd/output/types.go index 65accc4..7511648 100644 --- a/cmd/output/types.go +++ b/cmd/output/types.go @@ -51,6 +51,22 @@ type ErrorResult struct { Error string `json:"error" yaml:"error"` } +// UpdateStatus represents the outcome of an update check or install. +type UpdateStatus string + +const ( + UpdateStatusUpToDate UpdateStatus = "up-to-date" + UpdateStatusAvailable UpdateStatus = "available" + UpdateStatusUpdated UpdateStatus = "updated" +) + +// UpdateResult is the output of the update command. +type UpdateResult struct { + CurrentVersion string `json:"currentVersion" yaml:"currentVersion"` + LatestVersion string `json:"latestVersion" yaml:"latestVersion"` + Status UpdateStatus `json:"status" yaml:"status"` +} + // AccessDiff describes one cluster access (context) that is changing. type AccessDiff struct { Name string `json:"name" yaml:"name"` diff --git a/cmd/root.go b/cmd/root.go index ecee582..3826043 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -30,6 +30,7 @@ Commands: sync Fetch ClusterKubeconfigs from Greenhouse and merge them locally cluster-version Query the Kubernetes server version of a kubeconfig context version Print cloudctl build information + update Check for and install the latest cloudctl release Global flags available on every command: -o, --output text|json|yaml Output format (default: text) @@ -86,6 +87,7 @@ func init() { rootCmd.AddCommand(syncCmd) rootCmd.AddCommand(clusterVersionCmd) rootCmd.AddCommand(versionCmd) + rootCmd.AddCommand(updateCmd) } // configWithContext builds a rest.Config for the specified context name from the given kubeconfig path. diff --git a/cmd/update.go b/cmd/update.go new file mode 100644 index 0000000..c916d83 --- /dev/null +++ b/cmd/update.go @@ -0,0 +1,345 @@ +// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Greenhouse contributors +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "runtime" + "strings" + "time" + + "github.com/minio/selfupdate" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "golang.org/x/mod/semver" + + "github.com/cloudoperators/cloudctl/cmd/output" +) + +const githubReleasesLatestURL = "https://api.github.com/repos/cloudoperators/cloudctl/releases/latest" + +// updateHTTPClient is used for all update-related network calls. +// It clones http.DefaultTransport (when it is a *http.Transport) so proxy +// settings, TLS config, and HTTP/2 support are preserved. +// ResponseHeaderTimeout (30s) prevents hangs waiting for response headers. +// Timeout (10m) provides a hard upper bound on total request time, allowing +// large archive downloads to complete while still bounding worst-case duration. +var updateHTTPClient = func() *http.Client { + var transport http.RoundTripper + if dt, ok := http.DefaultTransport.(*http.Transport); ok { + t := dt.Clone() + t.ResponseHeaderTimeout = 30 * time.Second + transport = t + } else { + transport = http.DefaultTransport + } + return &http.Client{ + Transport: transport, + Timeout: 10 * time.Minute, + } +}() + +type ghRelease struct { + TagName string `json:"tag_name"` + Assets []ghAsset `json:"assets"` +} + +type ghAsset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` +} + +var updateCmd = &cobra.Command{ + Use: "update", + Short: "Check for and install the latest cloudctl release", + Long: `Queries the GitHub Releases API for the latest cloudctl version, +downloads the archive for the current OS/architecture, verifies the SHA256 +checksum, and atomically replaces the running binary. + +Use --check to only report whether an update is available without installing. + +Examples: + # Check for updates without installing + cloudctl update --check + + # Check and emit JSON for scripting + cloudctl update --check -o json + + # Install the latest release + cloudctl update`, + RunE: runUpdate, +} + +func init() { + updateCmd.Flags().Bool("check", false, "Check for updates without installing") + _ = viper.BindPFlags(updateCmd.Flags()) +} + +func runUpdate(cmd *cobra.Command, _ []string) error { + checkOnly := viper.GetBool("check") + + format, err := output.ParseFormat(viper.GetString("output")) + if err != nil { + return err + } + w := cmd.OutOrStdout() + printer := output.New(format, output.IsTTYWriter(w), w) + + if Version == "dev" { + _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "warning: running a dev build; version comparison may be inaccurate") + } + + stop := printer.StartSpinner("Checking for updates...") + rel, err := fetchLatestRelease(cmd.Context()) + stop() + if err != nil { + return fmt.Errorf("checking for updates: %w", err) + } + + // Normalise both versions to a "v" prefix for semver comparison. + currentVersion := Version + if !strings.HasPrefix(currentVersion, "v") { + currentVersion = "v" + currentVersion + } + latestVersion := rel.TagName + if !strings.HasPrefix(latestVersion, "v") { + latestVersion = "v" + latestVersion + } + + result := output.UpdateResult{ + CurrentVersion: currentVersion, + LatestVersion: latestVersion, + } + + // Use semver comparison so that current >= latest (e.g., pre-release or newer + // self-built version) is reported as up-to-date rather than triggering a downgrade. + // If either version is not a valid semver string, fall back to string equality. + if semver.IsValid(currentVersion) && semver.IsValid(latestVersion) { + if semver.Compare(currentVersion, latestVersion) >= 0 { + result.Status = output.UpdateStatusUpToDate + return printer.Print(result) + } + } else if currentVersion == latestVersion { + result.Status = output.UpdateStatusUpToDate + return printer.Print(result) + } + + if checkOnly { + result.Status = output.UpdateStatusAvailable + return printer.Print(result) + } + + assetName := fmt.Sprintf("cloudctl_%s_%s.tar.gz", runtime.GOOS, runtime.GOARCH) + checksumName := assetName + ".sha256" + + if runtime.GOOS == "windows" { + return fmt.Errorf("self-update on Windows is not yet supported (releases use .zip archives); please download manually from https://github.com/cloudoperators/cloudctl/releases") + } + + archiveURL, checksumURL, err := findAssetURLs(rel, assetName, checksumName) + if err != nil { + return err + } + + stop = printer.StartSpinner(fmt.Sprintf("Downloading %s...", latestVersion)) + expectedChecksum, err := downloadChecksum(cmd.Context(), checksumURL) + if err != nil { + stop() + return fmt.Errorf("downloading checksum: %w", err) + } + + binary, err := downloadAndExtract(cmd.Context(), archiveURL, expectedChecksum) + stop() + if err != nil { + return fmt.Errorf("downloading and extracting archive: %w", err) + } + + if err := applyUpdate(binary); err != nil { + return fmt.Errorf("applying update: %w", err) + } + + result.Status = output.UpdateStatusUpdated + return printer.Print(result) +} + +func fetchLatestRelease(ctx context.Context) (*ghRelease, error) { + return fetchLatestReleaseFrom(ctx, updateHTTPClient, githubReleasesLatestURL) +} + +func fetchLatestReleaseFrom(ctx context.Context, client *http.Client, url string) (*ghRelease, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", "cloudctl/"+Version) + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GitHub API returned HTTP %d", resp.StatusCode) + } + + var rel ghRelease + if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { + return nil, fmt.Errorf("decoding GitHub API response: %w", err) + } + return &rel, nil +} + +func findAssetURLs(rel *ghRelease, assetName, checksumName string) (archiveURL, checksumURL string, err error) { + for _, a := range rel.Assets { + switch a.Name { + case assetName: + archiveURL = a.BrowserDownloadURL + case checksumName: + checksumURL = a.BrowserDownloadURL + } + } + if archiveURL == "" { + return "", "", fmt.Errorf("no release asset found for %q", assetName) + } + if checksumURL == "" { + return "", "", fmt.Errorf("no checksum asset found for %q", checksumName) + } + return archiveURL, checksumURL, nil +} + +// downloadChecksum fetches a .sha256 file and returns the raw digest bytes. +// It handles both bare hex strings and BSD-style " " lines. +func downloadChecksum(ctx context.Context, url string) ([]byte, error) { + return downloadChecksumFrom(ctx, updateHTTPClient, url) +} + +func downloadChecksumFrom(ctx context.Context, client *http.Client, url string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d", resp.StatusCode) + } + + const maxChecksumSize = 4 << 10 // 4 KiB — far more than any checksum line needs + raw, err := io.ReadAll(io.LimitReader(resp.Body, maxChecksumSize+1)) + if err != nil { + return nil, err + } + if int64(len(raw)) > maxChecksumSize { + return nil, fmt.Errorf("checksum file exceeds maximum allowed size of 4 KiB") + } + + line := strings.TrimSpace(string(raw)) + // BSD-style: " " — take the first field. + fields := strings.Fields(line) + if len(fields) == 0 { + return nil, fmt.Errorf("checksum file is empty") + } + hexStr := fields[0] + + digest, err := hex.DecodeString(hexStr) + if err != nil { + return nil, fmt.Errorf("parsing checksum hex %q: %w", hexStr, err) + } + if len(digest) != sha256.Size { + return nil, fmt.Errorf("invalid checksum length: got %d bytes, want %d", len(digest), sha256.Size) + } + return digest, nil +} + +// downloadAndExtract downloads the .tar.gz archive, verifies its SHA256 against +// expectedChecksum, then extracts and returns the cloudctl binary as an io.Reader. +func downloadAndExtract(ctx context.Context, archiveURL string, expectedChecksum []byte) (io.Reader, error) { + return downloadAndExtractFrom(ctx, updateHTTPClient, archiveURL, expectedChecksum) +} + +func downloadAndExtractFrom(ctx context.Context, client *http.Client, url string, expectedChecksum []byte) (io.Reader, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d", resp.StatusCode) + } + + const maxArchiveSize = 256 << 20 // 256 MiB + archiveBytes, err := io.ReadAll(io.LimitReader(resp.Body, maxArchiveSize+1)) + if err != nil { + return nil, fmt.Errorf("reading archive: %w", err) + } + if int64(len(archiveBytes)) > maxArchiveSize { + return nil, fmt.Errorf("archive exceeds maximum allowed size of 256 MiB") + } + + // Verify SHA256 of the raw archive bytes. + digest := sha256.Sum256(archiveBytes) + if !bytes.Equal(digest[:], expectedChecksum) { + return nil, fmt.Errorf("checksum mismatch: expected %x, got %x", expectedChecksum, digest) + } + + // Decompress and extract the cloudctl binary from the tar. + gz, err := gzip.NewReader(bytes.NewReader(archiveBytes)) + if err != nil { + return nil, fmt.Errorf("decompressing archive: %w", err) + } + defer func() { _ = gz.Close() }() + + tr := tar.NewReader(gz) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("reading archive: %w", err) + } + if hdr.Name == "cloudctl" || strings.HasSuffix(hdr.Name, "/cloudctl") { + if hdr.Typeflag != tar.TypeReg { + return nil, fmt.Errorf("archive entry %q is not a regular file (type %d)", hdr.Name, hdr.Typeflag) + } + const maxBinarySize = 128 << 20 // 128 MiB + binaryBytes, err := io.ReadAll(io.LimitReader(tr, maxBinarySize+1)) + if err != nil { + return nil, fmt.Errorf("reading binary from archive: %w", err) + } + if int64(len(binaryBytes)) > maxBinarySize { + return nil, fmt.Errorf("binary exceeds maximum allowed size of 128 MiB") + } + return bytes.NewReader(binaryBytes), nil + } + } + + return nil, fmt.Errorf("binary \"cloudctl\" not found in archive") +} + +func applyUpdate(r io.Reader) error { + return selfupdate.Apply(r, selfupdate.Options{}) +} diff --git a/cmd/update_test.go b/cmd/update_test.go new file mode 100644 index 0000000..b55fc26 --- /dev/null +++ b/cmd/update_test.go @@ -0,0 +1,368 @@ +// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Greenhouse contributors +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + . "github.com/onsi/gomega" + + "github.com/cloudoperators/cloudctl/cmd/output" +) + +// buildTestArchive creates a minimal valid .tar.gz containing a "cloudctl" binary entry. +// Returns the archive bytes and the SHA256 digest of those bytes. +func buildTestArchive(t *testing.T, binaryContent []byte) (archiveBytes, checksum []byte) { + t.Helper() + + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + + hdr := &tar.Header{ + Name: "cloudctl", + Mode: 0o755, + Size: int64(len(binaryContent)), + } + if err := tw.WriteHeader(hdr); err != nil { + t.Fatalf("tar.WriteHeader: %v", err) + } + if _, err := tw.Write(binaryContent); err != nil { + t.Fatalf("tar.Write: %v", err) + } + if err := tw.Close(); err != nil { + t.Fatalf("tar.Close: %v", err) + } + if err := gz.Close(); err != nil { + t.Fatalf("gzip.Close: %v", err) + } + + archiveBytes = buf.Bytes() + digest := sha256.Sum256(archiveBytes) + checksum = digest[:] + return archiveBytes, checksum +} + +// --- fetchLatestRelease tests --- + +func TestFetchLatestRelease_OK(t *testing.T) { + g := NewWithT(t) + + rel := ghRelease{ + TagName: "v1.2.3", + Assets: []ghAsset{ + {Name: "cloudctl_linux_amd64.tar.gz", BrowserDownloadURL: "https://example.com/archive.tar.gz"}, + }, + } + body, _ := json.Marshal(rel) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(body) + })) + defer srv.Close() + + got, err := fetchLatestReleaseFrom(t.Context(), srv.Client(), srv.URL) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(got.TagName).To(Equal("v1.2.3")) + g.Expect(got.Assets).To(HaveLen(1)) + g.Expect(got.Assets[0].Name).To(Equal("cloudctl_linux_amd64.tar.gz")) +} + +func TestFetchLatestRelease_HTTPError(t *testing.T) { + g := NewWithT(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + _, err := fetchLatestReleaseFrom(t.Context(), srv.Client(), srv.URL) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("404")) +} + +// --- findAssetURLs tests --- + +func TestFindAssetURLs_Found(t *testing.T) { + g := NewWithT(t) + + rel := &ghRelease{ + TagName: "v1.0.0", + Assets: []ghAsset{ + {Name: "cloudctl_linux_amd64.tar.gz", BrowserDownloadURL: "https://example.com/archive.tar.gz"}, + {Name: "cloudctl_linux_amd64.tar.gz.sha256", BrowserDownloadURL: "https://example.com/archive.tar.gz.sha256"}, + }, + } + + archURL, csURL, err := findAssetURLs(rel, "cloudctl_linux_amd64.tar.gz", "cloudctl_linux_amd64.tar.gz.sha256") + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(archURL).To(Equal("https://example.com/archive.tar.gz")) + g.Expect(csURL).To(Equal("https://example.com/archive.tar.gz.sha256")) +} + +func TestFindAssetURLs_MissingArchive(t *testing.T) { + g := NewWithT(t) + + rel := &ghRelease{ + Assets: []ghAsset{ + {Name: "cloudctl_linux_amd64.tar.gz.sha256", BrowserDownloadURL: "https://example.com/checksum"}, + }, + } + + _, _, err := findAssetURLs(rel, "cloudctl_linux_amd64.tar.gz", "cloudctl_linux_amd64.tar.gz.sha256") + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("cloudctl_linux_amd64.tar.gz")) +} + +func TestFindAssetURLs_MissingChecksum(t *testing.T) { + g := NewWithT(t) + + rel := &ghRelease{ + Assets: []ghAsset{ + {Name: "cloudctl_linux_amd64.tar.gz", BrowserDownloadURL: "https://example.com/archive.tar.gz"}, + }, + } + + _, _, err := findAssetURLs(rel, "cloudctl_linux_amd64.tar.gz", "cloudctl_linux_amd64.tar.gz.sha256") + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("cloudctl_linux_amd64.tar.gz.sha256")) +} + +// --- downloadChecksum tests --- + +func TestDownloadChecksum_EmptyBody(t *testing.T) { + g := NewWithT(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(" \n")) + })) + defer srv.Close() + + _, err := downloadChecksumFrom(t.Context(), srv.Client(), srv.URL) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("empty")) +} + +func TestDownloadChecksum_OK(t *testing.T) { + g := NewWithT(t) + + expected := make([]byte, 32) + for i := range expected { + expected[i] = byte(i) + } + hexStr := hex.EncodeToString(expected) + // BSD-style line: " " + body := fmt.Sprintf("%s cloudctl_linux_amd64.tar.gz\n", hexStr) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(body)) + })) + defer srv.Close() + + got, err := downloadChecksumFrom(t.Context(), srv.Client(), srv.URL) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(got).To(Equal(expected)) +} + +func TestDownloadChecksum_BareHex(t *testing.T) { + g := NewWithT(t) + + expected := make([]byte, 32) + for i := range expected { + expected[i] = byte(i * 2 % 256) + } + hexStr := hex.EncodeToString(expected) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(hexStr + "\n")) + })) + defer srv.Close() + + got, err := downloadChecksumFrom(t.Context(), srv.Client(), srv.URL) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(got).To(Equal(expected)) +} + +// --- downloadAndExtract tests --- + +func TestDownloadAndExtract_OK(t *testing.T) { + g := NewWithT(t) + + binaryContent := []byte("fake cloudctl binary content") + archiveBytes, checksum := buildTestArchive(t, binaryContent) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(archiveBytes) + })) + defer srv.Close() + + r, err := downloadAndExtractFrom(t.Context(), srv.Client(), srv.URL, checksum) + g.Expect(err).NotTo(HaveOccurred()) + + var got bytes.Buffer + _, _ = got.ReadFrom(r) + g.Expect(got.Bytes()).To(Equal(binaryContent)) +} + +func TestDownloadAndExtract_ChecksumMismatch(t *testing.T) { + g := NewWithT(t) + + archiveBytes, _ := buildTestArchive(t, []byte("content")) + wrongChecksum := make([]byte, 32) // all zeros + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(archiveBytes) + })) + defer srv.Close() + + _, err := downloadAndExtractFrom(t.Context(), srv.Client(), srv.URL, wrongChecksum) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("checksum mismatch")) +} + +func TestDownloadAndExtract_BinaryNotFound(t *testing.T) { + g := NewWithT(t) + + // Build an archive without a "cloudctl" entry. + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + hdr := &tar.Header{Name: "other-binary", Mode: 0o755, Size: 4} + _ = tw.WriteHeader(hdr) + _, _ = tw.Write([]byte("data")) + _ = tw.Close() + _ = gz.Close() + archiveBytes := buf.Bytes() + digest := sha256.Sum256(archiveBytes) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(archiveBytes) + })) + defer srv.Close() + + _, err := downloadAndExtractFrom(t.Context(), srv.Client(), srv.URL, digest[:]) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("cloudctl")) +} + +func TestDownloadAndExtract_NonRegularEntry(t *testing.T) { + g := NewWithT(t) + + // Build an archive with a symlink entry named "cloudctl". + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + hdr := &tar.Header{ + Name: "cloudctl", + Typeflag: tar.TypeSymlink, + Linkname: "/usr/bin/evil", + } + _ = tw.WriteHeader(hdr) + _ = tw.Close() + _ = gz.Close() + archiveBytes := buf.Bytes() + digest := sha256.Sum256(archiveBytes) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(archiveBytes) + })) + defer srv.Close() + + _, err := downloadAndExtractFrom(t.Context(), srv.Client(), srv.URL, digest[:]) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("not a regular file")) +} + +func TestDownloadChecksum_InvalidLength(t *testing.T) { + g := NewWithT(t) + + // Only 16 bytes — too short for SHA-256. + shortDigest := make([]byte, 16) + hexStr := hex.EncodeToString(shortDigest) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(hexStr + "\n")) + })) + defer srv.Close() + + _, err := downloadChecksumFrom(t.Context(), srv.Client(), srv.URL) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("invalid checksum length")) +} + +// --- printer output tests --- + +func TestUpdateResult_PlainPrinter_UpToDate(t *testing.T) { + g := NewWithT(t) + + var buf strings.Builder + p := output.New(output.FormatText, false, &buf) + err := p.Print(output.UpdateResult{ + CurrentVersion: "v1.0.0", + LatestVersion: "v1.0.0", + Status: output.UpdateStatusUpToDate, + }) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(buf.String()).To(ContainSubstring("up to date")) +} + +func TestUpdateResult_PlainPrinter_Available(t *testing.T) { + g := NewWithT(t) + + var buf strings.Builder + p := output.New(output.FormatText, false, &buf) + err := p.Print(output.UpdateResult{ + CurrentVersion: "v1.0.0", + LatestVersion: "v1.1.0", + Status: output.UpdateStatusAvailable, + }) + g.Expect(err).NotTo(HaveOccurred()) + out := buf.String() + g.Expect(out).To(ContainSubstring("v1.0.0")) + g.Expect(out).To(ContainSubstring("v1.1.0")) +} + +func TestUpdateResult_PlainPrinter_Updated(t *testing.T) { + g := NewWithT(t) + + var buf strings.Builder + p := output.New(output.FormatText, false, &buf) + err := p.Print(output.UpdateResult{ + CurrentVersion: "v1.0.0", + LatestVersion: "v1.1.0", + Status: output.UpdateStatusUpdated, + }) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(buf.String()).To(ContainSubstring("->")) +} + +func TestUpdateResult_JSONPrinter(t *testing.T) { + g := NewWithT(t) + + var buf strings.Builder + p := output.New(output.FormatJSON, false, &buf) + err := p.Print(output.UpdateResult{ + CurrentVersion: "v1.0.0", + LatestVersion: "v1.1.0", + Status: output.UpdateStatusAvailable, + }) + g.Expect(err).NotTo(HaveOccurred()) + + var got output.UpdateResult + g.Expect(json.Unmarshal([]byte(buf.String()), &got)).To(Succeed()) + g.Expect(got.CurrentVersion).To(Equal("v1.0.0")) + g.Expect(got.LatestVersion).To(Equal("v1.1.0")) + g.Expect(got.Status).To(Equal(output.UpdateStatusAvailable)) +} diff --git a/go.mod b/go.mod index b077e77..3429418 100644 --- a/go.mod +++ b/go.mod @@ -7,9 +7,11 @@ require ( github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 github.com/cloudoperators/greenhouse v0.8.0 + github.com/minio/selfupdate v0.6.0 github.com/onsi/gomega v1.38.3 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 + golang.org/x/mod v0.38.0 golang.org/x/term v0.43.0 k8s.io/apimachinery v0.35.0 k8s.io/client-go v0.35.0 @@ -31,6 +33,7 @@ replace ( ) require ( + aead.dev/minisign v0.2.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect @@ -93,6 +96,7 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.51.0 // indirect golang.org/x/net v0.55.0 // indirect golang.org/x/oauth2 v0.34.0 // indirect golang.org/x/sys v0.45.0 // indirect @@ -100,7 +104,6 @@ require ( golang.org/x/time v0.14.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect google.golang.org/protobuf v1.36.11 // indirect - gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect k8s.io/api v0.35.0 // indirect k8s.io/apiextensions-apiserver v0.35.0 // indirect diff --git a/go.sum b/go.sum index a38eec2..efab2a3 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +aead.dev/minisign v0.2.0 h1:kAWrq/hBRu4AARY6AlciO83xhNnW9UaC8YipS2uhLPk= +aead.dev/minisign v0.2.0/go.mod h1:zdq6LdSd9TbuSxchxwhpA9zEb9YXcVGoE8JakuiGaIQ= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= @@ -143,6 +145,8 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU= +github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -222,28 +226,46 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/exp v0.0.0-20251017212417-90e834f514db h1:by6IehL4BH5k3e3SJmcoNbOobMey2SLpAF79iPOEBvw= golang.org/x/exp v0.0.0-20251017212417-90e834f514db/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= -golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210228012217-479acdf4ea46/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=