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
12 changes: 7 additions & 5 deletions cmd/tkn/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,19 @@ import (
"syscall"

"github.com/tektoncd/cli/pkg/cli"
"github.com/tektoncd/cli/pkg/cmd"
tknCmd "github.com/tektoncd/cli/pkg/cmd"
"github.com/tektoncd/cli/pkg/exitcode"
"github.com/tektoncd/cli/pkg/plugins"
_ "k8s.io/client-go/plugin/pkg/client/auth"
)

func main() {
tp := &cli.TektonParams{}
tkn := cmd.Root(tp)
tkn := tknCmd.Root(tp)

args := os.Args[1:]
cmd, _, _ := tkn.Find(args)
if cmd != nil && cmd == tkn && len(args) > 0 {
found, _, _ := tkn.Find(args)
if found != nil && found == tkn && len(args) > 0 {
exCmd, err := plugins.FindPlugin(os.Args[1])
// if we can't find command then execute the normal tkn command.
if err != nil {
Expand All @@ -49,6 +50,7 @@ func main() {

CoreTkn:
if err := tkn.Execute(); err != nil {
os.Exit(1)
tknCmd.PrintError(tkn, err, os.Stderr)
os.Exit(exitcode.CodeFrom(err))
}
Comment on lines 51 to 55
}
80 changes: 80 additions & 0 deletions docs/exit-codes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# tkn Exit Codes

`tkn` uses a consistent set of exit codes so that scripts and CI systems can
detect success or failure without parsing command output.

## Exit Code Table

| Code | Constant | Meaning |
|------|----------------|--------------------------------------------|
| `0` | `Success` | The command completed successfully. |

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In Bash, exit code 2 conventionally means "incorrect usage / invalid arguments." This PR uses 2 for "not found" instead. This isn't necessarily wrong (kubectl doesn't differentiate at all, and many CLIs define their own schemes), but it's worth documenting this divergence explicitly in docs/exit-codes.md so users don't confuse it with the shell convention.

| `1` | `GeneralError` | Unclassified error or command failure. |
| `2` | `NotFound` | The requested resource does not exist. |
| `3` | `InvalidInput` | Invalid flag, parameter, or input value. |
| `4` | `Timeout` | The operation exceeded its deadline. |
| `5` | `Unauthorized` | The request was unauthorized or forbidden. |

Exit code `127` is reserved for plugin execution failures.

## Exit Code `2` – Resource Not Found

`tkn` returns `2` whenever a Kubernetes API call returns an HTTP 404 (Not
Found). For example:

```bash
tkn taskrun describe my-missing-run -n default
# → Error: taskruns.tekton.dev "my-missing-run" not found
echo $? # 2
```

## Exit Code `5` – Unauthorized / Forbidden

`tkn` returns `5` when the server responds with HTTP 401 or 403:

```bash
tkn pipeline list -n restricted-ns
# → Error: pipelines.tekton.dev is forbidden: ...
echo $? # 5
```

## Structured Errors with `--output json`

When `--output json` is passed to any command that supports it, errors are
written to **stderr** as a JSON object instead of a plain-text message:

```bash
tkn pipelinerun describe missing-run --output json 2>err.json
cat err.json
# {"error":"pipelineruns.tekton.dev \"missing-run\" not found","code":2}
echo $? # 2
```

This allows programmatic consumers to parse both the error message and the
category code without inspecting the human-readable output.

## `--exit-with-error` and PipelineRun logs

`tkn pipelinerun logs --exit-with-error` exits with the PipelineRun's Unix
status after streaming logs:

| PipelineRun state | Exit code |
|----------------------------|-----------|
| Succeeded | `0` |
| Failed | `1` |
| No conditions yet | `1` |

> **Note:** The "no conditions" case returns `1` (general error) rather than
> `2` (not found) because the PipelineRun object exists — it simply has not
> been evaluated yet.

## Using Exit Codes in Scripts

```bash
tkn task describe my-task -n default
case $? in
0) echo "Found" ;;
2) echo "Task does not exist" ;;
5) echo "Permission denied" ;;
*) echo "Unexpected error" ;;
esac
```
3 changes: 2 additions & 1 deletion pkg/actions/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ package actions
import (
"context"

"github.com/tektoncd/cli/pkg/exitcode"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/discovery"
Expand All @@ -32,7 +33,7 @@ func Delete(gr schema.GroupVersionResource, dynamic dynamic.Interface, discovery

err = dynamic.Resource(*gvr).Namespace(ns).Delete(context.Background(), objname, op)
if err != nil {
return err
return exitcode.FromAPIError(err)
}

return nil
Expand Down
5 changes: 3 additions & 2 deletions pkg/actions/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"io"

"github.com/tektoncd/cli/pkg/cli"
"github.com/tektoncd/cli/pkg/exitcode"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
Expand Down Expand Up @@ -66,7 +67,7 @@ func GetUnstructured(gr schema.GroupVersionResource, c *cli.Clients, objname, ns

unstructuredObj, err := c.Dynamic.Resource(*gvr).Namespace(ns).Get(context.Background(), objname, op)
if err != nil {
return nil, err
return nil, exitcode.FromAPIError(err)
}
return unstructuredObj, nil
}
Expand All @@ -81,7 +82,7 @@ func Get(gr schema.GroupVersionResource, dynamic dynamic.Interface, discovery di

obj, err := dynamic.Resource(*gvr).Namespace(ns).Get(context.Background(), objname, op)
if err != nil {
return nil, err
return nil, exitcode.FromAPIError(err)
}

return obj, nil
Expand Down
5 changes: 3 additions & 2 deletions pkg/actions/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"io"

"github.com/tektoncd/cli/pkg/cli"
"github.com/tektoncd/cli/pkg/exitcode"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
Expand Down Expand Up @@ -57,7 +58,7 @@ func list(gr schema.GroupVersionResource, dynamic dynamic.Interface, discovery d

allRes, err := dynamic.Resource(*gvr).Namespace(ns).List(context.Background(), op)
if err != nil {
return nil, err
return nil, exitcode.FromAPIError(err)
}
Comment on lines 59 to 62

return allRes, nil
Expand All @@ -73,7 +74,7 @@ func List(gr schema.GroupVersionResource, dynamic dynamic.Interface, discovery d

allRes, err := dynamic.Resource(*gvr).Namespace(ns).List(context.Background(), op)
if err != nil {
return nil, err
return nil, exitcode.FromAPIError(err)
}

return allRes, nil
Expand Down
3 changes: 2 additions & 1 deletion pkg/actions/patch.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"os"

"github.com/tektoncd/cli/pkg/cli"
"github.com/tektoncd/cli/pkg/exitcode"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
Expand All @@ -35,7 +36,7 @@ func Patch(gr schema.GroupVersionResource, clients *cli.Clients, objName string,
unstructuredObj, err := clients.Dynamic.Resource(*gvr).Namespace(ns).Patch(context.Background(), objName, types.JSONPatchType, data, opt)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to patch object from %s namespace \n", ns)
return err
return exitcode.FromAPIError(err)
Comment on lines 36 to +39
}

return runtime.DefaultUnstructuredConverter.FromUnstructured(unstructuredObj.UnstructuredContent(), obj)
Expand Down
5 changes: 4 additions & 1 deletion pkg/cmd/pipelinerun/logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,10 @@ func Run(opts *options.LogOptions) error {

func prStatusToUnixStatus(pr *tektonv1.PipelineRun) int {
if len(pr.Status.Conditions) == 0 {
return 2
// PipelineRun has no conditions yet; treat as a general failure
// so the caller can distinguish from a successful run (0) without
// conflicting with the "resource not found" exit code (2).
return 1

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changing the "no conditions yet" exit code from 2 to 1 is semantically correct under the new scheme, but it's a breaking change for scripts relying on the previous behavior. The release note should explicitly mention this: "The exit code for PipelineRuns with no conditions has changed from 2 to 1."

}
if pr.Status.Conditions[0].Status == corev1.ConditionFalse {
return 1
Expand Down
4 changes: 3 additions & 1 deletion pkg/cmd/pipelinerun/logs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,9 @@ func TestLog_PrStatusToUnixStatus(t *testing.T) {
},
},
},
expected: 2,
// No conditions means the PipelineRun has not yet been evaluated;
// this is treated as a general failure (1), not "resource not found" (2).
expected: 1,
},
{
name: "Condition status is false",
Expand Down
31 changes: 27 additions & 4 deletions pkg/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
package cmd

import (
"encoding/json"
"fmt"
"os"

Expand All @@ -36,6 +37,7 @@ import (
"github.com/tektoncd/cli/pkg/cmd/triggerbinding"
"github.com/tektoncd/cli/pkg/cmd/triggertemplate"
"github.com/tektoncd/cli/pkg/cmd/version"
"github.com/tektoncd/cli/pkg/exitcode"
"github.com/tektoncd/cli/pkg/plugins"
"github.com/tektoncd/cli/pkg/suggestion"
)
Expand Down Expand Up @@ -90,10 +92,11 @@ func Root(p cli.Params) *cobra.Command {
pflag.CommandLine = pflag.NewFlagSet(os.Args[0], pflag.ExitOnError)

cmd := &cobra.Command{
Use: "tkn",
Short: "CLI for tekton pipelines",
Long: ``,
SilenceUsage: true,
Use: "tkn",
Short: "CLI for tekton pipelines",
Long: ``,
SilenceUsage: true,
SilenceErrors: true,
}
cobra.AddTemplateFunc("HasMainSubCommands", hasMainSubCommands)
cobra.AddTemplateFunc("HasUtilitySubCommands", hasUtilitySubCommands)
Expand Down Expand Up @@ -122,6 +125,26 @@ func Root(p cli.Params) *cobra.Command {
return cmd
}

// PrintError writes err to errW. When the resolved --output flag on cmd is
// "json", the error is serialised as {"error":"<message>","code":<n>}.
// Otherwise the standard "Error: <message>\n" format is used.
func PrintError(cmd *cobra.Command, err error, errW *os.File) {
outputFlag, _ := cmd.Flags().GetString("output")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PrintError calls cmd.Flags().GetString("output") where cmd is the root tkn command (passed from cmd/tkn/main.go). However, the --output flag is defined on individual subcommands, not on the root. GetString("output") on the root will return "" (and a non-nil error which is silently discarded), so the JSON error path will never trigger.

Consider resolving the executed subcommand's flags instead, or propagating the output format via a persistent pre-run hook that stores it somewhere accessible at error-handling time.

Comment on lines +128 to +132
if outputFlag == "json" {
payload := struct {
Error string `json:"error"`
Code int `json:"code"`
}{
Error: err.Error(),
Code: exitcode.CodeFrom(err),
}
b, _ := json.Marshal(payload)
fmt.Fprintf(errW, "%s\n", b)
} else {
fmt.Fprintf(errW, "Error: %s\n", err)
}
}

func commandName(cmd *cobra.Command) string {
if prerun.IsExperimental(cmd) {
return fmt.Sprintf("%s*", cmd.Name())
Expand Down
96 changes: 96 additions & 0 deletions pkg/exitcode/exitcode.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// Copyright © 2024 The Tekton Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// Package exitcode defines the standard exit codes used by tkn and provides
// helper types so that errors can carry their intended exit code through the
// call stack without requiring callers to parse error strings.
//
// Exit code table:
//
// 0 Success
// 1 General error / command failure
// 2 Resource not found
// 3 Invalid input / validation error
// 4 Timeout
// 5 Unauthorized / forbidden
package exitcode

import (
"errors"
"fmt"

k8serrors "k8s.io/apimachinery/pkg/api/errors"
)

const (
// Success is the exit code for a successful command.
Success = 0
// GeneralError is the exit code for unclassified errors.
GeneralError = 1
// NotFound is the exit code when a requested resource does not exist.
NotFound = 2
// InvalidInput is the exit code for invalid flags, parameters, or input.
InvalidInput = 3
Comment on lines +43 to +44
// Timeout is the exit code when an operation exceeds its deadline.
Timeout = 4
// Unauthorized is the exit code when the request is unauthorized or forbidden.
Unauthorized = 5
)

// Error is an error that carries a specific exit code.
type Error struct {
Code int
Message string
}

func (e *Error) Error() string {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FromAPIError creates a new *exitcode.Error with only the string message, discarding the original Kubernetes *StatusError. Any downstream code that calls k8serrors.IsNotFound(err) or errors.As(err, &statusErr) after a pkg/actions call will now get false because the original error is no longer in the chain.

Can we store the original and implement Unwrap():

type Error struct {
    Code    int
    Message string
    Err     error
}
func (e *Error) Error() string { return e.Message }
func (e *Error) Unwrap() error { return e.Err }

And update FromAPIError to preserve the original:

return &Error{Code: NotFound, Message: err.Error(), Err: err}

This preserves both the exit code semantics and the original error chain

return e.Message
}

// New creates an Error with an explicit code and formatted message.
func New(code int, format string, a ...interface{}) *Error {
return &Error{Code: code, Message: fmt.Sprintf(format, a...)}
}

// FromAPIError converts a Kubernetes API error into an Error with the
// appropriate exit code. If err is not a k8s status error it is returned
// unchanged.
Comment on lines +66 to +68
func FromAPIError(err error) error {
if err == nil {
return nil
}
switch {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The InvalidInput constant is declared but nothing in this PR ever emits exit code 3. FromAPIError doesn't map k8serrors.IsInvalid() or k8serrors.IsBadRequest() to it, and Cobra flag-parsing errors also bypass this classification entirely.

We can probably do

case k8serrors.IsInvalid(err), k8serrors.IsBadRequest(err):
    return &Error{Code: InvalidInput, Message: err.Error()}

case k8serrors.IsNotFound(err):
return &Error{Code: NotFound, Message: err.Error()}
case k8serrors.IsUnauthorized(err), k8serrors.IsForbidden(err):
return &Error{Code: Unauthorized, Message: err.Error()}
case k8serrors.IsTimeout(err):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

k8serrors.IsTimeout() only matches HTTP 504 from the API server. Context deadline exceeded errors (context.DeadlineExceeded) and client-side request timeouts won't produce exit code 4.

Can we add this before the default case

case errors.Is(err, context.DeadlineExceeded):
    return &Error{Code: Timeout, Message: err.Error(), Err: err}

return &Error{Code: Timeout, Message: err.Error()}
Comment on lines +78 to +79
default:
return err
}
Comment on lines +73 to +82
}

// CodeFrom returns the exit code carried by err, or GeneralError if err
// carries no code.
func CodeFrom(err error) int {
if err == nil {
return Success
}
var e *Error
if errors.As(err, &e) {
return e.Code
}
return GeneralError
}
Loading
Loading