From 7cff88455bfda9ed1eb1c9ae37360893c6131a26 Mon Sep 17 00:00:00 2001
From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com>
Date: Wed, 11 Feb 2026 09:55:26 +0100
Subject: [PATCH 01/17] feat: add better logs and formats for the download
---
internal/portal/http.go | 4 +-
internal/portal/portal.go | 33 +++++++++++++++-
internal/portal/write_counter.go | 67 +++++++++++++++++++++++++++++++-
3 files changed, 99 insertions(+), 5 deletions(-)
diff --git a/internal/portal/http.go b/internal/portal/http.go
index 4f91a573..9c475976 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..8ae29e88 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{
+ Timeout: 10 * time.Minute,
+ Transport: &http.Transport{
+ DialContext: (&net.Dialer{
+ Timeout: 30 * time.Second,
+ KeepAlive: 30 * time.Second,
+ }).DialContext,
+ TLSHandshakeTimeout: 30 * time.Second,
+ ResponseHeaderTimeout: 60 * time.Second,
+ ExpectContinueTimeout: 1 * time.Second,
+ IdleConnTimeout: 90 * time.Second,
+ MaxIdleConns: 100,
+ MaxIdleConnsPerHost: 10,
+ },
}
}
@@ -130,6 +150,7 @@ func (c *PortalClient) GetBody(path string) (body []byte, status int, err error)
// ListBuilds retrieves the list of available builds for the specified product.
func (c *PortalClient) ListBuilds(product Product) (availablePackages Builds, err error) {
+ log.Printf("Fetching available %s packages from portal...", product)
res, _, err := c.GetBody(fmt.Sprintf("/packages/%s", product))
if err != nil {
err = fmt.Errorf("failed to list packages: %w", err)
@@ -218,11 +239,19 @@ func (c *PortalClient) DownloadBuildArtifact(product Product, build Build, file
}
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)
diff --git a/internal/portal/write_counter.go b/internal/portal/write_counter.go
index f458a076..1bd3d550 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,39 @@ 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 := wc.StartBytes + wc.Written
+ percentage := float64(currentTotal) / float64(wc.Total) * 100
+ elapsed := time.Since(wc.StartTime)
+ speed := float64(wc.Written) / elapsed.Seconds()
+
+ var eta string
+ if speed > 0 {
+ remaining := wc.Total - currentTotal
+ etaSeconds := float64(remaining) / speed
+ eta = formatDuration(time.Duration(etaSeconds) * time.Second)
+ } 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)
+ 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)
}
@@ -67,3 +114,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)
+}
From c32d95490b91d045d7f9ddf0f8461e147e4270c8 Mon Sep 17 00:00:00 2001
From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com>
Date: Wed, 11 Feb 2026 13:04:34 +0100
Subject: [PATCH 02/17] feat: enhance download handling with retry logic and
improved error logging
---
internal/portal/portal.go | 106 ++++++++++++++++++++++++++++++--------
1 file changed, 84 insertions(+), 22 deletions(-)
diff --git a/internal/portal/portal.go b/internal/portal/portal.go
index 8ae29e88..2318ac6f 100644
--- a/internal/portal/portal.go
+++ b/internal/portal/portal.go
@@ -60,7 +60,7 @@ func newConfiguredHttpClient() *http.Client {
KeepAlive: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: 30 * time.Second,
- ResponseHeaderTimeout: 60 * time.Second,
+ ResponseHeaderTimeout: 2 * time.Minute,
ExpectContinueTimeout: 1 * time.Second,
IdleConnTimeout: 90 * time.Second,
MaxIdleConns: 100,
@@ -76,6 +76,27 @@ 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
+ if strings.HasPrefix(strings.TrimSpace(body), ""); idx != -1 {
+ endIdx := strings.Index(body[idx:], "")
+ if endIdx != -1 {
+ title := body[idx+7 : 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()
@@ -85,6 +106,7 @@ func (c *PortalClient) AuthorizedHttpRequest(req *http.Request) (resp *http.Resp
}
req.Header.Set("X-API-Key", apiKey)
+ req.Header.Set("Accept", "application/json")
resp, err = c.HttpClient.Do(req)
if err != nil {
@@ -101,8 +123,10 @@ func (c *PortalClient) AuthorizedHttpRequest(req *http.Request) (resp *http.Resp
if resp.Body != nil {
respBody, _ = io.ReadAll(resp.Body)
}
- 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
}
@@ -126,6 +150,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)
}
@@ -151,30 +176,64 @@ func (c *PortalClient) GetBody(path string) (body []byte, status int, err error)
// ListBuilds retrieves the list of available builds for the specified product.
func (c *PortalClient) ListBuilds(product Product) (availablePackages Builds, err error) {
log.Printf("Fetching available %s packages from portal...", product)
- res, _, err := c.GetBody(fmt.Sprintf("/packages/%s", product))
- if err != nil {
- err = fmt.Errorf("failed to list packages: %w", err)
- return
- }
- err = json.Unmarshal(res, &availablePackages)
- if err != nil {
- err = fmt.Errorf("failed to parse list packages response: %w", err)
- return
- }
+ // Retry logic for cold-starting servers
+ maxRetries := 5
+ retryDelay := 10 * time.Second
+
+ for attempt := 1; attempt <= maxRetries; attempt++ {
+ res, status, err := c.GetBody(fmt.Sprintf("/packages/%s", product))
- compareBuilds := func(l, r Build) int {
- if l.Date.Before(r.Date) {
- return -1
+ shouldRetry := false
+ if err != nil {
+ if strings.Contains(err.Error(), "timeout") || strings.Contains(err.Error(), "connection") {
+ shouldRetry = true
+ }
+ } else if status >= 500 && status < 600 {
+ shouldRetry = true
}
- if l.Date.Equal(r.Date) && l.Internal == r.Internal {
- return 0
+
+ if !shouldRetry {
+ if err != nil {
+ return availablePackages, fmt.Errorf("failed to list packages: %w", err)
+ }
+
+ err = json.Unmarshal(res, &availablePackages)
+ if err != nil {
+ return availablePackages, fmt.Errorf("failed to parse list packages response: %w", err)
+ }
+
+ compareBuilds := func(l, r Build) int {
+ if l.Date.Before(r.Date) {
+ return -1
+ }
+ if l.Date.Equal(r.Date) && l.Internal == r.Internal {
+ return 0
+ }
+ return 1
+ }
+ slices.SortFunc(availablePackages.Builds, compareBuilds)
+
+ return availablePackages, nil
+ }
+
+ if attempt < maxRetries {
+ if strings.Contains(err.Error(), "timeout") {
+ log.Printf("Request timed out (attempt %d/%d). Server may be starting up, waiting %v before retry...", attempt, maxRetries, retryDelay)
+ } else {
+ log.Printf("Request failed (attempt %d/%d), retrying in %v...", attempt, maxRetries, retryDelay)
+ }
+ time.Sleep(retryDelay)
+ retryDelay = time.Duration(float64(retryDelay) * 1.5) // Gradual backoff
+ } else {
+ if err != nil {
+ return availablePackages, fmt.Errorf("failed to list packages after %d attempts: %w", maxRetries, err)
+ }
+ return availablePackages, fmt.Errorf("failed to list packages after %d attempts: received status %d", maxRetries, status)
}
- return 1
}
- slices.SortFunc(availablePackages.Builds, compareBuilds)
- return
+ return availablePackages, fmt.Errorf("failed to list packages: max retries exceeded")
}
// GetBuild retrieves a specific build for the given product, version, and hash.
@@ -233,6 +292,7 @@ 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)
@@ -405,6 +465,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 {
@@ -414,7 +475,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 {
From 2d875401e117a219286f19ec126945215e0c34b5 Mon Sep 17 00:00:00 2001
From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com>
Date: Thu, 12 Feb 2026 12:30:33 +0100
Subject: [PATCH 03/17] feat: add retry logic and exponential backoff for
download failures
---
cli/cmd/download_package.go | 66 +++++++++++++++++++++++++++----------
1 file changed, 48 insertions(+), 18 deletions(-)
diff --git a/cli/cmd/download_package.go b/cli/cmd/download_package.go
index 9f57661e..e24a7b62 100644
--- a/cli/cmd/download_package.go
+++ b/cli/cmd/download_package.go
@@ -7,6 +7,7 @@ import (
"fmt"
"log"
"strings"
+ "time"
"github.com/codesphere-cloud/cs-go/pkg/io"
"github.com/spf13/cobra"
@@ -24,10 +25,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 {
@@ -89,6 +91,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 +104,52 @@ 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("codesphere", download, out, fileSize, c.Opts.Quiet)
+ util.CloseFileIgnoreError(out)
+
+ if downloadErr == nil {
+ break
+ }
+
+ // Determine if error is retryable
+ shouldRetry := strings.Contains(downloadErr.Error(), "timeout") ||
+ strings.Contains(downloadErr.Error(), "connection") ||
+ strings.Contains(downloadErr.Error(), "EOF") ||
+ strings.Contains(downloadErr.Error(), "reset by peer") ||
+ strings.Contains(downloadErr.Error(), "temporary failure")
+
+ 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)
From 98350c98695f2f3040bfcb43f6eb4d99ccd77e0f Mon Sep 17 00:00:00 2001
From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com>
Date: Thu, 12 Feb 2026 11:32:49 +0000
Subject: [PATCH 04/17] chore(docs): Auto-update docs and licenses
Signed-off-by: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com>
---
docs/oms-cli_download_package.md | 11 ++++++-----
docs/oms-cli_update_package.md | 11 ++++++-----
2 files changed, 12 insertions(+), 10 deletions(-)
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
From 86e43146233602094b316e0f374459f3b3603400 Mon Sep 17 00:00:00 2001
From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com>
Date: Thu, 12 Feb 2026 12:37:09 +0100
Subject: [PATCH 05/17] fix: reset ListBuilds
---
internal/portal/portal.go | 71 ++++++++++-----------------------------
1 file changed, 18 insertions(+), 53 deletions(-)
diff --git a/internal/portal/portal.go b/internal/portal/portal.go
index 2318ac6f..df6f07a3 100644
--- a/internal/portal/portal.go
+++ b/internal/portal/portal.go
@@ -175,65 +175,30 @@ func (c *PortalClient) GetBody(path string) (body []byte, status int, err error)
// ListBuilds retrieves the list of available builds for the specified product.
func (c *PortalClient) ListBuilds(product Product) (availablePackages Builds, err error) {
- log.Printf("Fetching available %s packages from portal...", product)
-
- // Retry logic for cold-starting servers
- maxRetries := 5
- retryDelay := 10 * time.Second
-
- for attempt := 1; attempt <= maxRetries; attempt++ {
- res, status, err := c.GetBody(fmt.Sprintf("/packages/%s", product))
-
- shouldRetry := false
- if err != nil {
- if strings.Contains(err.Error(), "timeout") || strings.Contains(err.Error(), "connection") {
- shouldRetry = true
- }
- } else if status >= 500 && status < 600 {
- shouldRetry = true
- }
-
- if !shouldRetry {
- if err != nil {
- return availablePackages, fmt.Errorf("failed to list packages: %w", err)
- }
-
- err = json.Unmarshal(res, &availablePackages)
- if err != nil {
- return availablePackages, fmt.Errorf("failed to parse list packages response: %w", err)
- }
+ res, _, err := c.GetBody(fmt.Sprintf("/packages/%s", product))
+ if err != nil {
+ err = fmt.Errorf("failed to list packages: %w", err)
+ return
+ }
- compareBuilds := func(l, r Build) int {
- if l.Date.Before(r.Date) {
- return -1
- }
- if l.Date.Equal(r.Date) && l.Internal == r.Internal {
- return 0
- }
- return 1
- }
- slices.SortFunc(availablePackages.Builds, compareBuilds)
+ err = json.Unmarshal(res, &availablePackages)
+ if err != nil {
+ err = fmt.Errorf("failed to parse list packages response: %w", err)
+ return
+ }
- return availablePackages, nil
+ compareBuilds := func(l, r Build) int {
+ if l.Date.Before(r.Date) {
+ return -1
}
-
- if attempt < maxRetries {
- if strings.Contains(err.Error(), "timeout") {
- log.Printf("Request timed out (attempt %d/%d). Server may be starting up, waiting %v before retry...", attempt, maxRetries, retryDelay)
- } else {
- log.Printf("Request failed (attempt %d/%d), retrying in %v...", attempt, maxRetries, retryDelay)
- }
- time.Sleep(retryDelay)
- retryDelay = time.Duration(float64(retryDelay) * 1.5) // Gradual backoff
- } else {
- if err != nil {
- return availablePackages, fmt.Errorf("failed to list packages after %d attempts: %w", maxRetries, err)
- }
- return availablePackages, fmt.Errorf("failed to list packages after %d attempts: received status %d", maxRetries, status)
+ if l.Date.Equal(r.Date) && l.Internal == r.Internal {
+ return 0
}
+ return 1
}
+ slices.SortFunc(availablePackages.Builds, compareBuilds)
- return availablePackages, fmt.Errorf("failed to list packages: max retries exceeded")
+ return
}
// GetBuild retrieves a specific build for the given product, version, and hash.
From 56d1263dc501ee042852d2f30dfd271b179e1ae9 Mon Sep 17 00:00:00 2001
From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com>
Date: Thu, 12 Feb 2026 13:00:14 +0100
Subject: [PATCH 06/17] feat: add tests
---
internal/portal/http.go | 2 +-
internal/portal/portal.go | 16 ++---
internal/portal/portal_test.go | 58 +++++++++++++++
internal/portal/write_counter.go | 20 +++---
internal/portal/write_counter_test.go | 100 ++++++++++++++++++++++++++
5 files changed, 177 insertions(+), 19 deletions(-)
diff --git a/internal/portal/http.go b/internal/portal/http.go
index 9c475976..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: newConfiguredHttpClient(),
+ HttpClient: NewConfiguredHttpClient(),
}
}
diff --git a/internal/portal/portal.go b/internal/portal/portal.go
index df6f07a3..08136d19 100644
--- a/internal/portal/portal.go
+++ b/internal/portal/portal.go
@@ -46,12 +46,12 @@ type HttpClient interface {
func NewPortalClient() *PortalClient {
return &PortalClient{
Env: env.NewEnv(),
- HttpClient: newConfiguredHttpClient(),
+ HttpClient: NewConfiguredHttpClient(),
}
}
-// newConfiguredHttpClient creates an HTTP client with proper timeouts
-func newConfiguredHttpClient() *http.Client {
+// NewConfiguredHttpClient creates an HTTP client with proper timeouts
+func NewConfiguredHttpClient() *http.Client {
return &http.Client{
Timeout: 10 * time.Minute,
Transport: &http.Transport{
@@ -76,8 +76,8 @@ const (
OmsProduct Product = "oms"
)
-// truncateHTMLResponse detects HTML responses and truncates them to avoid verbose error messages.
-func truncateHTMLResponse(body string) string {
+// TruncateHTMLResponse detects HTML responses and truncates them to avoid verbose error messages.
+func TruncateHTMLResponse(body string) string {
// Check if response looks like HTML
if strings.HasPrefix(strings.TrimSpace(body), " 0 {
- log.Printf("Starting download of %s...", byteCountToHumanReadable(resp.ContentLength))
+ log.Printf("Starting download of %s...", ByteCountToHumanReadable(resp.ContentLength))
}
// Create a WriteCounter to wrap the output file and report progress, unless quiet is requested.
@@ -440,7 +440,7 @@ func (c *PortalClient) GetApiKeyId(oldKey string) (string, error) {
if resp.StatusCode >= 300 {
respBody, _ := io.ReadAll(resp.Body)
- truncatedBody := truncateHTMLResponse(string(respBody))
+ truncatedBody := TruncateHTMLResponse(string(respBody))
return "", fmt.Errorf("unexpected response status: %d - %s, %s", resp.StatusCode, http.StatusText(resp.StatusCode), truncatedBody)
}
diff --git a/internal/portal/portal_test.go b/internal/portal/portal_test.go
index bff31b84..8adde793 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,60 @@ 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(10 * time.Minute))
+
+ transport, ok := client.Transport.(*http.Transport)
+ Expect(ok).To(BeTrue())
+ 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 1bd3d550..212692bf 100644
--- a/internal/portal/write_counter.go
+++ b/internal/portal/write_counter.go
@@ -64,24 +64,24 @@ func (wc *WriteCounter) Write(p []byte) (int, error) {
if speed > 0 {
remaining := wc.Total - currentTotal
etaSeconds := float64(remaining) / speed
- eta = formatDuration(time.Duration(etaSeconds) * time.Second)
+ eta = FormatDuration(time.Duration(etaSeconds) * time.Second)
} 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)),
+ ByteCountToHumanReadable(currentTotal),
+ ByteCountToHumanReadable(wc.Total),
+ ByteCountToHumanReadable(int64(speed)),
eta,
wc.animate())
} else {
elapsed := time.Since(wc.StartTime)
speed := float64(wc.Written) / elapsed.Seconds()
progress = fmt.Sprintf("\rDownloading... %s | Speed: %s/s %c \033[K",
- byteCountToHumanReadable(wc.Written),
- byteCountToHumanReadable(int64(speed)),
+ ByteCountToHumanReadable(wc.Written),
+ ByteCountToHumanReadable(int64(speed)),
wc.animate())
}
@@ -95,8 +95,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)
@@ -115,8 +115,8 @@ func (wc *WriteCounter) animate() byte {
return anim[wc.currentAnim]
}
-// formatDuration formats a duration in a human-readable format.
-func formatDuration(d time.Duration) string {
+// FormatDuration formats a duration in a human-readable format.
+func FormatDuration(d time.Duration) string {
if d < time.Second {
return "<1s"
}
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"),
+ )
})
From f8c48c7b18cb70953d1420ef006d2abd5ecd9d94 Mon Sep 17 00:00:00 2001
From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com>
Date: Thu, 12 Feb 2026 13:59:13 +0100
Subject: [PATCH 07/17] Update internal/portal/portal.go
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
internal/portal/portal.go | 1 +
1 file changed, 1 insertion(+)
diff --git a/internal/portal/portal.go b/internal/portal/portal.go
index 08136d19..40a58c74 100644
--- a/internal/portal/portal.go
+++ b/internal/portal/portal.go
@@ -122,6 +122,7 @@ 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()
}
truncatedBody := TruncateHTMLResponse(string(respBody))
log.Printf("Non-2xx response received - Status: %d", resp.StatusCode)
From 046768c014dea67bb00ea057cb9a836aaa015cbc Mon Sep 17 00:00:00 2001
From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com>
Date: Thu, 12 Feb 2026 14:00:09 +0100
Subject: [PATCH 08/17] Update internal/portal/portal.go
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
internal/portal/portal.go | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/internal/portal/portal.go b/internal/portal/portal.go
index 40a58c74..fe920488 100644
--- a/internal/portal/portal.go
+++ b/internal/portal/portal.go
@@ -106,7 +106,9 @@ func (c *PortalClient) AuthorizedHttpRequest(req *http.Request) (resp *http.Resp
}
req.Header.Set("X-API-Key", apiKey)
- req.Header.Set("Accept", "application/json")
+ if req.Header.Get("Accept") == "" {
+ req.Header.Set("Accept", "application/json")
+ }
resp, err = c.HttpClient.Do(req)
if err != nil {
From b0e4bef3cd96280dc6785cb4243843ff1a8ea04b Mon Sep 17 00:00:00 2001
From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com>
Date: Thu, 12 Feb 2026 14:00:43 +0100
Subject: [PATCH 09/17] Update cli/cmd/download_package.go
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
cli/cmd/download_package.go | 11 ++++++-----
1 file changed, 6 insertions(+), 5 deletions(-)
diff --git a/cli/cmd/download_package.go b/cli/cmd/download_package.go
index e24a7b62..8f962624 100644
--- a/cli/cmd/download_package.go
+++ b/cli/cmd/download_package.go
@@ -133,11 +133,12 @@ func (c *DownloadPackageCmd) DownloadBuild(p portal.Portal, build portal.Build,
}
// Determine if error is retryable
- shouldRetry := strings.Contains(downloadErr.Error(), "timeout") ||
- strings.Contains(downloadErr.Error(), "connection") ||
- strings.Contains(downloadErr.Error(), "EOF") ||
- strings.Contains(downloadErr.Error(), "reset by peer") ||
- strings.Contains(downloadErr.Error(), "temporary failure")
+ errMsg := strings.ToLower(downloadErr.Error())
+ shouldRetry := strings.Contains(errMsg, "timeout") ||
+ strings.Contains(errMsg, "connection") ||
+ strings.Contains(errMsg, "eof") ||
+ strings.Contains(errMsg, "reset by peer") ||
+ strings.Contains(errMsg, "temporary failure")
if !shouldRetry || attempt == maxRetries {
return fmt.Errorf("failed to download build after %d attempts: %w", attempt, downloadErr)
From e6bed3b3262378e9ceb232064fb2c955e50e94c0 Mon Sep 17 00:00:00 2001
From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com>
Date: Thu, 12 Feb 2026 14:01:37 +0100
Subject: [PATCH 10/17] Update internal/portal/portal.go
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
internal/portal/portal.go | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/internal/portal/portal.go b/internal/portal/portal.go
index fe920488..118a0e97 100644
--- a/internal/portal/portal.go
+++ b/internal/portal/portal.go
@@ -78,8 +78,10 @@ const (
// TruncateHTMLResponse detects HTML responses and truncates them to avoid verbose error messages.
func TruncateHTMLResponse(body string) string {
- // Check if response looks like HTML
- if strings.HasPrefix(strings.TrimSpace(body), ""); idx != -1 {
endIdx := strings.Index(body[idx:], "")
From b59ec9a7ee9cda59dca6553921fed6ffa95857d0 Mon Sep 17 00:00:00 2001
From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com>
Date: Thu, 12 Feb 2026 14:14:06 +0100
Subject: [PATCH 11/17] fix: enhance error handling and retry logic for
download failures
---
cli/cmd/download_package.go | 6 +++++-
internal/portal/portal.go | 2 +-
internal/portal/portal_test.go | 3 ++-
internal/portal/write_counter.go | 21 ++++++++++++++++-----
4 files changed, 24 insertions(+), 8 deletions(-)
diff --git a/cli/cmd/download_package.go b/cli/cmd/download_package.go
index 8f962624..e5330961 100644
--- a/cli/cmd/download_package.go
+++ b/cli/cmd/download_package.go
@@ -138,7 +138,11 @@ func (c *DownloadPackageCmd) DownloadBuild(p portal.Portal, build portal.Build,
strings.Contains(errMsg, "connection") ||
strings.Contains(errMsg, "eof") ||
strings.Contains(errMsg, "reset by peer") ||
- strings.Contains(errMsg, "temporary failure")
+ strings.Contains(errMsg, "temporary failure") ||
+ strings.Contains(errMsg, "no such host") ||
+ strings.Contains(errMsg, "dial tcp") ||
+ strings.Contains(errMsg, "network is unreachable") ||
+ strings.Contains(errMsg, "i/o timeout")
if !shouldRetry || attempt == maxRetries {
return fmt.Errorf("failed to download build after %d attempts: %w", attempt, downloadErr)
diff --git a/internal/portal/portal.go b/internal/portal/portal.go
index 118a0e97..13efe95f 100644
--- a/internal/portal/portal.go
+++ b/internal/portal/portal.go
@@ -53,8 +53,8 @@ func NewPortalClient() *PortalClient {
// NewConfiguredHttpClient creates an HTTP client with proper timeouts
func NewConfiguredHttpClient() *http.Client {
return &http.Client{
- Timeout: 10 * time.Minute,
Transport: &http.Transport{
+ Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
diff --git a/internal/portal/portal_test.go b/internal/portal/portal_test.go
index 8adde793..53623539 100644
--- a/internal/portal/portal_test.go
+++ b/internal/portal/portal_test.go
@@ -578,10 +578,11 @@ var _ = Describe("newConfiguredHttpClient", func() {
client := portal.NewConfiguredHttpClient()
Expect(client).NotTo(BeNil())
- Expect(client.Timeout).To(Equal(10 * time.Minute))
+ 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))
diff --git a/internal/portal/write_counter.go b/internal/portal/write_counter.go
index 212692bf..3ce05864 100644
--- a/internal/portal/write_counter.go
+++ b/internal/portal/write_counter.go
@@ -55,16 +55,23 @@ func (wc *WriteCounter) Write(p []byte) (int, error) {
if time.Since(wc.LastUpdate) >= 100*time.Millisecond {
var progress string
if wc.Total > 0 {
- currentTotal := wc.StartBytes + wc.Written
+ currentTotal := min(wc.StartBytes+wc.Written, wc.Total)
percentage := float64(currentTotal) / float64(wc.Total) * 100
elapsed := time.Since(wc.StartTime)
- speed := float64(wc.Written) / elapsed.Seconds()
+
+ // 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
- if speed > 0 {
- remaining := wc.Total - currentTotal
+ 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..."
}
@@ -78,7 +85,11 @@ func (wc *WriteCounter) Write(p []byte) (int, error) {
wc.animate())
} else {
elapsed := time.Since(wc.StartTime)
- speed := float64(wc.Written) / elapsed.Seconds()
+ // 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)),
From 6bb4809fbc0aaa46888a427b21ab026215ee71f6 Mon Sep 17 00:00:00 2001
From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com>
Date: Thu, 12 Feb 2026 14:16:08 +0100
Subject: [PATCH 12/17] fix: lint error
---
internal/portal/portal.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/internal/portal/portal.go b/internal/portal/portal.go
index 13efe95f..c160f6f2 100644
--- a/internal/portal/portal.go
+++ b/internal/portal/portal.go
@@ -126,7 +126,7 @@ 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()
+ _ = resp.Body.Close()
}
truncatedBody := TruncateHTMLResponse(string(respBody))
log.Printf("Non-2xx response received - Status: %d", resp.StatusCode)
From ef86d30efa14e662b57c01489075f08df8bc5341 Mon Sep 17 00:00:00 2001
From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com>
Date: Mon, 16 Feb 2026 09:26:04 +0100
Subject: [PATCH 13/17] Update cli/cmd/download_package.go
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
cli/cmd/download_package.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/cli/cmd/download_package.go b/cli/cmd/download_package.go
index e5330961..09373d5f 100644
--- a/cli/cmd/download_package.go
+++ b/cli/cmd/download_package.go
@@ -125,7 +125,7 @@ func (c *DownloadPackageCmd) DownloadBuild(p portal.Portal, build portal.Build,
fileSize = int(fileInfo.Size())
}
- downloadErr = p.DownloadBuildArtifact("codesphere", download, out, fileSize, c.Opts.Quiet)
+ downloadErr = p.DownloadBuildArtifact(portal.CodesphereProduct, download, out, fileSize, c.Opts.Quiet)
util.CloseFileIgnoreError(out)
if downloadErr == nil {
From c6ecbccb6143dc912f08e8762009f749de4ea680 Mon Sep 17 00:00:00 2001
From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com>
Date: Mon, 16 Feb 2026 09:26:25 +0100
Subject: [PATCH 14/17] Update internal/portal/portal.go
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
internal/portal/portal.go | 9 +++++----
1 file changed, 5 insertions(+), 4 deletions(-)
diff --git a/internal/portal/portal.go b/internal/portal/portal.go
index c160f6f2..fe6f81d9 100644
--- a/internal/portal/portal.go
+++ b/internal/portal/portal.go
@@ -82,11 +82,12 @@ func TruncateHTMLResponse(body string) string {
trimmed := strings.TrimSpace(body)
normalized := strings.ToLower(trimmed)
if strings.HasPrefix(normalized, ""); idx != -1 {
- endIdx := strings.Index(body[idx:], "")
+ // Extract title if present (case-insensitive on tag name)
+ lowerBody := strings.ToLower(body)
+ if idx := strings.Index(lowerBody, ""); idx != -1 {
+ endIdx := strings.Index(lowerBody[idx:], "")
if endIdx != -1 {
- title := body[idx+7 : idx+endIdx]
+ title := body[idx+len("") : idx+endIdx]
return fmt.Sprintf("Server says: %s", title)
}
}
From 34d9b92ac78751dcf358477c2a50522452af4a08 Mon Sep 17 00:00:00 2001
From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com>
Date: Mon, 16 Feb 2026 09:35:14 +0100
Subject: [PATCH 15/17] fix: improve retry logic for download errors with typed
error checking
---
cli/cmd/download_package.go | 69 +++++++++++++++++++++++++++++--------
1 file changed, 54 insertions(+), 15 deletions(-)
diff --git a/cli/cmd/download_package.go b/cli/cmd/download_package.go
index e5330961..69161b65 100644
--- a/cli/cmd/download_package.go
+++ b/cli/cmd/download_package.go
@@ -4,12 +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"
@@ -62,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"},
@@ -132,18 +137,7 @@ func (c *DownloadPackageCmd) DownloadBuild(p portal.Portal, build portal.Build,
break
}
- // Determine if error is retryable
- errMsg := strings.ToLower(downloadErr.Error())
- shouldRetry := strings.Contains(errMsg, "timeout") ||
- strings.Contains(errMsg, "connection") ||
- strings.Contains(errMsg, "eof") ||
- strings.Contains(errMsg, "reset by peer") ||
- strings.Contains(errMsg, "temporary failure") ||
- strings.Contains(errMsg, "no such host") ||
- strings.Contains(errMsg, "dial tcp") ||
- strings.Contains(errMsg, "network is unreachable") ||
- strings.Contains(errMsg, "i/o timeout")
-
+ shouldRetry := isRetryableError(downloadErr)
if !shouldRetry || attempt == maxRetries {
return fmt.Errorf("failed to download build after %d attempts: %w", attempt, downloadErr)
}
@@ -170,3 +164,48 @@ 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
+ if errors.As(err, &opErr) {
+ return true
+ }
+
+ return false
+}
From 8a44616fdb0a08adb78791c45d99eff0e988ab48 Mon Sep 17 00:00:00 2001
From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com>
Date: Mon, 16 Feb 2026 09:39:53 +0100
Subject: [PATCH 16/17] fix: simplify retryable error checking using typed
error assertion
---
cli/cmd/download_package.go | 6 +-----
1 file changed, 1 insertion(+), 5 deletions(-)
diff --git a/cli/cmd/download_package.go b/cli/cmd/download_package.go
index 15846519..678959a2 100644
--- a/cli/cmd/download_package.go
+++ b/cli/cmd/download_package.go
@@ -203,9 +203,5 @@ func isRetryableError(err error) bool {
}
var opErr *net.OpError
- if errors.As(err, &opErr) {
- return true
- }
-
- return false
+ return errors.As(err, &opErr)
}
From 714cb725b6383145f545759a2a1a0604207710bf Mon Sep 17 00:00:00 2001
From: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com>
Date: Mon, 16 Feb 2026 08:41:49 +0000
Subject: [PATCH 17/17] chore(docs): Auto-update docs and licenses
Signed-off-by: OliverTrautvetter <66372584+OliverTrautvetter@users.noreply.github.com>
---
NOTICE | 56 ++++++++++++++++++++++----------------------
internal/tmpl/NOTICE | 56 ++++++++++++++++++++++----------------------
2 files changed, 56 insertions(+), 56 deletions(-)
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/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