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