diff --git a/cmd/tkn/main.go b/cmd/tkn/main.go index 021f5cf185..6b6a9d77ad 100644 --- a/cmd/tkn/main.go +++ b/cmd/tkn/main.go @@ -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 { @@ -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)) } } diff --git a/docs/exit-codes.md b/docs/exit-codes.md new file mode 100644 index 0000000000..24054b94af --- /dev/null +++ b/docs/exit-codes.md @@ -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 +``` diff --git a/pkg/actions/delete.go b/pkg/actions/delete.go index f99d3d396f..122d0ba296 100644 --- a/pkg/actions/delete.go +++ b/pkg/actions/delete.go @@ -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" @@ -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 diff --git a/pkg/actions/get.go b/pkg/actions/get.go index 1025a0f878..165b096c90 100644 --- a/pkg/actions/get.go +++ b/pkg/actions/get.go @@ -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" @@ -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 } @@ -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 diff --git a/pkg/actions/list.go b/pkg/actions/list.go index 698731589c..ba199276a0 100644 --- a/pkg/actions/list.go +++ b/pkg/actions/list.go @@ -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" @@ -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 @@ -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 diff --git a/pkg/actions/patch.go b/pkg/actions/patch.go index 01db5ec5b6..4c3242b1a5 100644 --- a/pkg/actions/patch.go +++ b/pkg/actions/patch.go @@ -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" @@ -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) diff --git a/pkg/cmd/pipelinerun/logs.go b/pkg/cmd/pipelinerun/logs.go index 119e6a4ebd..119399bde3 100644 --- a/pkg/cmd/pipelinerun/logs.go +++ b/pkg/cmd/pipelinerun/logs.go @@ -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 diff --git a/pkg/cmd/pipelinerun/logs_test.go b/pkg/cmd/pipelinerun/logs_test.go index 20aba0356d..37eff1790c 100644 --- a/pkg/cmd/pipelinerun/logs_test.go +++ b/pkg/cmd/pipelinerun/logs_test.go @@ -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", diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go index 5cfb806ecd..39248384bd 100644 --- a/pkg/cmd/root.go +++ b/pkg/cmd/root.go @@ -15,6 +15,7 @@ package cmd import ( + "encoding/json" "fmt" "os" @@ -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" ) @@ -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) @@ -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":"","code":}. +// Otherwise the standard "Error: \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()) diff --git a/pkg/exitcode/exitcode.go b/pkg/exitcode/exitcode.go new file mode 100644 index 0000000000..523501956c --- /dev/null +++ b/pkg/exitcode/exitcode.go @@ -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 +} diff --git a/pkg/exitcode/exitcode_test.go b/pkg/exitcode/exitcode_test.go new file mode 100644 index 0000000000..70e5f78cfb --- /dev/null +++ b/pkg/exitcode/exitcode_test.go @@ -0,0 +1,91 @@ +// 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_test + +import ( + "errors" + "fmt" + "testing" + + "github.com/tektoncd/cli/pkg/exitcode" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +func TestCodeFrom_nil(t *testing.T) { + if got := exitcode.CodeFrom(nil); got != exitcode.Success { + t.Errorf("CodeFrom(nil) = %d, want %d", got, exitcode.Success) + } +} + +func TestCodeFrom_plain_error(t *testing.T) { + if got := exitcode.CodeFrom(fmt.Errorf("boom")); got != exitcode.GeneralError { + t.Errorf("CodeFrom(plain) = %d, want %d", got, exitcode.GeneralError) + } +} + +func TestCodeFrom_exitcode_error(t *testing.T) { + err := exitcode.New(exitcode.NotFound, "thing not found") + if got := exitcode.CodeFrom(err); got != exitcode.NotFound { + t.Errorf("CodeFrom(Error{NotFound}) = %d, want %d", got, exitcode.NotFound) + } +} + +func TestCodeFrom_wrapped_exitcode_error(t *testing.T) { + base := exitcode.New(exitcode.Unauthorized, "forbidden") + wrapped := fmt.Errorf("outer: %w", base) + if got := exitcode.CodeFrom(wrapped); got != exitcode.Unauthorized { + t.Errorf("CodeFrom(wrapped Unauthorized) = %d, want %d", got, exitcode.Unauthorized) + } +} + +func TestFromAPIError_nil(t *testing.T) { + if err := exitcode.FromAPIError(nil); err != nil { + t.Errorf("FromAPIError(nil) = %v, want nil", err) + } +} + +func TestFromAPIError_notFound(t *testing.T) { + gr := schema.GroupResource{Group: "tekton.dev", Resource: "pipelineruns"} + k8sErr := k8serrors.NewNotFound(gr, "my-pr") + err := exitcode.FromAPIError(k8sErr) + var e *exitcode.Error + if !errors.As(err, &e) { + t.Fatal("expected exitcode.Error") + } + if e.Code != exitcode.NotFound { + t.Errorf("Code = %d, want %d", e.Code, exitcode.NotFound) + } +} + +func TestFromAPIError_forbidden(t *testing.T) { + gr := schema.GroupResource{Group: "tekton.dev", Resource: "tasks"} + k8sErr := k8serrors.NewForbidden(gr, "my-task", fmt.Errorf("forbidden")) + err := exitcode.FromAPIError(k8sErr) + var e *exitcode.Error + if !errors.As(err, &e) { + t.Fatal("expected exitcode.Error") + } + if e.Code != exitcode.Unauthorized { + t.Errorf("Code = %d, want %d", e.Code, exitcode.Unauthorized) + } +} + +func TestFromAPIError_generic(t *testing.T) { + plain := fmt.Errorf("connection refused") + if got := exitcode.FromAPIError(plain); got != plain { + t.Errorf("FromAPIError(generic) changed the error, want original") + } +}