Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 23 additions & 7 deletions cmd/lint/lint.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package lint

import (
"errors"
"fmt"

"github.com/spf13/cobra"
Expand Down Expand Up @@ -91,7 +92,9 @@ func AddCommand(root *cobra.Command, flags *genericclioptions.ConfigFlags) {
// Complete phase
if err := command.Complete(); err != nil {
if clierrors.WriteStructuredError(cmd.ErrOrStderr(), err, outputFormat) {
return clierrors.NewAlreadyHandledError(err)
return clierrors.NewAlreadyHandledError(
clierrors.NewExitCodeError(clierrors.ExitCodeFromError(err), err),
)
}

if command.Verbose {
Expand All @@ -101,13 +104,17 @@ func AddCommand(root *cobra.Command, flags *genericclioptions.ConfigFlags) {
clierrors.WriteTextError(cmd.ErrOrStderr(), err)
}

return clierrors.NewAlreadyHandledError(err)
return clierrors.NewAlreadyHandledError(
clierrors.NewExitCodeError(clierrors.ExitCodeFromError(err), err),
)
}

// Validate phase
if err := command.Validate(); err != nil {
if clierrors.WriteStructuredError(cmd.ErrOrStderr(), err, outputFormat) {
return clierrors.NewAlreadyHandledError(err)
exitErr := clierrors.NewExitCodeError(clierrors.ExitValidation, err)

if clierrors.WriteStructuredError(cmd.ErrOrStderr(), exitErr, outputFormat) {
return clierrors.NewAlreadyHandledError(exitErr)
}

if command.Verbose {
Expand All @@ -117,14 +124,21 @@ func AddCommand(root *cobra.Command, flags *genericclioptions.ConfigFlags) {
clierrors.WriteTextError(cmd.ErrOrStderr(), err)
}

return clierrors.NewAlreadyHandledError(err)
return clierrors.NewAlreadyHandledError(exitErr)
}

// Run phase
err := command.Run(cmd.Context())
if err != nil {
// Verdict errors (findings already rendered) propagate directly
if errors.Is(err, clierrors.ErrAlreadyHandled) {
return err //nolint:wrapcheck // already wrapped by NewAlreadyHandledError
}

if clierrors.WriteStructuredError(cmd.ErrOrStderr(), err, outputFormat) {
return clierrors.NewAlreadyHandledError(err)
return clierrors.NewAlreadyHandledError(
clierrors.NewExitCodeError(clierrors.ExitCodeFromError(err), err),
)
}

if command.Verbose {
Expand All @@ -134,7 +148,9 @@ func AddCommand(root *cobra.Command, flags *genericclioptions.ConfigFlags) {
clierrors.WriteTextError(cmd.ErrOrStderr(), err)
}

return clierrors.NewAlreadyHandledError(err)
return clierrors.NewAlreadyHandledError(
clierrors.NewExitCodeError(clierrors.ExitCodeFromError(err), err),
)
}

return nil
Expand Down
8 changes: 4 additions & 4 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,12 @@ func main() {
lint.AddCommand(cmd, flags)

if err := cmd.Execute(); err != nil {
exitCode := int(clierrors.ExitCodeFromError(err))

if !errors.Is(err, clierrors.ErrAlreadyHandled) {
if _, writeErr := os.Stderr.WriteString(err.Error() + "\n"); writeErr != nil {
os.Exit(1)
}
_, _ = os.Stderr.WriteString(err.Error() + "\n")
}

os.Exit(1)
os.Exit(exitCode)
}
}
54 changes: 54 additions & 0 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,60 @@ kubectl odh lint --target-version 3.3.0
kubectl odh version
```

## Exit Codes

The CLI uses differentiated exit codes to help automation tools and CI/CD pipelines
take appropriate action based on the type of outcome.

| Exit Code | Category | Description |
|-----------|------------|----------------------------------------------------------|
| 0 | Success | Process completed without issues. |
| 1 | Error | General runtime or unexpected errors. |
| 2 | Warning | Process finished, but advisory warnings were found. |
| 3 | Validation | Invalid user input or configuration errors. |
| 4 | Auth | Authentication or authorization failures. |
| 5 | Connection | Network issues, timeouts, or service unavailability. |

### Precedence

When multiple issues occur, the exit code reflects the highest priority error:

1. Connection/Timeout (5) - Infrastructure failure
2. Auth (4) - Security-related failures
3. Validation (3) - Input-related failures
4. Error (1) - Catch-all runtime errors
5. Warning (2) - Only used if no higher-level errors exist

### Lint Command Exit Codes

The lint command maps finding impact levels to exit codes:

- **Prohibited or Blocking findings** → Exit 1 (upgrade cannot proceed)
- **Advisory findings only** → Exit 2 (upgrade can proceed, review recommended)
- **No findings** → Exit 0 (clean)

If a lint check fails to execute due to infrastructure issues (auth, connection),
the corresponding exit code (4 or 5) takes precedence over finding-based exit codes.

### Structured Error Output

When using `-o json` or `-o yaml`, error responses include an `exitCode` field
in the structured output, allowing automation tools to parse the exit code without
relying on shell `$?`:

```json
{
"error": {
"code": "AUTH_FAILED",
"message": "token expired",
"category": "authentication",
"exitCode": 4,
"retriable": false,
"suggestion": "Refresh your kubeconfig credentials with 'oc login' or 'kubectl config'"
}
}
```

## Key Architecture Decisions

### Core Principles
Expand Down
93 changes: 83 additions & 10 deletions pkg/lint/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,21 @@ import (
trainingoperatorworkloads "github.com/opendatahub-io/odh-cli/pkg/lint/checks/workloads/trainingoperator"
"github.com/opendatahub-io/odh-cli/pkg/resources"
"github.com/opendatahub-io/odh-cli/pkg/util/client"
clierrors "github.com/opendatahub-io/odh-cli/pkg/util/errors"
"github.com/opendatahub-io/odh-cli/pkg/util/iostreams"
"github.com/opendatahub-io/odh-cli/pkg/util/version"
)

// Verify Command implements cmd.Command interface at compile time.
var _ cmd.Command = (*Command)(nil)

const (
msgProhibitedOrBlocking = "prohibited or blocking findings detected: upgrade cannot proceed"
msgAdvisoryFindings = "advisory findings detected: review recommended before upgrade"
msgInfrastructureErrors = "one or more checks failed due to infrastructure errors"
msgCheckExecErrors = "check execution errors detected: %w"
)

// Command contains the lint command configuration.
type Command struct {
*SharedOptions
Expand Down Expand Up @@ -248,8 +256,10 @@ func (c *Command) Run(ctx context.Context) error {

// Reject downgrades when explicit --target-version is provided
if targetVersion.LT(*currentVersion) {
return fmt.Errorf("target version %s is older than current version %s (downgrades not supported)",
c.TargetVersion, currentVersion.String())
//nolint:wrapcheck // NewExitCodeError is a same-module constructor, not an external error
return clierrors.NewExitCodeError(clierrors.ExitValidation,
fmt.Errorf("target version %s is older than current version %s (downgrades not supported)",
c.TargetVersion, currentVersion.String()))
}

return c.runUpgradeMode(ctx, currentVersion)
Expand Down Expand Up @@ -315,8 +325,25 @@ func (c *Command) runUpgradeMode(ctx context.Context, currentVersion *semver.Ver
resultsByGroup[group] = results
}

// Flatten, strip nil results, and apply severity filter
// Flatten results and compute the highest-priority exit code from execution
// errors BEFORE filtering, so failures with Result == nil are not dropped.
flatResults := FlattenResults(resultsByGroup)

execExitCode := clierrors.ExitSuccess

var firstExecErr error

for _, exec := range flatResults {
if exec.Error != nil {
code := clierrors.ExitCodeFromError(exec.Error)
if clierrors.IsHigherPriority(code, execExitCode) {
execExitCode = code
firstExecErr = exec.Error
}
}
}

// Strip nil results and apply severity filter for display/verdict
flatResults = slices.DeleteFunc(flatResults, func(exec check.CheckExecution) bool {
return exec.Result == nil
})
Expand All @@ -327,13 +354,47 @@ func (c *Command) runUpgradeMode(ctx context.Context, currentVersion *semver.Ver
return err
}

// Print verdict and determine exit code
return c.printVerdictAndExit(flatResults)
// Print verdict and determine exit code from findings
findingsErr := c.evaluateVerdict(flatResults)

// If check execution errors (e.g. auth, connection) have higher priority
// than findings, propagate the infrastructure exit code instead.
if execExitCode != clierrors.ExitSuccess {
findingsExitCode := clierrors.ExitCodeFromError(findingsErr)
if clierrors.IsHigherPriority(execExitCode, findingsExitCode) ||
execExitCode == findingsExitCode {
if findingsErr != nil {
//nolint:wrapcheck // NewExitCodeError is a same-module constructor
return clierrors.NewExitCodeError(execExitCode,
fmt.Errorf(msgCheckExecErrors, firstExecErr))
}

//nolint:wrapcheck // NewExitCodeError is a same-module constructor
return clierrors.NewExitCodeError(execExitCode,
fmt.Errorf(msgInfrastructureErrors+": %w", firstExecErr))
}
}

// Verdict errors are pure exit code signals — the findings have already
// been rendered by formatAndOutputUpgradeResults above.
if findingsErr != nil {
// For table output, findings and verdict banner were already printed,
// so mark as handled to skip further output in cmd/lint/lint.go.
// For JSON/YAML, return the raw error so WriteStructuredError can
// emit the proper error envelope with exit code metadata.
if c.OutputFormat == OutputFormatTable {
return clierrors.NewAlreadyHandledError(findingsErr) //nolint:wrapcheck // wrapping is done by NewAlreadyHandledError
}

return findingsErr
}

return nil
}

// printVerdictAndExit prints a prominent result verdict for table output and returns
// an error if fail-on conditions are met (to control exit code).
func (c *Command) printVerdictAndExit(results []check.CheckExecution) error {
// evaluateVerdict prints a prominent result verdict for table output and returns
// an error carrying the appropriate ExitCode when fail-on conditions are met.
func (c *Command) evaluateVerdict(results []check.CheckExecution) error {
var hasProhibited, hasBlocking, hasAdvisory bool

for _, exec := range results {
Expand All @@ -357,8 +418,20 @@ func (c *Command) printVerdictAndExit(results []check.CheckExecution) error {
printVerdict(c.IO.Out(), hasProhibited, hasBlocking, hasAdvisory)
}

if hasProhibited {
return errors.New("prohibited findings detected: upgrade is not possible")
if hasProhibited || hasBlocking {
//nolint:wrapcheck // NewExitCodeError is a same-module constructor
return clierrors.NewExitCodeError(
clierrors.ExitError,
errors.New(msgProhibitedOrBlocking),
)
}

if hasAdvisory {
//nolint:wrapcheck // NewExitCodeError is a same-module constructor
return clierrors.NewExitCodeError(
clierrors.ExitWarning,
errors.New(msgAdvisoryFindings),
)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return nil
Expand Down
Loading