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))
}
}
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. |
| `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)
}

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)
}

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
}
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")
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
// 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 {
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.
func FromAPIError(err error) error {
if err == nil {
return nil
}
switch {
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):
return &Error{Code: Timeout, Message: err.Error()}
default:
return err
}
}

// 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