diff --git a/NOTICE b/NOTICE index 5e8f7c73..76f63a92 100644 --- a/NOTICE +++ b/NOTICE @@ -5,9 +5,9 @@ This project includes code licensed under the following terms: ---------- Module: cloud.google.com/go/artifactregistry -Version: v1.19.0 +Version: v1.20.0 License: Apache-2.0 -License URL: https://github.com/googleapis/google-cloud-go/blob/artifactregistry/v1.19.0/artifactregistry/LICENSE +License URL: https://github.com/googleapis/google-cloud-go/blob/artifactregistry/v1.20.0/artifactregistry/LICENSE ---------- Module: cloud.google.com/go/auth @@ -95,9 +95,9 @@ License URL: https://github.com/clipperhouse/uax29/blob/v2.4.0/LICENSE ---------- Module: github.com/codesphere-cloud/cs-go -Version: v0.16.4 +Version: v0.17.0 License: Apache-2.0 -License URL: https://github.com/codesphere-cloud/cs-go/blob/v0.16.4/LICENSE +License URL: https://github.com/codesphere-cloud/cs-go/blob/v0.17.0/LICENSE ---------- Module: github.com/codesphere-cloud/oms/internal/tmpl @@ -179,9 +179,9 @@ License URL: https://github.com/googleapis/enterprise-certificate-proxy/blob/v0. ---------- Module: github.com/googleapis/gax-go/v2 -Version: v2.16.0 +Version: v2.17.0 License: BSD-3-Clause -License URL: https://github.com/googleapis/gax-go/blob/v2.16.0/v2/LICENSE +License URL: https://github.com/googleapis/gax-go/blob/v2.17.0/v2/LICENSE ---------- Module: github.com/hashicorp/go-cleanhttp @@ -353,21 +353,21 @@ License URL: https://github.com/yaml/go-yaml/blob/v3.0.4/LICENSE ---------- Module: golang.org/x/crypto -Version: v0.47.0 +Version: v0.48.0 License: BSD-3-Clause -License URL: https://cs.opensource.google/go/x/crypto/+/v0.47.0:LICENSE +License URL: https://cs.opensource.google/go/x/crypto/+/v0.48.0:LICENSE ---------- Module: golang.org/x/net -Version: v0.49.0 +Version: v0.50.0 License: BSD-3-Clause -License URL: https://cs.opensource.google/go/x/net/+/v0.49.0:LICENSE +License URL: https://cs.opensource.google/go/x/net/+/v0.50.0:LICENSE ---------- Module: golang.org/x/oauth2 -Version: v0.34.0 +Version: v0.35.0 License: BSD-3-Clause -License URL: https://cs.opensource.google/go/x/oauth2/+/v0.34.0:LICENSE +License URL: https://cs.opensource.google/go/x/oauth2/+/v0.35.0:LICENSE ---------- Module: golang.org/x/sync/semaphore @@ -377,21 +377,21 @@ License URL: https://cs.opensource.google/go/x/sync/+/v0.19.0:LICENSE ---------- Module: golang.org/x/sys -Version: v0.40.0 +Version: v0.41.0 License: BSD-3-Clause -License URL: https://cs.opensource.google/go/x/sys/+/v0.40.0:LICENSE +License URL: https://cs.opensource.google/go/x/sys/+/v0.41.0:LICENSE ---------- Module: golang.org/x/term -Version: v0.39.0 +Version: v0.40.0 License: BSD-3-Clause -License URL: https://cs.opensource.google/go/x/term/+/v0.39.0:LICENSE +License URL: https://cs.opensource.google/go/x/term/+/v0.40.0:LICENSE ---------- Module: golang.org/x/text -Version: v0.33.0 +Version: v0.34.0 License: BSD-3-Clause -License URL: https://cs.opensource.google/go/x/text/+/v0.33.0:LICENSE +License URL: https://cs.opensource.google/go/x/text/+/v0.34.0:LICENSE ---------- Module: golang.org/x/time/rate @@ -401,15 +401,15 @@ License URL: https://cs.opensource.google/go/x/time/+/v0.14.0:LICENSE ---------- Module: google.golang.org/api -Version: v0.264.0 +Version: v0.266.0 License: BSD-3-Clause -License URL: https://github.com/googleapis/google-api-go-client/blob/v0.264.0/LICENSE +License URL: https://github.com/googleapis/google-api-go-client/blob/v0.266.0/LICENSE ---------- Module: google.golang.org/api/internal/third_party/uritemplates -Version: v0.264.0 +Version: v0.266.0 License: BSD-3-Clause -License URL: https://github.com/googleapis/google-api-go-client/blob/v0.264.0/internal/third_party/uritemplates/LICENSE +License URL: https://github.com/googleapis/google-api-go-client/blob/v0.266.0/internal/third_party/uritemplates/LICENSE ---------- Module: google.golang.org/genproto/googleapis @@ -419,21 +419,21 @@ License URL: https://github.com/googleapis/go-genproto/blob/8636f8732409/LICENSE ---------- Module: google.golang.org/genproto/googleapis/api -Version: v0.0.0-20260128011058-8636f8732409 +Version: v0.0.0-20260203192932-546029d2fa20 License: Apache-2.0 -License URL: https://github.com/googleapis/go-genproto/blob/8636f8732409/googleapis/api/LICENSE +License URL: https://github.com/googleapis/go-genproto/blob/546029d2fa20/googleapis/api/LICENSE ---------- Module: google.golang.org/genproto/googleapis/rpc -Version: v0.0.0-20260128011058-8636f8732409 +Version: v0.0.0-20260203192932-546029d2fa20 License: Apache-2.0 -License URL: https://github.com/googleapis/go-genproto/blob/8636f8732409/googleapis/rpc/LICENSE +License URL: https://github.com/googleapis/go-genproto/blob/546029d2fa20/googleapis/rpc/LICENSE ---------- Module: google.golang.org/grpc -Version: v1.78.0 +Version: v1.79.1 License: Apache-2.0 -License URL: https://github.com/grpc/grpc-go/blob/v1.78.0/LICENSE +License URL: https://github.com/grpc/grpc-go/blob/v1.79.1/LICENSE ---------- Module: google.golang.org/protobuf diff --git a/cli/cmd/download_package.go b/cli/cmd/download_package.go index 9f57661e..678959a2 100644 --- a/cli/cmd/download_package.go +++ b/cli/cmd/download_package.go @@ -4,11 +4,17 @@ package cmd import ( + "context" + "errors" "fmt" + "io" "log" + "net" "strings" + "syscall" + "time" - "github.com/codesphere-cloud/cs-go/pkg/io" + csio "github.com/codesphere-cloud/cs-go/pkg/io" "github.com/spf13/cobra" "github.com/codesphere-cloud/oms/internal/portal" @@ -24,10 +30,11 @@ type DownloadPackageCmd struct { type DownloadPackageOpts struct { *GlobalOptions - Version string - Hash string - Filename string - Quiet bool + Version string + Hash string + Filename string + Quiet bool + MaxRetries int } func (c *DownloadPackageCmd) RunE(_ *cobra.Command, args []string) error { @@ -60,9 +67,9 @@ func AddDownloadPackageCmd(download *cobra.Command, opts *GlobalOptions) { cmd: &cobra.Command{ Use: "package [VERSION]", Short: "Download a codesphere package", - Long: io.Long(`Download a specific version of a Codesphere package + Long: csio.Long(`Download a specific version of a Codesphere package To list available packages, run oms list packages.`), - Example: formatExamplesWithBinary("download package", []io.Example{ + Example: formatExamplesWithBinary("download package", []csio.Example{ {Cmd: "codesphere-v1.55.0", Desc: "Download Codesphere version 1.55.0"}, {Cmd: "--version codesphere-v1.55.0", Desc: "Download Codesphere version 1.55.0"}, {Cmd: "--version codesphere-v1.55.0 --file installer-lite.tar.gz", Desc: "Download lite package of Codesphere version 1.55.0"}, @@ -89,6 +96,7 @@ func AddDownloadPackageCmd(download *cobra.Command, opts *GlobalOptions) { pkg.cmd.Flags().StringVarP(&pkg.Opts.Hash, "hash", "H", "", "Hash of the version to download if multiple builds exist for the same version") pkg.cmd.Flags().StringVarP(&pkg.Opts.Filename, "file", "f", "installer.tar.gz", "Specify artifact to download") pkg.cmd.Flags().BoolVarP(&pkg.Opts.Quiet, "quiet", "q", false, "Suppress progress output during download") + pkg.cmd.Flags().IntVarP(&pkg.Opts.MaxRetries, "max-retries", "r", 5, "Maximum number of download retry attempts") download.AddCommand(pkg.cmd) pkg.cmd.RunE = pkg.RunE @@ -101,25 +109,46 @@ func (c *DownloadPackageCmd) DownloadBuild(p portal.Portal, build portal.Build, } fullFilename := strings.ReplaceAll(build.Version, "/", "-") + "-" + filename - out, err := c.FileWriter.OpenAppend(fullFilename) - if err != nil { - out, err = c.FileWriter.Create(fullFilename) + + maxRetries := max(c.Opts.MaxRetries, 1) + retryDelay := 5 * time.Second + + var downloadErr error + for attempt := 1; attempt <= maxRetries; attempt++ { + out, err := c.FileWriter.OpenAppend(fullFilename) if err != nil { - return fmt.Errorf("failed to create file %s: %w", fullFilename, err) + out, err = c.FileWriter.Create(fullFilename) + if err != nil { + return fmt.Errorf("failed to create file %s: %w", fullFilename, err) + } } - } - defer util.CloseFileIgnoreError(out) - // get already downloaded file size of fullFilename - fileSize := 0 - fileInfo, err := out.Stat() - if err == nil { - fileSize = int(fileInfo.Size()) + // Get current file size for resume functionality + fileSize := 0 + fileInfo, err := out.Stat() + if err == nil { + fileSize = int(fileInfo.Size()) + } + + downloadErr = p.DownloadBuildArtifact(portal.CodesphereProduct, download, out, fileSize, c.Opts.Quiet) + util.CloseFileIgnoreError(out) + + if downloadErr == nil { + break + } + + shouldRetry := isRetryableError(downloadErr) + if !shouldRetry || attempt == maxRetries { + return fmt.Errorf("failed to download build after %d attempts: %w", attempt, downloadErr) + } + + log.Printf("Download interrupted (attempt %d/%d). Retrying in %v...", attempt, maxRetries, retryDelay) + time.Sleep(retryDelay) + retryDelay = time.Duration(float64(retryDelay) * 1.5) // Exponential backoff } - err = p.DownloadBuildArtifact("codesphere", download, out, fileSize, c.Opts.Quiet) - if err != nil { - return fmt.Errorf("failed to download build: %w", err) + if downloadErr != nil { + return fmt.Errorf("failed to download build: %w", downloadErr) } verifyFile, err := c.FileWriter.Open(fullFilename) @@ -135,3 +164,44 @@ func (c *DownloadPackageCmd) DownloadBuild(p portal.Portal, build portal.Build, return nil } + +// isRetryableError determines if an error is transient and worth retrying. +// It uses typed error checking rather than string matching for reliability. +func isRetryableError(err error) bool { + if err == nil { + return false + } + + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + return true + } + + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { + return true + } + + var netErr net.Error + if errors.As(err, &netErr) { + if netErr.Timeout() { + return true + } + } + + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) { + return true + } + + if errors.Is(err, syscall.ECONNRESET) || // connection reset by peer + errors.Is(err, syscall.ECONNREFUSED) || // connection refused + errors.Is(err, syscall.ECONNABORTED) || // connection aborted + errors.Is(err, syscall.ENETUNREACH) || // network is unreachable + errors.Is(err, syscall.EHOSTUNREACH) || // host is unreachable + errors.Is(err, syscall.ETIMEDOUT) || // connection timed out + errors.Is(err, syscall.EPIPE) { // broken pipe + return true + } + + var opErr *net.OpError + return errors.As(err, &opErr) +} diff --git a/docs/oms-cli_download_package.md b/docs/oms-cli_download_package.md index d0ca18e1..eefc8361 100644 --- a/docs/oms-cli_download_package.md +++ b/docs/oms-cli_download_package.md @@ -28,11 +28,12 @@ $ oms-cli download package --version codesphere-v1.55.0 --file installer-lite.ta ### Options ``` - -f, --file string Specify artifact to download (default "installer.tar.gz") - -H, --hash string Hash of the version to download if multiple builds exist for the same version - -h, --help help for package - -q, --quiet Suppress progress output during download - -V, --version string Codesphere version to download + -f, --file string Specify artifact to download (default "installer.tar.gz") + -H, --hash string Hash of the version to download if multiple builds exist for the same version + -h, --help help for package + -r, --max-retries int Maximum number of download retry attempts (default 5) + -q, --quiet Suppress progress output during download + -V, --version string Codesphere version to download ``` ### SEE ALSO diff --git a/docs/oms-cli_update_package.md b/docs/oms-cli_update_package.md index 583a7e71..1f34829c 100644 --- a/docs/oms-cli_update_package.md +++ b/docs/oms-cli_update_package.md @@ -28,11 +28,12 @@ $ oms-cli download package --version codesphere-v1.55.0 --file installer-lite.ta ### Options ``` - -f, --file string Specify artifact to download (default "installer.tar.gz") - -H, --hash string Hash of the version to download if multiple builds exist for the same version - -h, --help help for package - -q, --quiet Suppress progress output during download - -V, --version string Codesphere version to download + -f, --file string Specify artifact to download (default "installer.tar.gz") + -H, --hash string Hash of the version to download if multiple builds exist for the same version + -h, --help help for package + -r, --max-retries int Maximum number of download retry attempts (default 5) + -q, --quiet Suppress progress output during download + -V, --version string Codesphere version to download ``` ### SEE ALSO diff --git a/internal/portal/http.go b/internal/portal/http.go index 4f91a573..a73493be 100644 --- a/internal/portal/http.go +++ b/internal/portal/http.go @@ -22,7 +22,7 @@ type HttpWrapper struct { func NewHttpWrapper() *HttpWrapper { return &HttpWrapper{ - HttpClient: http.DefaultClient, + HttpClient: NewConfiguredHttpClient(), } } @@ -77,7 +77,7 @@ func (c *HttpWrapper) Download(url string, file io.Writer, quiet bool) error { counter := file if !quiet { - counter = NewWriteCounter(file) + counter = NewWriteCounterWithTotal(file, resp.ContentLength, 0) } _, err = io.Copy(counter, resp.Body) diff --git a/internal/portal/portal.go b/internal/portal/portal.go index 0a430716..fe6f81d9 100644 --- a/internal/portal/portal.go +++ b/internal/portal/portal.go @@ -12,6 +12,7 @@ import ( "fmt" "io" "log" + "net" "net/http" "net/url" "slices" @@ -45,7 +46,26 @@ type HttpClient interface { func NewPortalClient() *PortalClient { return &PortalClient{ Env: env.NewEnv(), - HttpClient: http.DefaultClient, + HttpClient: NewConfiguredHttpClient(), + } +} + +// NewConfiguredHttpClient creates an HTTP client with proper timeouts +func NewConfiguredHttpClient() *http.Client { + return &http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + TLSHandshakeTimeout: 30 * time.Second, + ResponseHeaderTimeout: 2 * time.Minute, + ExpectContinueTimeout: 1 * time.Second, + IdleConnTimeout: 90 * time.Second, + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + }, } } @@ -56,6 +76,30 @@ const ( OmsProduct Product = "oms" ) +// TruncateHTMLResponse detects HTML responses and truncates them to avoid verbose error messages. +func TruncateHTMLResponse(body string) string { + // Check if response looks like HTML (case-insensitive) using a single trimmed value + trimmed := strings.TrimSpace(body) + normalized := strings.ToLower(trimmed) + if strings.HasPrefix(normalized, ""); idx != -1 { + endIdx := strings.Index(lowerBody[idx:], "") + if endIdx != -1 { + title := body[idx+len("") : idx+endIdx] + return fmt.Sprintf("Server says: %s", title) + } + } + return "Received HTML response instead of JSON" + } + + if len(body) <= 500 { + return body + } + return body[:500] + "... (truncated)" +} + // AuthorizedHttpRequest sends a HTTP request with the necessary authorization headers. func (c *PortalClient) AuthorizedHttpRequest(req *http.Request) (resp *http.Response, err error) { apiKey, err := c.Env.GetOmsPortalApiKey() @@ -65,6 +109,9 @@ func (c *PortalClient) AuthorizedHttpRequest(req *http.Request) (resp *http.Resp } req.Header.Set("X-API-Key", apiKey) + if req.Header.Get("Accept") == "" { + req.Header.Set("Accept", "application/json") + } resp, err = c.HttpClient.Do(req) if err != nil { @@ -80,9 +127,12 @@ func (c *PortalClient) AuthorizedHttpRequest(req *http.Request) (resp *http.Resp if resp.StatusCode >= 300 { if resp.Body != nil { respBody, _ = io.ReadAll(resp.Body) + _ = resp.Body.Close() } - log.Printf("Non-2xx response received - Status: %d, Body: %s", resp.StatusCode, string(respBody)) - err = fmt.Errorf("unexpected response status: %d - %s, %s", resp.StatusCode, http.StatusText(resp.StatusCode), string(respBody)) + truncatedBody := TruncateHTMLResponse(string(respBody)) + log.Printf("Non-2xx response received - Status: %d", resp.StatusCode) + log.Printf("%s", truncatedBody) + err = fmt.Errorf("unexpected response status: %d - %s (%s)", resp.StatusCode, http.StatusText(resp.StatusCode), truncatedBody) return } @@ -106,6 +156,7 @@ func (c *PortalClient) HttpRequest(method string, path string, body []byte) (res if len(body) > 0 { req.Header.Set("Content-Type", "application/json") } + req.Header.Set("Accept", "application/json") return c.AuthorizedHttpRequest(req) } @@ -212,17 +263,26 @@ func (c *PortalClient) DownloadBuildArtifact(product Product, build Build, file // Download the file from startByte to allow resuming req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") resp, err := c.AuthorizedHttpRequest(req) if err != nil { return fmt.Errorf("GET request to download build failed: %w", err) } defer func() { _ = resp.Body.Close() }() + if !quiet && resp.ContentLength > 0 { + log.Printf("Starting download of %s...", ByteCountToHumanReadable(resp.ContentLength)) + } + // Create a WriteCounter to wrap the output file and report progress, unless quiet is requested. // Default behavior: report progress. Quiet callers should pass true for quiet. counter := file if !quiet { - counter = NewWriteCounter(file) + totalSize := resp.ContentLength + if startByte > 0 && totalSize > 0 { + totalSize = totalSize + int64(startByte) + } + counter = NewWriteCounterWithTotal(file, totalSize, int64(startByte)) } _, err = io.Copy(counter, resp.Body) @@ -376,6 +436,7 @@ func (c *PortalClient) GetApiKeyId(oldKey string) (string, error) { } req.Header.Set("X-API-Key", oldKey) + req.Header.Set("Accept", "application/json") resp, err := c.HttpClient.Do(req) if err != nil { @@ -385,7 +446,8 @@ func (c *PortalClient) GetApiKeyId(oldKey string) (string, error) { if resp.StatusCode >= 300 { respBody, _ := io.ReadAll(resp.Body) - return "", fmt.Errorf("unexpected response status: %d - %s, %s", resp.StatusCode, http.StatusText(resp.StatusCode), string(respBody)) + truncatedBody := TruncateHTMLResponse(string(respBody)) + return "", fmt.Errorf("unexpected response status: %d - %s, %s", resp.StatusCode, http.StatusText(resp.StatusCode), truncatedBody) } var result struct { diff --git a/internal/portal/portal_test.go b/internal/portal/portal_test.go index bff31b84..53623539 100644 --- a/internal/portal/portal_test.go +++ b/internal/portal/portal_test.go @@ -16,6 +16,7 @@ import ( "net/url" "os" "path/filepath" + "strings" "time" "github.com/codesphere-cloud/oms/internal/env" @@ -532,3 +533,61 @@ var _ = Describe("PortalClient", func() { }) }) }) + +var _ = Describe("truncateHTMLResponse", func() { + It("returns short non-HTML response unchanged", func() { + body := `{"error": "not found"}` + result := portal.TruncateHTMLResponse(body) + Expect(result).To(Equal(body)) + }) + + It("truncates long non-HTML responses", func() { + body := strings.Repeat("a", 600) + result := portal.TruncateHTMLResponse(body) + Expect(result).To(HaveLen(500 + len("... (truncated)"))) + Expect(result).To(HaveSuffix("... (truncated)")) + }) + + It("extracts title from HTML response with DOCTYPE", func() { + body := `<!DOCTYPE html><html><head><title>502 Bad Gateway...` + result := portal.TruncateHTMLResponse(body) + Expect(result).To(Equal("Server says: 502 Bad Gateway")) + }) + + It("extracts title from HTML response starting with html tag", func() { + body := `Service Unavailable...` + result := portal.TruncateHTMLResponse(body) + Expect(result).To(Equal("Server says: Service Unavailable")) + }) + + It("handles HTML without title tag", func() { + body := `Error page` + result := portal.TruncateHTMLResponse(body) + Expect(result).To(Equal("Received HTML response instead of JSON")) + }) + + It("handles HTML with whitespace before DOCTYPE", func() { + body := ` Error` + result := portal.TruncateHTMLResponse(body) + Expect(result).To(Equal("Server says: Error")) + }) +}) + +var _ = Describe("newConfiguredHttpClient", func() { + It("creates an HTTP client with configured timeouts", func() { + client := portal.NewConfiguredHttpClient() + + Expect(client).NotTo(BeNil()) + Expect(client.Timeout).To(Equal(time.Duration(0))) + + transport, ok := client.Transport.(*http.Transport) + Expect(ok).To(BeTrue()) + Expect(transport.Proxy).NotTo(BeNil()) + Expect(transport.TLSHandshakeTimeout).To(Equal(30 * time.Second)) + Expect(transport.ResponseHeaderTimeout).To(Equal(2 * time.Minute)) + Expect(transport.ExpectContinueTimeout).To(Equal(1 * time.Second)) + Expect(transport.IdleConnTimeout).To(Equal(90 * time.Second)) + Expect(transport.MaxIdleConns).To(Equal(100)) + Expect(transport.MaxIdleConnsPerHost).To(Equal(10)) + }) +}) diff --git a/internal/portal/write_counter.go b/internal/portal/write_counter.go index f458a076..3ce05864 100644 --- a/internal/portal/write_counter.go +++ b/internal/portal/write_counter.go @@ -13,7 +13,10 @@ import ( // WriteCounter is a custom io.Writer that counts bytes written and logs progress. type WriteCounter struct { Written int64 + Total int64 + StartBytes int64 LastUpdate time.Time + StartTime time.Time Writer io.Writer currentAnim int } @@ -24,6 +27,18 @@ func NewWriteCounter(writer io.Writer) *WriteCounter { Writer: writer, // Initialize to zero so the first Write triggers an immediate log LastUpdate: time.Time{}, + StartTime: time.Now(), + } +} + +// NewWriteCounterWithTotal creates a new WriteCounter with known total size. +func NewWriteCounterWithTotal(writer io.Writer, total int64, startBytes int64) *WriteCounter { + return &WriteCounter{ + Writer: writer, + Total: total, + StartBytes: startBytes, + LastUpdate: time.Time{}, + StartTime: time.Now(), } } @@ -38,7 +53,50 @@ func (wc *WriteCounter) Write(p []byte) (int, error) { wc.Written += int64(n) if time.Since(wc.LastUpdate) >= 100*time.Millisecond { - _, err = fmt.Fprintf(log.Writer(), "\rDownloading... %s transferred %c \033[K", byteCountToHumanReadable(wc.Written), wc.animate()) + var progress string + if wc.Total > 0 { + currentTotal := min(wc.StartBytes+wc.Written, wc.Total) + percentage := float64(currentTotal) / float64(wc.Total) * 100 + elapsed := time.Since(wc.StartTime) + + // Guard against division by zero when elapsed is very small + var speed float64 + if elapsed.Seconds() > 0.001 { + speed = float64(wc.Written) / elapsed.Seconds() + } + + var eta string + remaining := wc.Total - currentTotal + if speed > 0 && remaining > 0 { + etaSeconds := float64(remaining) / speed + eta = FormatDuration(time.Duration(etaSeconds) * time.Second) + } else if remaining <= 0 { + eta = "done" + } else { + eta = "calculating..." + } + + progress = fmt.Sprintf("\rDownloading... %.1f%% (%s / %s) | Speed: %s/s | ETA: %s %c \033[K", + percentage, + ByteCountToHumanReadable(currentTotal), + ByteCountToHumanReadable(wc.Total), + ByteCountToHumanReadable(int64(speed)), + eta, + wc.animate()) + } else { + elapsed := time.Since(wc.StartTime) + // Guard against division by zero when elapsed is very small + var speed float64 + if elapsed.Seconds() > 0.001 { + speed = float64(wc.Written) / elapsed.Seconds() + } + progress = fmt.Sprintf("\rDownloading... %s | Speed: %s/s %c \033[K", + ByteCountToHumanReadable(wc.Written), + ByteCountToHumanReadable(int64(speed)), + wc.animate()) + } + + _, err = fmt.Fprint(log.Writer(), progress) if err != nil { log.Printf("error writing progress: %v", err) } @@ -48,8 +106,8 @@ func (wc *WriteCounter) Write(p []byte) (int, error) { return n, nil } -// byteCountToHumanReadable converts a byte count to a human-readable format (e.g., KB, MB, GB). -func byteCountToHumanReadable(b int64) string { +// ByteCountToHumanReadable converts a byte count to a human-readable format (e.g., KB, MB, GB). +func ByteCountToHumanReadable(b int64) string { const unit = 1024 if b < unit { return fmt.Sprintf("%d B", b) @@ -67,3 +125,21 @@ func (wc *WriteCounter) animate() byte { wc.currentAnim = (wc.currentAnim + 1) % len(anim) return anim[wc.currentAnim] } + +// FormatDuration formats a duration in a human-readable format. +func FormatDuration(d time.Duration) string { + if d < time.Second { + return "<1s" + } + if d < time.Minute { + return fmt.Sprintf("%.0fs", d.Seconds()) + } + if d < time.Hour { + minutes := int(d.Minutes()) + seconds := int(d.Seconds()) % 60 + return fmt.Sprintf("%dm%ds", minutes, seconds) + } + hours := int(d.Hours()) + minutes := int(d.Minutes()) % 60 + return fmt.Sprintf("%dh%dm", hours, minutes) +} diff --git a/internal/portal/write_counter_test.go b/internal/portal/write_counter_test.go index dad493b0..5b062fa0 100644 --- a/internal/portal/write_counter_test.go +++ b/internal/portal/write_counter_test.go @@ -34,4 +34,104 @@ var _ = Describe("WriteCounter", func() { out := logBuf.String() Expect(out).NotTo(BeEmpty()) }) + + Describe("NewWriteCounterWithTotal", func() { + It("creates a WriteCounter with total and start bytes", func() { + var underlying bytes.Buffer + wc := portal.NewWriteCounterWithTotal(&underlying, 1000, 100) + + Expect(wc.Total).To(Equal(int64(1000))) + Expect(wc.StartBytes).To(Equal(int64(100))) + Expect(wc.Written).To(Equal(int64(0))) + Expect(wc.Writer).To(Equal(&underlying)) + }) + + It("writes data correctly to underlying writer", func() { + var underlying bytes.Buffer + wc := portal.NewWriteCounterWithTotal(&underlying, 100, 0) + + data := []byte("test data") + n, err := wc.Write(data) + + Expect(err).NotTo(HaveOccurred()) + Expect(n).To(Equal(len(data))) + Expect(underlying.String()).To(Equal("test data")) + Expect(wc.Written).To(Equal(int64(len(data)))) + }) + + It("emits progress with percentage when total is known", func() { + var logBuf bytes.Buffer + prev := log.Writer() + log.SetOutput(&logBuf) + defer log.SetOutput(prev) + + var underlying bytes.Buffer + wc := portal.NewWriteCounterWithTotal(&underlying, 100, 0) + wc.LastUpdate = time.Now().Add(-time.Second) + + _, err := wc.Write([]byte("1234567890")) // 10 bytes of 100 total = 10% + Expect(err).NotTo(HaveOccurred()) + + out := logBuf.String() + Expect(out).To(ContainSubstring("%")) + Expect(out).To(ContainSubstring("ETA")) + }) + + It("handles resume downloads with start bytes offset", func() { + var logBuf bytes.Buffer + prev := log.Writer() + log.SetOutput(&logBuf) + defer log.SetOutput(prev) + + var underlying bytes.Buffer + // Total is 100 bytes, starting at 50 (50% already downloaded) + wc := portal.NewWriteCounterWithTotal(&underlying, 100, 50) + wc.LastUpdate = time.Now().Add(-time.Second) + + // Write 25 more bytes (should now be at 75%) + data := make([]byte, 25) + _, err := wc.Write(data) + Expect(err).NotTo(HaveOccurred()) + + out := logBuf.String() + Expect(out).To(ContainSubstring("75")) + }) + }) +}) + +var _ = Describe("formatDuration", func() { + DescribeTable("formats durations correctly", + func(d time.Duration, expected string) { + result := portal.FormatDuration(d) + Expect(result).To(Equal(expected)) + }, + Entry("less than a second", 500*time.Millisecond, "<1s"), + Entry("exactly one second", 1*time.Second, "1s"), + Entry("30 seconds", 30*time.Second, "30s"), + Entry("59 seconds", 59*time.Second, "59s"), + Entry("1 minute", 1*time.Minute, "1m0s"), + Entry("1 minute 30 seconds", 1*time.Minute+30*time.Second, "1m30s"), + Entry("5 minutes 45 seconds", 5*time.Minute+45*time.Second, "5m45s"), + Entry("59 minutes 59 seconds", 59*time.Minute+59*time.Second, "59m59s"), + Entry("1 hour", 1*time.Hour, "1h0m"), + Entry("1 hour 30 minutes", 1*time.Hour+30*time.Minute, "1h30m"), + Entry("2 hours 15 minutes", 2*time.Hour+15*time.Minute, "2h15m"), + ) +}) + +var _ = Describe("byteCountToHumanReadable", func() { + DescribeTable("converts bytes correctly", + func(bytes int64, expected string) { + result := portal.ByteCountToHumanReadable(bytes) + Expect(result).To(Equal(expected)) + }, + Entry("0 bytes", int64(0), "0 B"), + Entry("512 bytes", int64(512), "512 B"), + Entry("1023 bytes", int64(1023), "1023 B"), + Entry("1 KB", int64(1024), "1.0 KB"), + Entry("1.5 KB", int64(1536), "1.5 KB"), + Entry("1 MB", int64(1024*1024), "1.0 MB"), + Entry("1.5 MB", int64(1536*1024), "1.5 MB"), + Entry("1 GB", int64(1024*1024*1024), "1.0 GB"), + ) }) diff --git a/internal/tmpl/NOTICE b/internal/tmpl/NOTICE index 5e8f7c73..76f63a92 100644 --- a/internal/tmpl/NOTICE +++ b/internal/tmpl/NOTICE @@ -5,9 +5,9 @@ This project includes code licensed under the following terms: ---------- Module: cloud.google.com/go/artifactregistry -Version: v1.19.0 +Version: v1.20.0 License: Apache-2.0 -License URL: https://github.com/googleapis/google-cloud-go/blob/artifactregistry/v1.19.0/artifactregistry/LICENSE +License URL: https://github.com/googleapis/google-cloud-go/blob/artifactregistry/v1.20.0/artifactregistry/LICENSE ---------- Module: cloud.google.com/go/auth @@ -95,9 +95,9 @@ License URL: https://github.com/clipperhouse/uax29/blob/v2.4.0/LICENSE ---------- Module: github.com/codesphere-cloud/cs-go -Version: v0.16.4 +Version: v0.17.0 License: Apache-2.0 -License URL: https://github.com/codesphere-cloud/cs-go/blob/v0.16.4/LICENSE +License URL: https://github.com/codesphere-cloud/cs-go/blob/v0.17.0/LICENSE ---------- Module: github.com/codesphere-cloud/oms/internal/tmpl @@ -179,9 +179,9 @@ License URL: https://github.com/googleapis/enterprise-certificate-proxy/blob/v0. ---------- Module: github.com/googleapis/gax-go/v2 -Version: v2.16.0 +Version: v2.17.0 License: BSD-3-Clause -License URL: https://github.com/googleapis/gax-go/blob/v2.16.0/v2/LICENSE +License URL: https://github.com/googleapis/gax-go/blob/v2.17.0/v2/LICENSE ---------- Module: github.com/hashicorp/go-cleanhttp @@ -353,21 +353,21 @@ License URL: https://github.com/yaml/go-yaml/blob/v3.0.4/LICENSE ---------- Module: golang.org/x/crypto -Version: v0.47.0 +Version: v0.48.0 License: BSD-3-Clause -License URL: https://cs.opensource.google/go/x/crypto/+/v0.47.0:LICENSE +License URL: https://cs.opensource.google/go/x/crypto/+/v0.48.0:LICENSE ---------- Module: golang.org/x/net -Version: v0.49.0 +Version: v0.50.0 License: BSD-3-Clause -License URL: https://cs.opensource.google/go/x/net/+/v0.49.0:LICENSE +License URL: https://cs.opensource.google/go/x/net/+/v0.50.0:LICENSE ---------- Module: golang.org/x/oauth2 -Version: v0.34.0 +Version: v0.35.0 License: BSD-3-Clause -License URL: https://cs.opensource.google/go/x/oauth2/+/v0.34.0:LICENSE +License URL: https://cs.opensource.google/go/x/oauth2/+/v0.35.0:LICENSE ---------- Module: golang.org/x/sync/semaphore @@ -377,21 +377,21 @@ License URL: https://cs.opensource.google/go/x/sync/+/v0.19.0:LICENSE ---------- Module: golang.org/x/sys -Version: v0.40.0 +Version: v0.41.0 License: BSD-3-Clause -License URL: https://cs.opensource.google/go/x/sys/+/v0.40.0:LICENSE +License URL: https://cs.opensource.google/go/x/sys/+/v0.41.0:LICENSE ---------- Module: golang.org/x/term -Version: v0.39.0 +Version: v0.40.0 License: BSD-3-Clause -License URL: https://cs.opensource.google/go/x/term/+/v0.39.0:LICENSE +License URL: https://cs.opensource.google/go/x/term/+/v0.40.0:LICENSE ---------- Module: golang.org/x/text -Version: v0.33.0 +Version: v0.34.0 License: BSD-3-Clause -License URL: https://cs.opensource.google/go/x/text/+/v0.33.0:LICENSE +License URL: https://cs.opensource.google/go/x/text/+/v0.34.0:LICENSE ---------- Module: golang.org/x/time/rate @@ -401,15 +401,15 @@ License URL: https://cs.opensource.google/go/x/time/+/v0.14.0:LICENSE ---------- Module: google.golang.org/api -Version: v0.264.0 +Version: v0.266.0 License: BSD-3-Clause -License URL: https://github.com/googleapis/google-api-go-client/blob/v0.264.0/LICENSE +License URL: https://github.com/googleapis/google-api-go-client/blob/v0.266.0/LICENSE ---------- Module: google.golang.org/api/internal/third_party/uritemplates -Version: v0.264.0 +Version: v0.266.0 License: BSD-3-Clause -License URL: https://github.com/googleapis/google-api-go-client/blob/v0.264.0/internal/third_party/uritemplates/LICENSE +License URL: https://github.com/googleapis/google-api-go-client/blob/v0.266.0/internal/third_party/uritemplates/LICENSE ---------- Module: google.golang.org/genproto/googleapis @@ -419,21 +419,21 @@ License URL: https://github.com/googleapis/go-genproto/blob/8636f8732409/LICENSE ---------- Module: google.golang.org/genproto/googleapis/api -Version: v0.0.0-20260128011058-8636f8732409 +Version: v0.0.0-20260203192932-546029d2fa20 License: Apache-2.0 -License URL: https://github.com/googleapis/go-genproto/blob/8636f8732409/googleapis/api/LICENSE +License URL: https://github.com/googleapis/go-genproto/blob/546029d2fa20/googleapis/api/LICENSE ---------- Module: google.golang.org/genproto/googleapis/rpc -Version: v0.0.0-20260128011058-8636f8732409 +Version: v0.0.0-20260203192932-546029d2fa20 License: Apache-2.0 -License URL: https://github.com/googleapis/go-genproto/blob/8636f8732409/googleapis/rpc/LICENSE +License URL: https://github.com/googleapis/go-genproto/blob/546029d2fa20/googleapis/rpc/LICENSE ---------- Module: google.golang.org/grpc -Version: v1.78.0 +Version: v1.79.1 License: Apache-2.0 -License URL: https://github.com/grpc/grpc-go/blob/v1.78.0/LICENSE +License URL: https://github.com/grpc/grpc-go/blob/v1.79.1/LICENSE ---------- Module: google.golang.org/protobuf