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 := `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