From 76db84d0a0a2d561dedf54a868d5f428f20942c9 Mon Sep 17 00:00:00 2001 From: Marcin Owsiany Date: Thu, 9 Jul 2026 09:51:04 +0200 Subject: [PATCH 1/7] Drop roxctl dependency, use Central REST API for CRS generation Replace the roxctl subprocess call for generating Cluster Registration Secrets with a direct HTTP POST to Central's REST API endpoint (/v1/cluster-init/crs). This eliminates the requirement for users to install a matching roxctl binary. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/e2e-tests.yml | 10 -- README.md | 1 - internal/deployer/crs.go | 162 +++++++++++++++++++++++++++----- internal/deployer/deployer.go | 2 +- internal/deployer/roxctl.go | 114 ---------------------- 5 files changed, 141 insertions(+), 148 deletions(-) delete mode 100644 internal/deployer/roxctl.go diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index a5b00673..17bd9737 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -60,16 +60,6 @@ jobs: chmod +x /usr/local/bin/roxie roxie version - - name: Install roxctl - env: - ROXCTL_VERSION: "4.10.0" - ROXCTL_SHA256: "5db647b14569465866c0162522e83393ebf02f671f4556b1b3ed551b9f8433bc" - run: | - curl -fsSLo /usr/local/bin/roxctl \ - "https://mirror.openshift.com/pub/rhacs/assets/${ROXCTL_VERSION}/bin/Linux/roxctl" - echo "${ROXCTL_SHA256} /usr/local/bin/roxctl" | sha256sum -c - - chmod +x /usr/local/bin/roxctl - roxctl version - name: Authenticate to GCloud if: inputs.cluster-type == 'gke' diff --git a/README.md b/README.md index 7d773e57..ca5184c8 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,6 @@ the cluster to succeed. Prerequisites: - `kubectl` configured to point at your target cluster -- `roxctl` CLI is installed - `roxie` CLI is installed Deploy using: diff --git a/internal/deployer/crs.go b/internal/deployer/crs.go index f2bb9e9e..5d36c338 100644 --- a/internal/deployer/crs.go +++ b/internal/deployer/crs.go @@ -2,43 +2,161 @@ package deployer import ( "context" + "crypto/tls" + "crypto/x509" + "encoding/pem" + "encoding/json" "errors" "fmt" + "io" + "net/http" + "os" + "slices" "strings" + "time" "github.com/stackrox/roxie/internal/k8s" ) -// generateCRS generates the Central Resource Secret using roxctl +type crsGenRequest struct { + Name string `json:"name"` +} + +type crsGenResponse struct { + CRS []byte `json:"crs"` +} + +// generateCRS generates a Cluster Registration Secret via Central's REST API. func (d *Deployer) generateCRS(ctx context.Context, clusterName string) (string, error) { crsName := fmt.Sprintf("%s-crs", clusterName) - d.logger.Infof("Generating CRS named %q with roxctl...", crsName) - - result, err := d.runRoxctl(ctx, RoxctlOptions{ - Args: []string{ - "-e", d.centralEndpoint, - "central", - "crs", - "generate", - crsName, - "--output=-", // Output to stdout - }, - UseAuthentication: true, - MaxAttempts: 5, - RetryDelay: 10, - }) + d.logger.Infof("Generating CRS named %q via Central API...", crsName) + + const maxAttempts = 5 + const baseRetryDelay = 10 + retryableSubstrings := []string{ + "connection refused", + "connection reset", + "connection timed out", + "timed out", + "timeout", + "network is unreachable", + "temporary failure in name resolution", + "no route to host", + "tls handshake timeout", + "eof", + "bad gateway", + "service unavailable", + "context deadline exceeded", + "no such host", + } + + client, err := d.centralHTTPClient() if err != nil { - return "", fmt.Errorf("failed to generate CRS: %w", err) + return "", fmt.Errorf("failed to create HTTP client: %w", err) } - crsContent := strings.TrimSpace(result.Stdout) - if crsContent == "" { - return "", errors.New("CRS content is empty") + reqBody, err := json.Marshal(crsGenRequest{Name: crsName}) + if err != nil { + return "", fmt.Errorf("failed to marshal CRS request: %w", err) } - d.logger.Success("✓ CRS generated") - return crsContent, nil + url := fmt.Sprintf("https://%s/v1/cluster-init/crs", d.centralEndpoint) + + var lastErr error + for attempt := 1; attempt <= maxAttempts; attempt++ { + if attempt > 1 { + waitTime := time.Duration(attempt*baseRetryDelay) * time.Second + d.logger.Infof("Retrying CRS generation (attempt %d/%d) after %v...", attempt, maxAttempts, waitTime) + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(waitTime): + } + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(reqBody))) + if err != nil { + return "", fmt.Errorf("failed to create HTTP request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.SetBasicAuth(AdminUsername, d.centralPassword) + + resp, err := client.Do(req) + if err != nil { + lastErr = err + errLower := strings.ToLower(err.Error()) + if attempt < maxAttempts && slices.ContainsFunc(retryableSubstrings, func(sub string) bool { + return strings.Contains(errLower, sub) + }) { + d.logger.Warningf("Transient error generating CRS: %v", err) + continue + } + return "", fmt.Errorf("failed to generate CRS: %w", err) + } + + body, readErr := io.ReadAll(resp.Body) + resp.Body.Close() + if readErr != nil { + return "", fmt.Errorf("failed to read CRS response body: %w", readErr) + } + + if resp.StatusCode == http.StatusBadGateway || resp.StatusCode == http.StatusServiceUnavailable { + lastErr = fmt.Errorf("server returned %s", resp.Status) + if attempt < maxAttempts { + d.logger.Warningf("Transient HTTP error generating CRS: %s", resp.Status) + continue + } + return "", lastErr + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("CRS generation failed with status %s: %s", resp.Status, string(body)) + } + + var crsResp crsGenResponse + if err := json.Unmarshal(body, &crsResp); err != nil { + return "", fmt.Errorf("failed to parse CRS response: %w", err) + } + + crsContent := strings.TrimSpace(string(crsResp.CRS)) + if crsContent == "" { + return "", errors.New("CRS content is empty") + } + + d.logger.Success("✓ CRS generated") + return crsContent, nil + } + + return "", fmt.Errorf("CRS generation failed after %d attempts: %w", maxAttempts, lastErr) +} + +// centralHTTPClient returns an HTTP client configured for talking to Central. +func (d *Deployer) centralHTTPClient() (*http.Client, error) { + tlsConfig := &tls.Config{} + + if d.roxCACertFile != "" { + pemData, err := os.ReadFile(d.roxCACertFile) + if err != nil { + return nil, fmt.Errorf("reading CA cert file %s: %w", d.roxCACertFile, err) + } + pool := x509.NewCertPool() + for block, rest := pem.Decode(pemData); block != nil; block, rest = pem.Decode(rest) { + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, fmt.Errorf("parsing CA certificate from %s: %w", d.roxCACertFile, err) + } + pool.AddCert(cert) + } + tlsConfig.RootCAs = pool + } + + return &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: tlsConfig, + }, + }, nil } // applyCRS applies the CRS content to the sensor namespace diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go index 9cc3d8b7..77a1b93e 100644 --- a/internal/deployer/deployer.go +++ b/internal/deployer/deployer.go @@ -546,7 +546,7 @@ func (d *Deployer) waitForNamespaceDeletion(namespace string) error { // checkRequiredTools verifies that required CLI tools are available func checkRequiredTools() error { - requiredTools := []string{"kubectl", "roxctl"} + requiredTools := []string{"kubectl"} var missing []string for _, tool := range requiredTools { diff --git a/internal/deployer/roxctl.go b/internal/deployer/roxctl.go deleted file mode 100644 index 54390065..00000000 --- a/internal/deployer/roxctl.go +++ /dev/null @@ -1,114 +0,0 @@ -package deployer - -import ( - "bytes" - "context" - "fmt" - "os" - "os/exec" - "strings" - "time" -) - -// RoxctlOptions contains options for running roxctl commands -type RoxctlOptions struct { - Args []string // Command arguments (e.g., ["central", "crs", "generate", "cluster-name"]) - Env map[string]string // Additional environment variables - UseAuthentication bool // Whether to set ROX_ADMIN_PASSWORD and ROX_CA_CERT_FILE - MaxAttempts int // Maximum number of retry attempts (default: 5) - RetryDelay int // Base retry delay in seconds (default: 10, multiplied by attempt number) -} - -// RoxctlResult contains the result of a roxctl command execution -type RoxctlResult struct { - Stdout string - Stderr string -} - -// runRoxctl executes a roxctl command with automatic retries on transient errors -func (d *Deployer) runRoxctl(ctx context.Context, opts RoxctlOptions) (*RoxctlResult, error) { - if opts.MaxAttempts <= 0 { - opts.MaxAttempts = 5 - } - if opts.RetryDelay <= 0 { - opts.RetryDelay = 10 - } - - // List of transient/retryable errors - retryableErrors := []string{ - "connection refused", - "connection reset", - "connection timed out", - "timed out", - "timeout", - "network is unreachable", - "temporary failure in name resolution", - "no route to host", - "tls handshake timeout", - "eof", - "bad gateway", - "service unavailable", - "context deadline exceeded", - "no such host", - } - - var lastStderr string - var lastErr error - - for attempt := 1; attempt <= opts.MaxAttempts; attempt++ { - if attempt > 1 { - waitTime := time.Duration(attempt*opts.RetryDelay) * time.Second - d.logger.Infof("Retrying roxctl command (attempt %d/%d) after %v...", attempt, opts.MaxAttempts, waitTime) - time.Sleep(waitTime) - } - - cmd := exec.CommandContext(ctx, "roxctl", opts.Args...) - - cmd.Env = os.Environ() - - if opts.UseAuthentication { - if d.centralPassword != "" { - cmd.Env = append(cmd.Env, fmt.Sprintf("ROX_ADMIN_PASSWORD=%s", d.centralPassword)) - } - if d.roxCACertFile != "" { - cmd.Env = append(cmd.Env, fmt.Sprintf("ROX_CA_CERT_FILE=%s", d.roxCACertFile)) - } - } - - for k, v := range opts.Env { - cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", k, v)) - } - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - err := cmd.Run() - lastStderr = stderr.String() - lastErr = err - - if err == nil { - return &RoxctlResult{ - Stdout: stdout.String(), - Stderr: lastStderr, - }, nil - } - - isRetryable := false - for _, retryErr := range retryableErrors { - if strings.Contains(strings.ToLower(lastStderr), strings.ToLower(retryErr)) { - isRetryable = true - break - } - } - - if !isRetryable || attempt == opts.MaxAttempts { - d.logger.Errorf("roxctl error: %s", lastStderr) - return nil, fmt.Errorf("roxctl command failed: %w", err) - } - - d.logger.Warningf("Transient error in roxctl command: %s", lastStderr) - } - - return nil, fmt.Errorf("roxctl command failed after %d attempts: %w", opts.MaxAttempts, lastErr) -} From bc41bce2e4072e8bbec69ac04e31738cca96280b Mon Sep 17 00:00:00 2001 From: Marcin Owsiany Date: Thu, 9 Jul 2026 10:01:09 +0200 Subject: [PATCH 2/7] Fix import ordering Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/deployer/crs.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/deployer/crs.go b/internal/deployer/crs.go index 5d36c338..d1fca09e 100644 --- a/internal/deployer/crs.go +++ b/internal/deployer/crs.go @@ -4,8 +4,8 @@ import ( "context" "crypto/tls" "crypto/x509" - "encoding/pem" "encoding/json" + "encoding/pem" "errors" "fmt" "io" From 2bf330cb91fd543a2f4a0cd091ca2310e1709a52 Mon Sep 17 00:00:00 2001 From: Marcin Owsiany Date: Thu, 9 Jul 2026 11:44:34 +0200 Subject: [PATCH 3/7] unique name --- internal/deployer/crs.go | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/internal/deployer/crs.go b/internal/deployer/crs.go index d1fca09e..5883cdb0 100644 --- a/internal/deployer/crs.go +++ b/internal/deployer/crs.go @@ -28,9 +28,6 @@ type crsGenResponse struct { // generateCRS generates a Cluster Registration Secret via Central's REST API. func (d *Deployer) generateCRS(ctx context.Context, clusterName string) (string, error) { - crsName := fmt.Sprintf("%s-crs", clusterName) - d.logger.Infof("Generating CRS named %q via Central API...", crsName) - const maxAttempts = 5 const baseRetryDelay = 10 @@ -56,11 +53,6 @@ func (d *Deployer) generateCRS(ctx context.Context, clusterName string) (string, return "", fmt.Errorf("failed to create HTTP client: %w", err) } - reqBody, err := json.Marshal(crsGenRequest{Name: crsName}) - if err != nil { - return "", fmt.Errorf("failed to marshal CRS request: %w", err) - } - url := fmt.Sprintf("https://%s/v1/cluster-init/crs", d.centralEndpoint) var lastErr error @@ -75,6 +67,15 @@ func (d *Deployer) generateCRS(ctx context.Context, clusterName string) (string, } } + // Include attempt in name for uniqueness, in case a failure is between addition to DB and reading response. + crsName := fmt.Sprintf("%s-crs-%d", clusterName, attempt) + d.logger.Infof("Generating CRS named %q via Central API...", crsName) + + reqBody, err := json.Marshal(crsGenRequest{Name: crsName}) + if err != nil { + return "", fmt.Errorf("failed to marshal CRS request: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(reqBody))) if err != nil { return "", fmt.Errorf("failed to create HTTP request: %w", err) From 1d0dc3a0827d5566e9a26f47ecd05f265f0d415a Mon Sep 17 00:00:00 2001 From: Marcin Owsiany Date: Thu, 9 Jul 2026 11:44:43 +0200 Subject: [PATCH 4/7] check close --- internal/deployer/crs.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/deployer/crs.go b/internal/deployer/crs.go index 5883cdb0..6b1d85a5 100644 --- a/internal/deployer/crs.go +++ b/internal/deployer/crs.go @@ -97,10 +97,13 @@ func (d *Deployer) generateCRS(ctx context.Context, clusterName string) (string, } body, readErr := io.ReadAll(resp.Body) - resp.Body.Close() + closeErr := resp.Body.Close() if readErr != nil { return "", fmt.Errorf("failed to read CRS response body: %w", readErr) } + if closeErr != nil { + return "", fmt.Errorf("failed to close CRS response body: %w", closeErr) + } if resp.StatusCode == http.StatusBadGateway || resp.StatusCode == http.StatusServiceUnavailable { lastErr = fmt.Errorf("server returned %s", resp.Status) From dd5108f6b71ee1eddc0d5d7bab732ae374b23697 Mon Sep 17 00:00:00 2001 From: Marcin Owsiany Date: Thu, 9 Jul 2026 11:44:59 +0200 Subject: [PATCH 5/7] log added certs --- internal/deployer/crs.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/deployer/crs.go b/internal/deployer/crs.go index 6b1d85a5..0ca69f5d 100644 --- a/internal/deployer/crs.go +++ b/internal/deployer/crs.go @@ -145,13 +145,16 @@ func (d *Deployer) centralHTTPClient() (*http.Client, error) { return nil, fmt.Errorf("reading CA cert file %s: %w", d.roxCACertFile, err) } pool := x509.NewCertPool() + var caCertsAdded int for block, rest := pem.Decode(pemData); block != nil; block, rest = pem.Decode(rest) { cert, err := x509.ParseCertificate(block.Bytes) if err != nil { return nil, fmt.Errorf("parsing CA certificate from %s: %w", d.roxCACertFile, err) } pool.AddCert(cert) + caCertsAdded++ } + d.logger.Infof("Loaded %d CA certificate(s) from %q", caCertsAdded, d.roxCACertFile) tlsConfig.RootCAs = pool } From d9577d6bbc0f7ce291888558d2f029c865b5b031 Mon Sep 17 00:00:00 2001 From: Marcin Owsiany Date: Thu, 9 Jul 2026 13:55:32 +0200 Subject: [PATCH 6/7] custom tls verification to mimic roxctl --- internal/deployer/crs.go | 71 +++++++++++++++++++ internal/errorhelpers/format.go | 120 ++++++++++++++++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 internal/errorhelpers/format.go diff --git a/internal/deployer/crs.go b/internal/deployer/crs.go index 0ca69f5d..68257c29 100644 --- a/internal/deployer/crs.go +++ b/internal/deployer/crs.go @@ -15,9 +15,15 @@ import ( "strings" "time" + "github.com/stackrox/roxie/internal/errorhelpers" "github.com/stackrox/roxie/internal/k8s" ) +const ( + ServiceCACommonName string = `StackRox Certificate Authority` + CentralCommonName string = `CENTRAL_SERVICE: Central` +) + type crsGenRequest struct { Name string `json:"name"` } @@ -156,6 +162,8 @@ func (d *Deployer) centralHTTPClient() (*http.Client, error) { } d.logger.Infof("Loaded %d CA certificate(s) from %q", caCertsAdded, d.roxCACertFile) tlsConfig.RootCAs = pool + tlsConfig.InsecureSkipVerify = true + tlsConfig.VerifyPeerCertificate = verifyFunc(tlsConfig) } return &http.Client{ @@ -166,6 +174,69 @@ func (d *Deployer) centralHTTPClient() (*http.Client, error) { }, nil } +// logic borrowed from VerifyPeerCertFunc in tlscheck package and serviceCertFallbackVerifier in stackrox/stackrox codebase. +func verifyFunc(conf *tls.Config) func([][]byte, [][]*x509.Certificate) error { + return func(rawCerts [][]byte, _ [][]*x509.Certificate) error { + + if len(rawCerts) == 0 { + return errors.New("remote peer presented no certificates") + } + + certs := make([]*x509.Certificate, 0, len(rawCerts)) + for _, rawCert := range rawCerts { + cert, err := x509.ParseCertificate(rawCert) + if err != nil { + return fmt.Errorf("failed to parse peer certificate: %w", err) + } + certs = append(certs, cert) + } + + leaf := certs[0] + intermediates := x509.NewCertPool() + for _, cert := range certs[1:] { + intermediates.AddCert(cert) + } + + systemVerifyOpts := x509.VerifyOptions{ + DNSName: conf.ServerName, + Intermediates: intermediates, + Roots: conf.RootCAs, + } + + _, systemVerifyErr := leaf.Verify(systemVerifyOpts) + if systemVerifyErr == nil || !isACentralCert(leaf) { + return systemVerifyErr + } + + verifyErrs := errorhelpers.NewErrorList("verifying central certificate") + verifyErrs.AddError(systemVerifyErr) + + serviceVerifyOpts := x509.VerifyOptions{ + DNSName: "central.stackrox", + Intermediates: intermediates, + Roots: conf.RootCAs, + } + + _, serviceVerifyErr := leaf.Verify(serviceVerifyOpts) + if serviceVerifyErr == nil { + return nil + } + verifyErrs.AddError(serviceVerifyErr) + return verifyErrs.ToError() + } +} + +// isACentralCert returns true if the cert's issuer and subject CNs claim look like central's. +func isACentralCert(cert *x509.Certificate) bool { + if cert.Issuer.CommonName != ServiceCACommonName { + return false + } + if cert.Subject.CommonName == CentralCommonName { + return true + } + return false +} + // applyCRS applies the CRS content to the sensor namespace func (d *Deployer) applyCRS(ctx context.Context, crsContent string) error { d.logger.Info("Applying CRS to sensor namespace") diff --git a/internal/errorhelpers/format.go b/internal/errorhelpers/format.go new file mode 100644 index 00000000..f5ed0366 --- /dev/null +++ b/internal/errorhelpers/format.go @@ -0,0 +1,120 @@ +// Package errorhelpers was copied fromhttps://github.com/stackrox/stackrox/tree/master/pkg/errorhelpers +package errorhelpers + +import ( + "fmt" + "strings" + + "github.com/pkg/errors" +) + +// ErrorList is a wrapper around many errors +type ErrorList struct { + start string + errors []error +} + +// NewErrorList returns a new ErrorList +func NewErrorList(start string) *ErrorList { + return &ErrorList{ + start: start, + } +} + +// NewErrorListWithErrors returns a new ErrorList with the given errors. +func NewErrorListWithErrors(start string, errors []error) *ErrorList { + errorList := NewErrorList(start) + for _, err := range errors { + errorList.AddError(err) + } + return errorList +} + +// AddError adds the passed error to the list of errors if it is not nil +func (e *ErrorList) AddError(err error) { + if err == nil { + return + } + e.errors = append(e.errors, err) +} + +// AddErrors adds the non-nil errors in the given slice to the list of errors. +func (e *ErrorList) AddErrors(errs ...error) { + for _, err := range errs { + if err == nil { + continue + } + e.errors = append(e.errors, err) + } +} + +// AddWrap is a convenient wrapper around `AddError(errors.Wrap(err, msg))`. +func (e *ErrorList) AddWrap(err error, msg string) { + e.AddError(errors.Wrap(err, msg)) +} + +// AddWrapf is a convenient wrapper around `AddError(errors.Wrapf(err, format, args...))`. +func (e *ErrorList) AddWrapf(err error, format string, args ...interface{}) { + e.AddError(errors.Wrapf(err, format, args...)) +} + +// AddString adds a string based error to the list +func (e *ErrorList) AddString(err string) { + e.errors = append(e.errors, errors.New(err)) +} + +// AddStringf adds a templated string +func (e *ErrorList) AddStringf(t string, args ...interface{}) { + e.errors = append(e.errors, errors.Errorf(t, args...)) +} + +// AddStrings adds multiple string based errors to the list. +func (e *ErrorList) AddStrings(errs ...string) { + for _, err := range errs { + e.errors = append(e.errors, errors.New(err)) + } +} + +// ToError returns an error if there were errors added or nil +func (e *ErrorList) ToError() error { + if len(e.errors) == 0 { + return nil + } + return e +} + +// Error implements the error interface +func (e *ErrorList) Error() string { + return e.String() +} + +// String converts the list to a string, returning empty if no errors were added. +func (e *ErrorList) String() string { + switch len(e.errors) { + case 0: + return "" + case 1: + return fmt.Sprintf("%s error: %s", e.start, e.errors[0]) + default: + return fmt.Sprintf("%s errors: [%s]", e.start, strings.Join(e.ErrorStrings(), ", ")) + } +} + +// ErrorStrings returns all the error strings in this ErrorList as a slice, ignoring the start string. +func (e *ErrorList) ErrorStrings() []string { + errors := make([]string, 0, len(e.errors)) + for _, err := range e.errors { + errors = append(errors, err.Error()) + } + return errors +} + +// Errors returns the underlying errors in the error list +func (e *ErrorList) Errors() []error { + return e.errors +} + +// Empty returns whether the list of error strings is empty. +func (e *ErrorList) Empty() bool { + return len(e.errors) == 0 +} From f82ad1cb598ae37532733e50fcd7f5f283b1d44f Mon Sep 17 00:00:00 2001 From: Marcin Owsiany Date: Thu, 9 Jul 2026 13:58:02 +0200 Subject: [PATCH 7/7] go mod tidy --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 9652942f..492c1961 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/fatih/color v1.19.0 github.com/google/go-containerregistry v0.21.7 github.com/moby/moby/client v0.5.0 + github.com/pkg/errors v0.9.1 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 @@ -44,7 +45,6 @@ require ( github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect - github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/x448/float16 v0.8.4 // indirect