diff --git a/internal/mirror/cmd/pull/pull.go b/internal/mirror/cmd/pull/pull.go index 57b6ec97..f9b9145f 100644 --- a/internal/mirror/cmd/pull/pull.go +++ b/internal/mirror/cmd/pull/pull.go @@ -49,6 +49,7 @@ import ( "github.com/deckhouse/deckhouse-cli/internal/version" "github.com/deckhouse/deckhouse-cli/pkg/libmirror/modules" "github.com/deckhouse/deckhouse-cli/pkg/libmirror/operations/params" + "github.com/deckhouse/deckhouse-cli/pkg/libmirror/util/errorutil" "github.com/deckhouse/deckhouse-cli/pkg/libmirror/util/log" "github.com/deckhouse/deckhouse-cli/pkg/libmirror/validation" registryservice "github.com/deckhouse/deckhouse-cli/pkg/registry/service" @@ -307,7 +308,8 @@ func (p *Puller) Execute(ctx context.Context) error { p.logger.WarnLn("Operation cancelled by user") return nil } - return fmt.Errorf("pull from registry: %w", err) + fmt.Fprint(os.Stderr, errorutil.FormatRegistryError(err)) + return fmt.Errorf("pull from registry failed: %w", err) } return nil @@ -420,6 +422,7 @@ func (p *Puller) validatePlatformAccess() error { } if accessErr != nil { + fmt.Fprint(os.Stderr, errorutil.FormatRegistryError(accessErr)) return fmt.Errorf("Source registry is not accessible: %w", accessErr) } @@ -472,6 +475,7 @@ func (p *Puller) pullSecurityDatabases() error { p.logger.Warnf("Skipping pull of security databases: %v", err) return nil case err != nil: + fmt.Fprint(os.Stderr, errorutil.FormatRegistryError(err)) return fmt.Errorf("Source registry is not accessible: %w", err) } @@ -547,6 +551,7 @@ func (p *Puller) validateModulesAccess() error { defer cancel() if err := p.accessValidator.ValidateListAccessForRepo(ctx, modulesRepo, p.validationOpts...); err != nil { + fmt.Fprint(os.Stderr, errorutil.FormatRegistryError(err)) return fmt.Errorf("Source registry is not accessible: %w", err) } return nil diff --git a/internal/mirror/cmd/push/push.go b/internal/mirror/cmd/push/push.go index 2bf9e68e..376ee77e 100644 --- a/internal/mirror/cmd/push/push.go +++ b/internal/mirror/cmd/push/push.go @@ -43,6 +43,7 @@ import ( "github.com/deckhouse/deckhouse-cli/internal/mirror/operations" "github.com/deckhouse/deckhouse-cli/internal/version" "github.com/deckhouse/deckhouse-cli/pkg/libmirror/operations/params" + "github.com/deckhouse/deckhouse-cli/pkg/libmirror/util/errorutil" "github.com/deckhouse/deckhouse-cli/pkg/libmirror/util/log" "github.com/deckhouse/deckhouse-cli/pkg/libmirror/validation" ) @@ -236,6 +237,7 @@ func validateRegistryAccess(ctx context.Context, pushParams *params.PushParams) accessValidator := validation.NewRemoteRegistryAccessValidator() err := accessValidator.ValidateWriteAccessForRepo(ctx, path.Join(pushParams.RegistryHost, pushParams.RegistryPath), opts...) if err != nil { + fmt.Fprint(os.Stderr, errorutil.FormatRegistryError(err)) return fmt.Errorf("validate write access to registry %s: %w", path.Join(pushParams.RegistryHost, pushParams.RegistryPath), err) } @@ -359,7 +361,8 @@ func (p *Pusher) executeNewPush() error { p.logger.WarnLn("Operation cancelled by user") return nil } - return fmt.Errorf("push to registry: %w", err) + fmt.Fprint(os.Stderr, errorutil.FormatRegistryError(err)) + return fmt.Errorf("push to registry failed: %w", err) } return nil @@ -372,7 +375,8 @@ func (p *Pusher) validateRegistryAccess() error { defer cancel() err := validateRegistryAccess(ctx, p.pushParams) if err != nil && os.Getenv("MIRROR_BYPASS_ACCESS_CHECKS") != "1" { - return fmt.Errorf("registry credentials validation failure: %w", err) + fmt.Fprint(os.Stderr, errorutil.FormatRegistryError(err)) + return fmt.Errorf("registry credentials validation failed: %w", err) } return nil } diff --git a/pkg/libmirror/util/errorutil/errors.go b/pkg/libmirror/util/errorutil/errors.go index 1ab00ff7..682f124e 100644 --- a/pkg/libmirror/util/errorutil/errors.go +++ b/pkg/libmirror/util/errorutil/errors.go @@ -16,7 +16,17 @@ limitations under the License. package errorutil -import "strings" +import ( + "crypto/x509" + "errors" + "fmt" + "net" + "net/http" + "strings" + "syscall" + + "github.com/google/go-containerregistry/pkg/v1/remote/transport" +) const CustomTrivyMediaTypesWarning = `` + "It looks like you are using Project Quay registry and it is not configured correctly for hosting Deckhouse.\n" + @@ -58,3 +68,271 @@ func IsTrivyMediaTypeNotAllowedError(err error) bool { return strings.Contains(errMsg, "MANIFEST_INVALID") && (strings.Contains(errMsg, "vnd.aquasec.trivy") || strings.Contains(errMsg, "application/octet-stream")) } + +const ( + colorReset = "\033[0m" + colorRed = "\033[31m" + colorYellow = "\033[33m" + colorCyan = "\033[36m" + colorBold = "\033[1m" +) + +type errorCategory struct { + name string + causes []string + solutions []string +} + +func isCertificateError(err error) bool { + var ( + unknownAuthErr x509.UnknownAuthorityError + certInvalidErr x509.CertificateInvalidError + hostnameErr x509.HostnameError + systemRootsErr x509.SystemRootsError + constraintErr x509.ConstraintViolationError + insecureAlgErr x509.InsecureAlgorithmError + ) + + return errors.As(err, &unknownAuthErr) || + errors.As(err, &certInvalidErr) || + errors.As(err, &hostnameErr) || + errors.As(err, &systemRootsErr) || + errors.As(err, &constraintErr) || + errors.As(err, &insecureAlgErr) +} + +func isAuthenticationError(err error) bool { + var transportErr *transport.Error + if !errors.As(err, &transportErr) { + return false + } + + if transportErr.StatusCode == http.StatusUnauthorized || transportErr.StatusCode == http.StatusForbidden { + return true + } + + for _, diag := range transportErr.Errors { + if diag.Code == transport.UnauthorizedErrorCode || diag.Code == transport.DeniedErrorCode { + return true + } + } + + return false +} + +func isNetworkError(err error) bool { + var ( + netErr net.Error + opErr *net.OpError + syscallErr syscall.Errno + ) + + if errors.As(err, &netErr) { + return true + } + + if errors.As(err, &opErr) { + return true + } + + if errors.As(err, &syscallErr) { + return syscallErr == syscall.ECONNREFUSED || + syscallErr == syscall.ECONNRESET || + syscallErr == syscall.ETIMEDOUT || + syscallErr == syscall.ENETUNREACH || + syscallErr == syscall.EHOSTUNREACH + } + + return false +} + +func isDNSError(err error) bool { + var dnsErr *net.DNSError + return errors.As(err, &dnsErr) +} + +func formatError(category errorCategory, err error) string { + var b strings.Builder + + b.WriteString("\n") + b.WriteString(colorBold) + b.WriteString(colorRed) + b.WriteString("error") + b.WriteString(colorReset) + b.WriteString(colorBold) + b.WriteString(": ") + b.WriteString(category.name) + b.WriteString(colorReset) + b.WriteString("\n") + + b.WriteString(colorCyan) + b.WriteString(" ╰─▶ ") + b.WriteString(colorReset) + b.WriteString(err.Error()) + b.WriteString("\n\n") + + if len(category.causes) > 0 { + b.WriteString(colorYellow) + b.WriteString(" Possible causes:\n") + b.WriteString(colorReset) + for _, cause := range category.causes { + b.WriteString(" • ") + b.WriteString(cause) + b.WriteString("\n") + } + b.WriteString("\n") + } + + if len(category.solutions) > 0 { + b.WriteString(colorCyan) + b.WriteString(" How to fix:\n") + b.WriteString(colorReset) + for _, solution := range category.solutions { + b.WriteString(" • ") + b.WriteString(solution) + b.WriteString("\n") + } + } + + return b.String() +} + +func FormatRegistryError(err error) string { + if err == nil { + return "" + } + + var category errorCategory + + switch { + case isCertificateError(err): + category = errorCategory{ + name: "TLS/certificate verification failed", + causes: []string{ + "Self-signed certificate without proper trust chain", + "Certificate expired or not yet valid", + "Hostname mismatch between certificate and registry URL", + "Corporate proxy or middleware intercepting HTTPS connections", + }, + solutions: []string{ + "Use --insecure flag to skip TLS verification (not recommended for production)", + "Add the registry's CA certificate to your system trust store", + "Verify the registry URL hostname matches the certificate", + }, + } + + case isAuthenticationError(err): + var transportErr *transport.Error + name := "Authentication failed" + if errors.As(err, &transportErr) { + switch transportErr.StatusCode { + case http.StatusUnauthorized: + name = "Authentication failed (HTTP 401 Unauthorized)" + case http.StatusForbidden: + name = "Access denied (HTTP 403 Forbidden)" + } + } + + category = errorCategory{ + name: name, + causes: []string{ + "Invalid or expired credentials", + "License key is incorrect, expired, or not provided", + "Insufficient permissions for the requested operation", + }, + solutions: []string{ + "Verify your license key is correct and not expired", + "Ensure --license flag is specified with a valid key", + "Contact registry administrator to verify access rights", + }, + } + + case isDNSError(err): + var dnsErr *net.DNSError + name := "DNS resolution failed" + if errors.As(err, &dnsErr) && dnsErr.Name != "" { + name = fmt.Sprintf("DNS resolution failed for '%s'", dnsErr.Name) + } + + category = errorCategory{ + name: name, + causes: []string{ + "Registry hostname cannot be resolved by DNS", + "DNS server is unreachable or not responding", + "Incorrect registry URL or typo in hostname", + }, + solutions: []string{ + "Verify the registry URL is spelled correctly", + "Check your DNS server configuration", + "Try using the registry's IP address instead of hostname", + }, + } + + case isNetworkError(err): + var opErr *net.OpError + name := "Network connection failed" + if errors.As(err, &opErr) { + if opErr.Addr != nil { + name = fmt.Sprintf("Network connection failed to %s", opErr.Addr.String()) + } + } + + category = errorCategory{ + name: name, + causes: []string{ + "Network connectivity issues or no internet connection", + "Firewall or security group blocking the connection", + "Registry server is down or unreachable", + }, + solutions: []string{ + "Check your network connection and internet access", + "Verify firewall rules allow outbound HTTPS (port 443)", + "Test connectivity with: curl -v https://", + }, + } + + case IsImageNotFoundError(err): + category = errorCategory{ + name: "Image not found in registry", + causes: []string{ + "Image tag doesn't exist in the registry", + "Incorrect image name or tag specified", + }, + solutions: []string{ + "Verify the image name and tag are correct", + "Check if you have permission to access this image", + }, + } + + case IsRepoNotFoundError(err): + category = errorCategory{ + name: "Repository not found in registry", + causes: []string{ + "Repository doesn't exist in the registry", + "Incorrect repository path or name", + }, + solutions: []string{ + "Verify the repository path is correct", + "Ensure you have permission to access this repository", + }, + } + + case IsTrivyMediaTypeNotAllowedError(err): + category = errorCategory{ + name: "Unsupported OCI artifact type", + causes: []string{ + "Registry doesn't support required media types for Trivy security databases", + "Project Quay registry not configured for Deckhouse artifacts", + }, + solutions: []string{ + "Configure registry to allow custom OCI artifact types", + "See: https://deckhouse.io/products/kubernetes-platform/documentation/v1/supported_versions.html#container-registry", + }, + } + + default: + return err.Error() + } + + return formatError(category, err) +}