diagnostic

package
v0.29.25 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Apr 13, 2026 License: Apache-2.0 Imports: 4 Imported by: 0

README

pkg/diagnostic

User-friendly error diagnostics for d8 CLI. Known errors get formatted with possible causes and solutions instead of raw Go error text:

error: TLS/certificate verification failed
  ╰─▶ x509: certificate signed by unknown authority

  * Self-signed certificate on the source registry
    -> Use --tls-skip-verify flag to skip TLS verification
  * Corporate proxy intercepting HTTPS connections
    -> Check if a corporate proxy is intercepting HTTPS traffic

How it works

  root.go                              mirror/cmd/pull (RunE)
  ───────                              ──────────────────────

  rootCmd.Execute()
       |
       |  cobra dispatches
       |  to subcommand ──────────────> err := puller.Execute()
       |                                     |
       |                                [Diagnose err] -> is it HelpfulError?
       |                                     |
       |                                 yes | no
       |                                  |     |
       |                  *HelpfulError <-+     +-> fmt.Errorf("pull failed: %w", err)
       |                                  |     |
       |  error returns   <───────────────+─────+
       |
  [errors.As HelpfulError?]
       |
   yes | no
    |     |
    v     v
  .Format()   "Error executing command: ..." (as usual)
  (colored)   (plain)

Each command diagnoses errors with its own errdetect package. root.go only catches *HelpfulError via errors.As - it does not import any errdetect, so unrelated commands never get false diagnostics.

HelpfulError

type Suggestion struct {
    Cause     string   // why it might have happened
    Solutions []string // how to fix this specific cause
}

type HelpfulError struct {
    Category    string       // what went wrong: "TLS/certificate verification failed"
    OriginalErr error        // the underlying error (required, used by Unwrap/Error/Format)
    Suggestions []Suggestion // cause-solution pairs (optional)
}
Field Required What happens if empty
Category yes output shows error: with no description
OriginalErr yes safe (no panic), but Unwrap returns nil and Format skips the error line
Suggestions no suggestions section is omitted

How fields map to output (Format()):

error: TLS/certificate verification failed  <-- Category
  ╰─▶ x509: certificate signed by ...      <-- OriginalErr (unwrapped chain)

  * Self-signed certificate                 <-- Suggestion.Cause
    -> Use --tls-skip-verify flag           <-- Suggestion.Solutions
  * Corporate proxy intercepting HTTPS      <-- next Suggestion.Cause
    -> Check if proxy is intercepting ...   <-- its Solutions

Error() returns plain text for logs: "Category: OriginalErr.Error()". Unwrap() returns OriginalErr so errors.Is/errors.As work through it.

Where classifiers live

Classifiers are application/UI logic, not library code. They contain user-facing advice (CLI flags, links to docs) that is specific to each command. Place them in internal/ next to the command they serve.

pkg/diagnostic/                        HelpfulError + Format (generic, reusable)
pkg/registry/errmatch/                 error matchers (generic, reusable)
internal/mirror/cmd/pull/errdetect/    pull-specific diagnostics
internal/mirror/cmd/push/errdetect/    push-specific diagnostics

Why per-command: pull advises --license/--source-login, push advises --registry-login/--registry-password. Shared classifier would give ambiguous advice.

Adding diagnostics to a new command

1. Create an errdetect package next to your command:

// internal/backup/cmd/snapshot/errdetect/diagnose.go
package errdetect

import (
    "errors"
    "github.com/deckhouse/deckhouse-cli/pkg/diagnostic"
)

func Diagnose(err error) *diagnostic.HelpfulError {
    var helpErr *diagnostic.HelpfulError
    if errors.As(err, &helpErr) {
        return nil // already diagnosed, don't wrap twice
    }

    if isETCDError(err) {
        return &diagnostic.HelpfulError{
            Category:    "ETCD connection failed",
            OriginalErr: err,
            Suggestions: []diagnostic.Suggestion{
                {
                    Cause:     "ETCD cluster is unreachable",
                    Solutions: []string{"Check ETCD health: etcdctl endpoint health"},
                },
            },
        }
    }
    return nil
}

2. Call it in RunE of your leaf command:

if err := doSnapshot(); err != nil {
    if diag := errdetect.Diagnose(err); diag != nil {
        return diag
    }
    return fmt.Errorf("snapshot failed: %w", err)
}

No changes to root.go needed - it catches any *HelpfulError regardless of which errdetect produced it.

Rules (Best Practice)

  • Classifiers go in internal/<command>/errdetect/ - they are application logic, not libraries
  • Diagnose in the leaf command (RunE), not in libraries or root.go
  • Each command uses its own errdetect - prevents false diagnostics
  • Skip diagnosis if the error is already a *HelpfulError (see guard in the example above)
  • Suggestions are optional but highly recommended

Documentation

Overview

Package diagnostic provides HelpfulError - a wrapper around standard Go errors that adds possible causes and actionable solutions for the user.

When a command returns a HelpfulError, the top-level handler in cmd/d8/root.go detects it via errors.As and prints a formatted diagnostic instead of a raw error. If an error is not wrapped in HelpfulError, it is printed as usual.

Creating a HelpfulError

Option 1: use a command-specific errdetect package (see internal/mirror/cmd/pull/errdetect for an example):

if diag := errdetect.Diagnose(err); diag != nil {
    return diag
}

Option 2: wrap an error directly:

return &diagnostic.HelpfulError{
    Category:    "ETCD snapshot failed",
    OriginalErr: err,
    Suggestions: []diagnostic.Suggestion{
        {
            Cause:     "ETCD cluster is unreachable",
            Solutions: []string{"Check ETCD health: etcdctl endpoint health"},
        },
    },
}

Suggestions are optional - an empty slice is silently omitted from output. Each Suggestion pairs a cause with its specific solutions.

How fields map to Format() output

error: ETCD snapshot failed                <-- Category
  ╰─▶ save snapshot                         <-- OriginalErr chain (unwrapped)
    ╰─▶ dial tcp 10.0.0.1:2379
      ╰─▶ connection refused

  * ETCD cluster is unreachable             <-- Suggestion.Cause
    -> Check ETCD health: etcdctl ...       <-- Suggestion.Solutions

How it propagates

HelpfulError implements the error interface. It propagates up the call chain like any other error. The original error is preserved via HelpfulError.Unwrap, so errors.Is and errors.As work through the wrapper.

In cmd/d8/root.go:

var helpErr *diagnostic.HelpfulError
if errors.As(err, &helpErr) {
    fmt.Fprint(os.Stderr, helpErr.Format()) // colored output, once
}

HelpfulError.Error returns plain text (safe for logs). HelpfulError.Format returns colored terminal output (TTY-aware, respects NO_COLOR).

Adding diagnostics to a new command

Create an errdetect package next to your command with a Diagnose function:

// internal/backup/cmd/snapshot/errdetect/diagnose.go
func Diagnose(err error) *diagnostic.HelpfulError {
    if isETCDError(err) {
        return &diagnostic.HelpfulError{
            Category: "ETCD connection failed", OriginalErr: err,
            Suggestions: []diagnostic.Suggestion{
                {Cause: "ETCD cluster is unreachable", Solutions: []string{"Check ETCD health"}},
            },
        }
    }
    return nil
}

Then call it at the command level:

if diag := errdetect.Diagnose(err); diag != nil {
    return diag
}

Important: diagnose at the command level, not in root.go

Each command must call its own errdetect package. root.go only catches HelpfulError via errors.As - it does not import or call any classifier. This prevents false classification: a DNS error from "d8 backup" must not be diagnosed with registry-specific advice like "--tls-skip-verify".

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type HelpfulError

type HelpfulError struct {
	Category    string       // e.g. "DNS resolution failed for 'registry.example.com'"
	OriginalErr error        // the underlying error
	Suggestions []Suggestion // cause-solution pairs shown to the user
}

HelpfulError is an error enriched with possible causes and actionable solutions. It implements the error interface so it can propagate up the call chain and be printed once at the top level, avoiding double output.

func (*HelpfulError) Error

func (e *HelpfulError) Error() string

Error returns a plain-text representation suitable for logging and error wrapping. Use Format() for user-facing terminal output.

func (*HelpfulError) Format

func (e *HelpfulError) Format() string

Format returns the formatted diagnostic string with colors if stderr is a TTY.

error: Network connection failed to 127.0.0.1:443
  ╰─▶ dial tcp 127.0.0.1:443: connect: connection refused

  * Firewall or security group blocking the connection
    -> Verify firewall rules allow outbound HTTPS (port 443)
  * Registry is down or unreachable
    -> Test connectivity with: curl -v https://<registry>

func (*HelpfulError) Unwrap

func (e *HelpfulError) Unwrap() error

Unwrap returns the original error so errors.Is/errors.As work through the wrapper.

type Suggestion

type Suggestion struct {
	Cause     string   // why it might have happened
	Solutions []string // how to fix this specific cause
}

Suggestion pairs a possible cause with its specific solutions.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL