diff --git a/README.md b/README.md index 5e681475..4ee5b2ef 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,28 @@ microcks [command] [flags] | `--microcksURL` | Microcks API URL | +### Exit codes + +`microcks` returns a distinct exit code per outcome, so CI can branch on *why* a +run failed — for example retry a flaky runner but fail the build on a broken +contract: + +| Code | Meaning | +| ---- | ------- | +| 0 | Success, or the contract test conformed | +| 1 | Contract test failed — a clean run whose result did not conform | +| 2 | Usage — bad arguments or flags | +| 11 | Connection — could not reach the Microcks or Keycloak endpoint | +| 12 | API — a server rejected the request or returned an unusable response | +| 13 | Not found — a requested resource does not exist | +| 14 | Environment — a local precondition failed (container runtime, image, readiness) | +| 20 | Generic — an unclassified failure | + +`1` means the tool ran fine and the API violated its contract — not that the tool +errored. Codes `0`/`1`/`2` follow the common [Unix exit-status convention](https://en.wikipedia.org/wiki/Exit_status); +`11`–`20` are Microcks-CLI–specific. See [documentation/error-handling.md](documentation/error-handling.md). + + ### Local contract testing without a server `microcks test --dry-run` runs a contract test with zero infrastructure: no running Microcks server, no Keycloak credentials, no upfront import. The CLI spins up an ephemeral Microcks container (via [Testcontainers](https://microcks.io/documentation/guides/usage/developing-testcontainers/)), imports your spec, runs the test against your endpoint, prints the result and tears the container down. diff --git a/cmd/cmd.go b/cmd/cmd.go index 33b6733c..ec20c97c 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -18,18 +18,20 @@ package cmd import ( "github.com/microcks/microcks-cli/pkg/config" "github.com/microcks/microcks-cli/pkg/connectors" - "github.com/microcks/microcks-cli/pkg/errors" "github.com/spf13/cobra" ) -func NewCommand() *cobra.Command { +func NewCommand() (*cobra.Command, error) { var clientOpts connectors.ClientOptions command := &cobra.Command{ - Use: "microcks", - Short: "A CLI tool for Microcks", - SilenceUsage: true, + Use: "microcks", + Short: "A CLI tool for Microcks", + // cmd.Handle (called by main) is the single point that prints errors and + // exits, so Cobra must not also print them. + SilenceUsage: true, + SilenceErrors: true, Run: func(cmd *cobra.Command, args []string) { cmd.HelpFunc()(cmd, args) }, @@ -50,7 +52,9 @@ func NewCommand() *cobra.Command { command.AddCommand(NewLogoutCommand(&clientOpts)) defaultLocalConfigPath, err := config.DefaultLocalConfigPath() - errors.CheckError(err) + if err != nil { + return nil, err + } command.PersistentFlags().StringVar(&clientOpts.ConfigPath, "config", defaultLocalConfigPath, "Path to Microcks config") command.PersistentFlags().StringVar(&clientOpts.Context, "microcks-context", "", "Name of the Microcks context to use") command.PersistentFlags().BoolVar(&clientOpts.Verbose, "verbose", false, "Produce dumps of HTTP exchanges") @@ -61,5 +65,5 @@ func NewCommand() *cobra.Command { command.PersistentFlags().StringVar(&clientOpts.ServerAddr, "microcksURL", "", "Microcks API URL") command.MarkFlagsRequiredTogether("keycloakClientId", "keycloakClientSecret") - return command + return command, nil } diff --git a/cmd/context.go b/cmd/context.go index f88f9c31..a83488df 100644 --- a/cmd/context.go +++ b/cmd/context.go @@ -27,38 +27,40 @@ microcks context http://localhost:8080 # Delete Microcks context microcks context http://localhost:8080 --delete`, - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { configPath := globalClientOpts.ConfigPath localCfg, err := config.ReadLocalConfig(configPath) - errors.CheckError(err) + if err != nil { + return err + } if delete { if len(args) == 0 { - cmd.HelpFunc()(cmd, args) - os.Exit(1) + return errors.Wrapf(errors.KindUsage, "context --delete requires a CONTEXT argument") } - err := deleteContext(args[0], configPath) - errors.CheckError(err) - return + return deleteContext(args[0], configPath) } if len(args) == 0 { - printMicrocksContexts(configPath) - return + return printMicrocksContexts(configPath) } ctxName := args[0] - errors.CheckConfigNil(localCfg == nil, configPath) + if localCfg == nil { + return errors.Wrapf(errors.KindUsage, "no contexts defined in %s", configPath) + } if localCfg.CurrentContext == ctxName { fmt.Printf("Already at context '%s'\n", localCfg.CurrentContext) - return + return nil } if _, err = localCfg.ResolveContext(ctxName); err != nil { - log.Fatal(err) + return errors.Wrap(errors.KindNotFound, err) } localCfg.CurrentContext = ctxName - err = config.WriteLocalConfig(*localCfg, configPath) - errors.CheckError(err) + if err := config.WriteLocalConfig(*localCfg, configPath); err != nil { + return err + } fmt.Printf("Switched to context '%s'\n", localCfg.CurrentContext) + return nil }, } @@ -69,44 +71,52 @@ microcks context http://localhost:8080 --delete`, func deleteContext(context, configPath string) error { localCfg, err := config.ReadLocalConfig(configPath) - errors.CheckError(err) + if err != nil { + return err + } if localCfg == nil { - return fmt.Errorf("Nothing to logout from") + return errors.Wrapf(errors.KindUsage, "nothing to delete") } serverName, ok := localCfg.RemoveContext(context) if !ok { - return fmt.Errorf("Context %s does not exist", context) + return errors.Wrapf(errors.KindNotFound, "context %q does not exist", context) } _ = localCfg.RemoveUser(context) _ = localCfg.RemoveServer(serverName) if localCfg.IsEmpty() { - err := localCfg.DeleteLocalConfig(configPath) - errors.CheckError(err) + if err := localCfg.DeleteLocalConfig(configPath); err != nil { + return err + } } else { if localCfg.CurrentContext == context { localCfg.CurrentContext = "" } - err = config.ValidateLocalConfig(*localCfg) - if err != nil { - return fmt.Errorf("Error in logging out") + if err := config.ValidateLocalConfig(*localCfg); err != nil { + return err + } + if err := config.WriteLocalConfig(*localCfg, configPath); err != nil { + return err } - err = config.WriteLocalConfig(*localCfg, configPath) - errors.CheckError(err) } fmt.Printf("Context '%s' deleted\n", context) return nil } -func printMicrocksContexts(configPath string) { +func printMicrocksContexts(configPath string) error { localCfg, err := config.ReadLocalConfig(configPath) - errors.CheckError(err) - errors.CheckConfigNil(localCfg == nil, configPath) + if err != nil { + return err + } + if localCfg == nil { + return errors.Wrapf(errors.KindUsage, "no contexts defined in %s", configPath) + } w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) defer func() { _ = w.Flush() }() columnNames := []string{"CURRENT", "NAME", "SERVER"} - _, err = fmt.Fprintf(w, "%s\n", strings.Join(columnNames, "\t")) - errors.CheckError(err) + if _, err = fmt.Fprintf(w, "%s\n", strings.Join(columnNames, "\t")); err != nil { + return err + } for _, contextRef := range localCfg.Contexts { context, err := localCfg.ResolveContext(contextRef.Name) @@ -117,7 +127,9 @@ func printMicrocksContexts(configPath string) { if localCfg.CurrentContext == context.Name { prefix = "*" } - _, err = fmt.Fprintf(w, "%s\t%s\t%s\n", prefix, context.Name, context.Server.Server) - errors.CheckError(err) + if _, err = fmt.Fprintf(w, "%s\t%s\t%s\n", prefix, context.Name, context.Server.Server); err != nil { + return err + } } + return nil } diff --git a/cmd/context_test.go b/cmd/context_test.go index 937ed00b..396a3b91 100644 --- a/cmd/context_test.go +++ b/cmd/context_test.go @@ -40,6 +40,7 @@ const testConfigFilePath = "./testdata/local.config" func TestDeleteContext(t *testing.T) { //write the test config file + require.NoError(t, os.MkdirAll("./testdata", 0o750)) err := os.WriteFile(testConfigFilePath, []byte(testConfig), os.ModePerm) require.NoError(t, err) @@ -52,7 +53,7 @@ func TestDeleteContext(t *testing.T) { //Delete non-existing context err = deleteContext("microcks.io", testConfigFilePath) - require.EqualError(t, err, "Context microcks.io does not exist") + require.EqualError(t, err, `context "microcks.io" does not exist`) //Delete non-current context err = deleteContext("http://localhost:8080", testConfigFilePath) @@ -67,6 +68,5 @@ func TestDeleteContext(t *testing.T) { func TestDeleteContextEmpty(t *testing.T) { err := deleteContext("http://localhost:8080", "./testdata/non-existent-file.config") - require.EqualError(t, err, "Nothing to logout from") + require.EqualError(t, err, "nothing to delete") } - diff --git a/cmd/exit.go b/cmd/exit.go new file mode 100644 index 00000000..f7523e34 --- /dev/null +++ b/cmd/exit.go @@ -0,0 +1,76 @@ +/* + * Copyright The Microcks 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 cmd + +import ( + stderrors "errors" + "fmt" + "io" + "os" + + "github.com/microcks/microcks-cli/pkg/errors" +) + +// exitCodes maps each Failure Kind to a process exit status. This is the only +// place that knows about exit codes; pkg/* speaks in Failure Kinds. +var exitCodes = map[errors.Kind]int{ + errors.KindGeneric: 20, + errors.KindUsage: 2, + errors.KindConnection: 11, + errors.KindAPI: 12, + errors.KindNotFound: 13, + errors.KindEnvironment: 14, +} + +// ExitCodeFor returns the process exit code for err (0 when nil). Pure and +// testable; Handle is the side-effecting wrapper main() calls. +func ExitCodeFor(err error) int { + switch { + case err == nil: + return 0 + case stderrors.Is(err, errors.ErrTestFailed): + // A clean run with a non-conforming result — pytest-style exit 1. + return 1 + default: + if code, ok := exitCodes[errors.KindOf(err)]; ok { + return code + } + // A non-nil error must never exit 0; fall back to generic. + return exitCodes[errors.KindGeneric] + } +} + +// Handle is the single exit point for the CLI. It prints err to stderr (except +// the silent ErrTestFailed sentinel, whose result the command already rendered) +// and exits with the mapped code. main() calls this and nothing else exits. +func Handle(err error) { + if err == nil { + return + } + printError(os.Stderr, err) + os.Exit(ExitCodeFor(err)) +} + +func printError(w io.Writer, err error) { + if err == nil || stderrors.Is(err, errors.ErrTestFailed) { + return + } + var usageErr *usageError + if stderrors.As(err, &usageErr) && usageErr.usage != "" { + fmt.Fprint(w, usageErr.usage) + } + fmt.Fprintln(w, err) +} diff --git a/cmd/exit_test.go b/cmd/exit_test.go new file mode 100644 index 00000000..e0c5c025 --- /dev/null +++ b/cmd/exit_test.go @@ -0,0 +1,81 @@ +/* + * Copyright The Microcks 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 cmd + +import ( + "bytes" + stderrors "errors" + "fmt" + "strings" + "testing" + + "github.com/microcks/microcks-cli/pkg/errors" +) + +func TestExitCodeFor(t *testing.T) { + cases := []struct { + name string + err error + want int + }{ + {"nil", nil, 0}, + {"test failed", errors.ErrTestFailed, 1}, + {"test failed wrapped", fmt.Errorf("run: %w", errors.ErrTestFailed), 1}, + {"usage", errors.Wrap(errors.KindUsage, stderrors.New("bad flag")), 2}, + {"connection", errors.Wrap(errors.KindConnection, stderrors.New("refused")), 11}, + {"api", errors.Wrap(errors.KindAPI, stderrors.New("500")), 12}, + {"not found", errors.Wrap(errors.KindNotFound, stderrors.New("404")), 13}, + {"environment", errors.Wrap(errors.KindEnvironment, stderrors.New("no docker")), 14}, + {"generic", stderrors.New("boom"), 20}, + } + for _, c := range cases { + if got := ExitCodeFor(c.err); got != c.want { + t.Errorf("%s: ExitCodeFor = %d, want %d", c.name, got, c.want) + } + } +} + +func TestPrintErrorIncludesUsageFromUsageError(t *testing.T) { + cmd, err := NewCommand() + if err != nil { + t.Fatalf("NewCommand returned error: %v", err) + } + importCmd, _, err := cmd.Find([]string{"import"}) + if err != nil { + t.Fatalf("could not find import command: %v", err) + } + + var out bytes.Buffer + printError(&out, usageErrorf(importCmd, "import requires a file")) + + got := out.String() + if !strings.Contains(got, "Usage:") { + t.Fatalf("printError did not include usage: %q", got) + } + if !strings.Contains(got, "microcks import") { + t.Fatalf("printError did not include import usage: %q", got) + } + if !strings.Contains(got, "import requires a file") { + t.Fatalf("printError did not include error: %q", got) + } +} + +func TestUsageErrorIsKindUsage(t *testing.T) { + err := usageErrorf(nil, "bad args") + if got := errors.KindOf(err); got != errors.KindUsage { + t.Fatalf("KindOf = %v, want KindUsage", got) + } +} diff --git a/cmd/import.go b/cmd/import.go index 2a8f8731..5ff6bf42 100644 --- a/cmd/import.go +++ b/cmd/import.go @@ -17,7 +17,6 @@ package cmd import ( "fmt" - "os" "strconv" "strings" @@ -36,12 +35,10 @@ func NewImportCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command Short: "import API artifacts on Microcks server", Long: `import API artifacts on Microcks server`, Args: cobra.MaximumNArgs(1), - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { // Parse subcommand args first. if len(args) == 0 { - fmt.Println("import command require args") - cmd.HelpFunc()(cmd, args) - os.Exit(1) + return usageErrorf(cmd, "import requires a argument") } specificationFiles := args[0] @@ -54,8 +51,7 @@ func NewImportCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command // Read local config file in case we need some context info. localConfig, err := config.ReadLocalConfig(globalClientOpts.ConfigPath) if err != nil { - fmt.Println(err) - os.Exit(1) + return err } // Prepare Microcks client. @@ -63,25 +59,29 @@ func NewImportCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command if globalClientOpts.ServerAddr != "" && globalClientOpts.ClientId != "" && globalClientOpts.ClientSecret != "" { // Create client with server address. - mc = connectors.NewMicrocksClient(globalClientOpts.ServerAddr) + var err error + mc, err = connectors.NewMicrocksClient(globalClientOpts.ServerAddr) + if err != nil { + return err + } keycloakURL, err := mc.GetKeycloakURL() if err != nil { - fmt.Printf("Got error when invoking Microcks client retrieving config: %s", err) - os.Exit(1) + return err } - var oauthToken string = "unauthenticated-token" + oauthToken := "unauthenticated-token" if keycloakURL != "null" { // If Keycloak is enabled, retrieve an OAuth token using Keycloak Client. - kc := connectors.NewKeycloakClient(keycloakURL, globalClientOpts.ClientId, globalClientOpts.ClientSecret) + kc, err := connectors.NewKeycloakClient(keycloakURL, globalClientOpts.ClientId, globalClientOpts.ClientSecret) + if err != nil { + return err + } oauthToken, err = kc.ConnectAndGetToken() if err != nil { - fmt.Printf("Got error when invoking Keycloak client: %s", err) - os.Exit(1) + return err } - //fmt.Printf("Retrieve OAuthToken: %s", oauthToken) } // Set Auth token. @@ -100,8 +100,7 @@ func NewImportCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command } else { // Create client from config file and using the current or provided context. if localConfig == nil { - fmt.Println("Please login to perform operation...") - os.Exit(1) + return errors.Wrapf(errors.KindUsage, "please login to perform this operation") } if globalClientOpts.Context == "" { @@ -110,8 +109,7 @@ func NewImportCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command mc, err = connectors.NewClient(*globalClientOpts) if err != nil { - fmt.Printf("error %v", err) - os.Exit(1) + return err } } @@ -134,8 +132,7 @@ func NewImportCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command // Try uploading this artifact. msg, err := mc.UploadArtifact(f, mainArtifact) if err != nil { - fmt.Printf("Got error when invoking Microcks client importing Artifact: %s", err) - os.Exit(1) + return err } action := "discovered" if !mainArtifact { @@ -146,10 +143,14 @@ func NewImportCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command // If watch flag is provided, update watch config. if watch { watchFile, err := config.DefaultLocalWatchPath() - errors.CheckError(err) + if err != nil { + return err + } watchCfg, err := config.ReadLocalWatchConfig(watchFile) - errors.CheckError(err) + if err != nil { + return err + } if watchCfg == nil { watchCfg = &config.WatchConfig{} } @@ -167,22 +168,28 @@ func NewImportCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command }) // Write watch file. - err = config.WriteLocalWatchConfig(*watchCfg, watchFile) - errors.CheckError(err) + if err := config.WriteLocalWatchConfig(*watchCfg, watchFile); err != nil { + return err + } } } // Start watcher if --watch flag is provided. if watch { watchFile, err := config.DefaultLocalWatchPath() - errors.CheckError(err) + if err != nil { + return err + } wm, err := watcher.NewWatchManger(watchFile) - errors.CheckError(err) + if err != nil { + return err + } fmt.Println("Watch mode enabled - microcks-watcher started...") wm.Run() } + return nil }, } diff --git a/cmd/importDir.go b/cmd/importDir.go index 081f4924..a38c0a62 100644 --- a/cmd/importDir.go +++ b/cmd/importDir.go @@ -23,6 +23,7 @@ import ( "github.com/microcks/microcks-cli/pkg/config" "github.com/microcks/microcks-cli/pkg/connectors" + "github.com/microcks/microcks-cli/pkg/errors" "github.com/spf13/cobra" ) @@ -115,11 +116,9 @@ func NewImportDirCommand(globalClientOpts *connectors.ClientOptions) *cobra.Comm microcks import-dir ./api-specs --recursive microcks import-dir ./api-specs --pattern "*.yaml" microcks import-dir ./api-specs --recursive --pattern "openapi.*"`, - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { if len(args) == 0 { - fmt.Println("import-dir command requires a directory path") - cmd.HelpFunc()(cmd, args) - os.Exit(1) + return errors.Wrapf(errors.KindUsage, "import-dir requires a directory path argument") } dirPath := args[0] @@ -130,13 +129,11 @@ func NewImportDirCommand(globalClientOpts *connectors.ClientOptions) *cobra.Comm localConfig, err := config.ReadLocalConfig(globalClientOpts.ConfigPath) if err != nil { - fmt.Println(err) - os.Exit(1) + return err } if localConfig == nil { - fmt.Println("Please login to perform operation...") - os.Exit(1) + return errors.Wrapf(errors.KindUsage, "please login to perform this operation") } if globalClientOpts.Context == "" { @@ -146,8 +143,7 @@ func NewImportDirCommand(globalClientOpts *connectors.ClientOptions) *cobra.Comm // Create client mc, err := connectors.NewClient(*globalClientOpts) if err != nil { - fmt.Printf("error %v", err) - os.Exit(1) + return err } // Set up business logic dependencies @@ -161,12 +157,10 @@ func NewImportDirCommand(globalClientOpts *connectors.ClientOptions) *cobra.Comm // Execute business logic result, err := ImportDirectory(mc, fs, dirPath, importConfig) if err != nil { - if validationErr, ok := err.(*ValidationError); ok { - fmt.Println(validationErr.Message) - os.Exit(1) + if _, ok := err.(*ValidationError); ok { + return errors.Wrap(errors.KindUsage, err) } - fmt.Printf("Error: %v\n", err) - os.Exit(1) + return err } // Display results @@ -197,6 +191,7 @@ func NewImportDirCommand(globalClientOpts *connectors.ClientOptions) *cobra.Comm } fmt.Printf("\nImport completed: %d/%d files imported successfully\n", result.SuccessCount, result.TotalFiles) + return importDirectoryPartialFailure(result) }, } @@ -207,6 +202,13 @@ func NewImportDirCommand(globalClientOpts *connectors.ClientOptions) *cobra.Comm return importDirCmd } +func importDirectoryPartialFailure(result ImportResult) error { + if result.FailedCount == 0 { + return nil + } + return errors.Wrapf(errors.KindAPI, "%d/%d files failed to import", result.FailedCount, result.TotalFiles) +} + func ImportDirectory(client MicrocksClient, fs FileSystem, dirPath string, config ImportConfig) (ImportResult, error) { if err := validateDirectory(fs, dirPath); err != nil { return ImportResult{}, err diff --git a/cmd/importURL.go b/cmd/importURL.go index cb1640f7..d73a7603 100644 --- a/cmd/importURL.go +++ b/cmd/importURL.go @@ -18,12 +18,12 @@ package cmd import ( "fmt" - "os" "strconv" "strings" "github.com/microcks/microcks-cli/pkg/config" "github.com/microcks/microcks-cli/pkg/connectors" + "github.com/microcks/microcks-cli/pkg/errors" "github.com/spf13/cobra" ) @@ -32,11 +32,10 @@ func NewImportURLCommand(globalClientOpts *connectors.ClientOptions) *cobra.Comm Use: "import-url", Short: "import API artifacts from URL on Microcks server", Long: `import API artifacts from URL on Microcks server`, - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { // Parse subcommand args first. if len(args) == 0 { - fmt.Println("import-url command require args") - os.Exit(1) + return errors.Wrapf(errors.KindUsage, "import-url requires a argument") } specificationFiles := args[0] @@ -49,25 +48,29 @@ func NewImportURLCommand(globalClientOpts *connectors.ClientOptions) *cobra.Comm if globalClientOpts.ServerAddr != "" && globalClientOpts.ClientId != "" && globalClientOpts.ClientSecret != "" { // create client with server address - mc = connectors.NewMicrocksClient(globalClientOpts.ServerAddr) + var err error + mc, err = connectors.NewMicrocksClient(globalClientOpts.ServerAddr) + if err != nil { + return err + } keycloakURL, err := mc.GetKeycloakURL() if err != nil { - fmt.Printf("Got error when invoking Microcks client retrieving config: %s", err) - os.Exit(1) + return err } - var oauthToken string = "unauthenticated-token" + oauthToken := "unauthenticated-token" if keycloakURL != "null" { // If Keycloak is enabled, retrieve an OAuth token using Keycloak Client. - kc := connectors.NewKeycloakClient(keycloakURL, globalClientOpts.ClientId, globalClientOpts.ClientSecret) + kc, err := connectors.NewKeycloakClient(keycloakURL, globalClientOpts.ClientId, globalClientOpts.ClientSecret) + if err != nil { + return err + } oauthToken, err = kc.ConnectAndGetToken() if err != nil { - fmt.Printf("Got error when invoking Keycloak client: %s", err) - os.Exit(1) + return err } - //fmt.Printf("Retrieve OAuthToken: %s", oauthToken) } //Set Auth token @@ -76,13 +79,11 @@ func NewImportURLCommand(globalClientOpts *connectors.ClientOptions) *cobra.Comm localConfig, err := config.ReadLocalConfig(globalClientOpts.ConfigPath) if err != nil { - fmt.Println(err) - os.Exit(1) + return err } if localConfig == nil { - fmt.Println("Please login to perform operation...") - os.Exit(1) + return errors.Wrapf(errors.KindUsage, "please login to perform this operation") } if globalClientOpts.Context == "" { @@ -91,8 +92,7 @@ func NewImportURLCommand(globalClientOpts *connectors.ClientOptions) *cobra.Comm mc, err = connectors.NewClient(*globalClientOpts) if err != nil { - fmt.Printf("error %v", err) - os.Exit(1) + return err } } sepSpecificationFiles := strings.Split(specificationFiles, ",") @@ -120,11 +120,11 @@ func NewImportURLCommand(globalClientOpts *connectors.ClientOptions) *cobra.Comm // Try downloading the artifcat msg, err := mc.DownloadArtifact(f, mainArtifact, secret) if err != nil { - fmt.Printf("Got error when invoking Microcks client importing Artifact: %s", err) - os.Exit(1) + return err } fmt.Printf("Microcks has discovered '%s'\n", msg) } + return nil }, } diff --git a/cmd/login.go b/cmd/login.go index 2180504e..0ed60429 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -57,14 +57,13 @@ microcks login http://localhost:8080 --sso --sso-port # Get OAuth URI instead of getting redirect to browser for SSO login microcks login http://localhost:8080 --sso --sso-launch-browser=false `, - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() var server string //Check if server name is provided or not if len(args) != 1 { - cmd.HelpFunc()(cmd, args) - os.Exit(1) + return usageErrorf(cmd, "login requires exactly one SERVER argument") } config.InsecureTLS = globalClientOpts.InsecureTLS @@ -72,11 +71,13 @@ microcks login http://localhost:8080 --sso --sso-launch-browser=false config.Verbose = globalClientOpts.Verbose server = args[0] - mc := connectors.NewMicrocksClient(server) + mc, err := connectors.NewMicrocksClient(server) + if err != nil { + return err + } keycloakUrl, err := mc.GetKeycloakURL() - if err != nil { - log.Fatal(err) + return err } if ctxName == "" { @@ -94,9 +95,13 @@ microcks login http://localhost:8080 --sso --sso-launch-browser=false } configFile, err := config.DefaultLocalConfigPath() - errors.CheckError(err) + if err != nil { + return err + } localConfig, err := config.ReadLocalConfig(configFile) - errors.CheckError(err) + if err != nil { + return err + } if localConfig == nil { localConfig = &config.LocalConfig{} @@ -116,20 +121,30 @@ microcks login http://localhost:8080 --sso --sso-launch-browser=false clientSecret := os.Getenv("MICROCKS_CLIENT_SECRET") if clientID == "" || clientSecret == "" { - fmt.Printf("Please Set 'MICROCKS_CLIENT_ID' & 'MICROCKS_CLIENT_SECRET' to perform password login\n") - os.Exit(1) + return errors.Wrapf(errors.KindUsage, "please set 'MICROCKS_CLIENT_ID' & 'MICROCKS_CLIENT_SECRET' to perform password login") } //Perform login and retrive tokens - authToken, refreshToken = passwordLogin(keycloakUrl, clientID, clientSecret, username, password) + authToken, refreshToken, err = passwordLogin(keycloakUrl, clientID, clientSecret, username, password) + if err != nil { + return err + } authCfg.ClientId = clientID authCfg.ClientSecret = clientSecret } else { httpClient := mc.HttpClient() ctx = oidc.ClientContext(ctx, httpClient) - kc := connectors.NewKeycloakClient(keycloakUrl, "", "") + kc, err := connectors.NewKeycloakClient(keycloakUrl, "", "") + if err != nil { + return err + } oauth2conf, err := kc.GetOIDCConfig() - errors.CheckError(err) - authToken, refreshToken = oauth2login(ctx, ssoProt, oauth2conf, ssoLaunchBrowser) + if err != nil { + return err + } + authToken, refreshToken, err = oauth2login(ctx, ssoProt, oauth2conf, ssoLaunchBrowser) + if err != nil { + return err + } authCfg.ClientId = "microcks-app-js" } @@ -166,10 +181,12 @@ microcks login http://localhost:8080 --sso --sso-launch-browser=false User: server, }) - err = config.WriteLocalConfig(*localConfig, configFile) - errors.CheckError(err) + if err := config.WriteLocalConfig(*localConfig, configFile); err != nil { + return err + } fmt.Printf("Context '%s' updated\n", ctxName) + return nil }, } @@ -188,7 +205,7 @@ func oauth2login( port int, oauth2conf *oauth2.Config, ssoLaunchBrowser bool, -) (string, string) { +) (string, string, error) { oauth2conf.ClientID = "microcks-app-js" oauth2conf.RedirectURL = fmt.Sprintf("http://localhost:%d/auth/callback", port) @@ -198,7 +215,9 @@ func oauth2login( completionChan := make(chan string) stateNonce, err := rand.String(24) - errors.CheckError(err) + if err != nil { + return "", "", err + } var tokenString string var refreshToken string @@ -212,7 +231,9 @@ func oauth2login( 43, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~", ) - errors.CheckError(err) + if err != nil { + return "", "", err + } codeChallengeHash := sha256.Sum256([]byte(codeVerifier)) codeChallenge := base64.RawURLEncoding.EncodeToString(codeChallengeHash[:]) @@ -279,76 +300,93 @@ func oauth2login( authBaseURL := strings.SplitN(url, "?", 2)[0] fmt.Printf("Performing %s flow login: %s\n", "authorization_code", authBaseURL) time.Sleep(1 * time.Second) - ssoAuthFlow(url, ssoLaunchBrowser) + if err := ssoAuthFlow(url, ssoLaunchBrowser); err != nil { + return "", "", err + } go func() { log.Printf("Listen: %s\n", srv.Addr) if err := srv.ListenAndServe(); err != http.ErrServerClosed { - log.Fatalf("Temporary HTTP server failed: %s", err) + completionChan <- fmt.Sprintf("temporary HTTP server failed: %s", err) } }() errMsg := <-completionChan if errMsg != "" { - log.Fatal(errMsg) + return "", "", errors.Wrapf(errors.KindGeneric, "%s", errMsg) } fmt.Printf("Authentication successful\n") ctx, cancel := context.WithTimeout(ctx, 1*time.Second) defer cancel() _ = srv.Shutdown(ctx) - return tokenString, refreshToken + return tokenString, refreshToken, nil } -func ssoAuthFlow(url string, ssoLaunchBrowser bool) { +func ssoAuthFlow(url string, ssoLaunchBrowser bool) error { if ssoLaunchBrowser { fmt.Printf("Opening system default browser for authentication\n") - err := open.Start(url) - errors.CheckError(err) + if err := open.Start(url); err != nil { + return err + } } else { fmt.Printf("To authenticate, copy-and-paste the following URL into your preferred browser: %s\n", url) } + return nil } -func passwordLogin(keycloakURL, clientId, clientSecret, Username, Password string) (string, string) { - kc := connectors.NewKeycloakClient(keycloakURL, clientId, clientSecret) - username, password := promptCredentials(Username, Password) +func passwordLogin(keycloakURL, clientId, clientSecret, Username, Password string) (string, string, error) { + kc, err := connectors.NewKeycloakClient(keycloakURL, clientId, clientSecret) + if err != nil { + return "", "", err + } + username, password, err := promptCredentials(Username, Password) + if err != nil { + return "", "", err + } authToken, refreshToken, err := kc.ConnectAndGetTokenAndRefreshToken(username, password) - if err != nil { - panic(err) + return "", "", err } - return authToken, refreshToken + return authToken, refreshToken, nil } -func promptCredentials(username, password string) (string, string) { - return promptUserName(username), promptPassword(password) +func promptCredentials(username, password string) (string, string, error) { + u, err := promptUserName(username) + if err != nil { + return "", "", err + } + p, err := promptPassword(password) + if err != nil { + return "", "", err + } + return u, p, nil } -func promptUserName(value string) string { +func promptUserName(value string) (string, error) { for value == "" { reader := bufio.NewReader(os.Stdin) fmt.Print("Username" + ": ") valueRaw, err := reader.ReadString('\n') if err != nil { - panic(err) + return "", err } value = strings.TrimSpace(valueRaw) } - return value + return value, nil } -func promptPassword(password string) string { +func promptPassword(password string) (string, error) { for password == "" { fmt.Print("Password: ") passwordRaw, err := term.ReadPassword(int(os.Stdin.Fd())) if err != nil { - panic(err) + return "", err } password = string(passwordRaw) fmt.Print("\n") } - return password + return password, nil } func StringField(claims jwt.MapClaims, fieldName string) string { diff --git a/cmd/logout.go b/cmd/logout.go index b1c638cc..2438f751 100644 --- a/cmd/logout.go +++ b/cmd/logout.go @@ -2,11 +2,10 @@ package cmd import ( "fmt" - "log" - "os" "github.com/microcks/microcks-cli/pkg/config" "github.com/microcks/microcks-cli/pkg/connectors" + "github.com/microcks/microcks-cli/pkg/errors" "github.com/spf13/cobra" ) @@ -22,18 +21,17 @@ microcks logout http://localhost:8080 # Log out from a named context microcks logout dev-context`, - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { if len(args) == 0 { - cmd.HelpFunc()(cmd, args) - os.Exit(1) + return errors.Wrapf(errors.KindUsage, "logout requires a CONTEXT or server URL argument") } target := args[0] - err := logoutContext(target, globalClientOpts.ConfigPath) - if err != nil { - log.Fatal(err) + if err := logoutContext(target, globalClientOpts.ConfigPath); err != nil { + return err } fmt.Printf("Logged out from '%s'\n", target) + return nil }, } @@ -46,7 +44,7 @@ func logoutContext(target, configPath string) error { return err } if localCfg == nil { - return fmt.Errorf("Nothing to logout from") + return errors.Wrapf(errors.KindUsage, "nothing to log out from") } userName := target @@ -55,7 +53,7 @@ func logoutContext(target, configPath string) error { } if ok := localCfg.RemoveToken(userName); !ok { - return fmt.Errorf("Context %s does not exist", target) + return errors.Wrapf(errors.KindNotFound, "context %q does not exist", target) } err = config.ValidateLocalConfig(*localCfg) diff --git a/cmd/start.go b/cmd/start.go index 1d5066d7..9f544686 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -2,7 +2,6 @@ package cmd import ( "fmt" - "log" "net/http" "time" @@ -36,11 +35,13 @@ microcks start --driver [driver you wnat either 'docker' or 'podman'] # Define name of your microcks container/instance microcks start --name [name of you container/instance]`, - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { configFile := globalClientOpts.ConfigPath localConfig, err := config.ReadLocalConfig(configFile) - errors.CheckError(err) + if err != nil { + return err + } if localConfig == nil { localConfig = &config.LocalConfig{} @@ -60,10 +61,14 @@ microcks start --name [name of you container/instance]`, instanceDriver = driver } containerClient, err := connectors.NewContainerClient(instanceDriver) - errors.CheckError(err) + if err != nil { + return errors.Wrap(errors.KindEnvironment, err) + } exists, err := containerClient.ContainerExists(instance.ContainerID) containerClient.CloseClient() - errors.CheckError(err) + if err != nil { + return errors.Wrap(errors.KindEnvironment, err) + } if !exists { fmt.Printf("Container for instance %s no longer exists, recreating it\n", name) instance.Status = "" @@ -74,20 +79,23 @@ microcks start --name [name of you container/instance]`, switch instance.Status { case "Running": fmt.Printf("Microcks instance with name %s is already running", name) - return + return nil case "Exited": containerClient, err := connectors.NewContainerClient(instance.Driver) - errors.CheckError(err) + if err != nil { + return errors.Wrap(errors.KindEnvironment, err) + } defer containerClient.CloseClient() if err := containerClient.StartContainer(instance.ContainerID); err != nil { - log.Fatalf("failed to start container: %v", err) - return + return errors.Wrap(errors.KindEnvironment, fmt.Errorf("failed to start container: %w", err)) } instance.Status = "Running" default: containerClient, err := connectors.NewContainerClient(driver) - errors.CheckError(err) + if err != nil { + return errors.Wrap(errors.KindEnvironment, err) + } defer containerClient.CloseClient() containerId, err := containerClient.CreateContainer(connectors.ContainerOpts{ @@ -97,13 +105,11 @@ microcks start --name [name of you container/instance]`, AutoRemove: autoRemove, }) if err != nil { - log.Fatalf("Failed to create a container: %v", err) - return + return errors.Wrap(errors.KindEnvironment, fmt.Errorf("failed to create container: %w", err)) } if err := containerClient.StartContainer(containerId); err != nil { - log.Fatalf("failed to start container: %v", err) - return + return errors.Wrap(errors.KindEnvironment, fmt.Errorf("failed to start container: %w", err)) } instance.ContainerID = containerId @@ -156,8 +162,9 @@ microcks start --name [name of you container/instance]`, }) // Save configs to config file - err = config.WriteLocalConfig(*localConfig, configFile) - errors.CheckError(err) + if err := config.WriteLocalConfig(*localConfig, configFile); err != nil { + return err + } // The container being up doesn't mean the Microcks server inside // is serving traffic yet: wait until HTTP is actually answering @@ -165,12 +172,13 @@ microcks start --name [name of you container/instance]`, if !noWait { fmt.Printf("Waiting for Microcks to be ready at %s ...\n", server) if err := waitForReady(server, readyTimeout); err != nil { - log.Fatalf("Microcks container is started but the server is not ready: %v. "+ - "It may still be booting — retry shortly or raise --ready-timeout.", err) + return errors.Wrapf(errors.KindEnvironment, "Microcks container is started but the server is not ready: %v. "+ + "It may still be booting — retry shortly or raise --ready-timeout", err) } } fmt.Printf("Microcks started successfully at %s\n", server) + return nil }, } startCmd.Flags().StringVar(&name, "name", "microcks", "name for your Microcks instance") diff --git a/cmd/stop.go b/cmd/stop.go index eaba91fd..50a33afc 100644 --- a/cmd/stop.go +++ b/cmd/stop.go @@ -16,34 +16,38 @@ func NewStopCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { Use: "stop", Short: "stop microcks instance", Long: "stop microcks instance", - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { configFile := globalClientOpts.ConfigPath localConfig, err := config.ReadLocalConfig(configFile) - errors.CheckError(err) + if err != nil { + return err + } if localConfig == nil { fmt.Println("Config not found, nothing to stop") - return + return nil } ctx, err := localConfig.ResolveContext("") - errors.CheckError(err) + if err != nil { + return err + } instance := ctx.Instance if instance.Name == "" { fmt.Println("No instance is associated with this context") - return + return nil } containerClient, err := connectors.NewContainerClient(instance.Driver) - errors.CheckError(err) + if err != nil { + return errors.Wrap(errors.KindEnvironment, err) + } defer containerClient.CloseClient() - err = containerClient.StopContainer(instance.ContainerID) - if err != nil { - log.Fatalf("Failed to stop a container: %v", err) - return + if err := containerClient.StopContainer(instance.ContainerID); err != nil { + return errors.Wrap(errors.KindEnvironment, fmt.Errorf("failed to stop container: %w", err)) } fmt.Println("") log.Printf("Instance %s stopped successfully", instance.Name) @@ -53,8 +57,7 @@ func NewStopCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { if instance.AutoRemove { _, ok := localConfig.RemoveContext(ctx.Name) if !ok { - log.Fatalf("Context %s does not exist", ctx.Name) - return + return errors.Wrapf(errors.KindNotFound, "context %q does not exist", ctx.Name) } _ = localConfig.RemoveServer(ctx.Server.Server) _ = localConfig.RemoveUser(ctx.User.Name) @@ -68,8 +71,7 @@ func NewStopCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { localConfig.UpsertInstance(instance) log.Printf("Instance %s status updated to Exited", instance.Name) } - err = config.WriteLocalConfig(*localConfig, configFile) - errors.CheckError(err) + return config.WriteLocalConfig(*localConfig, configFile) }, } diff --git a/cmd/test.go b/cmd/test.go index 6d340bbc..de46db70 100644 --- a/cmd/test.go +++ b/cmd/test.go @@ -17,7 +17,6 @@ package cmd import ( "fmt" - "os" "strconv" "strings" "time" @@ -52,7 +51,7 @@ func NewTestCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { Short: "Run tests on Microcks", Long: `Run tests on Microcks`, Args: cobra.ExactArgs(3), - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { serviceRef := args[0] testEndpoint := args[1] @@ -60,26 +59,21 @@ func NewTestCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { // Validate presence and values of args. if len(serviceRef) == 0 || strings.HasPrefix(serviceRef, "-") { - fmt.Fprintln(os.Stderr, "missing required argument: (e.g. 'my-api:1.0')") - os.Exit(1) + return errors.Wrapf(errors.KindUsage, "missing required argument: (e.g. 'my-api:1.0')") } if len(testEndpoint) == 0 || strings.HasPrefix(testEndpoint, "-") { - fmt.Fprintln(os.Stderr, "missing required argument: (e.g. 'http://localhost:8080/api')") - os.Exit(1) + return errors.Wrapf(errors.KindUsage, "missing required argument: (e.g. 'http://localhost:8080/api')") } if len(runnerType) == 0 || strings.HasPrefix(runnerType, "-") { - fmt.Fprintln(os.Stderr, "missing required argument: (e.g. 'HTTP', 'POSTMAN', 'OPEN_API_SCHEMA')") - os.Exit(1) + return errors.Wrapf(errors.KindUsage, "missing required argument: (e.g. 'HTTP', 'POSTMAN', 'OPEN_API_SCHEMA')") } if _, validChoice := runnerChoices[runnerType]; !validChoice { - fmt.Println(" should be one of: HTTP, SOAP_HTTP, SOAP_UI, POSTMAN, OPEN_API_SCHEMA, ASYNC_API_SCHEMA, GRPC_PROTOBUF, GRAPHQL_SCHEMA") - os.Exit(1) + return errors.Wrapf(errors.KindUsage, " should be one of: HTTP, SOAP_HTTP, SOAP_UI, POSTMAN, OPEN_API_SCHEMA, ASYNC_API_SCHEMA, GRPC_PROTOBUF, GRAPHQL_SCHEMA") } // Validate presence and values of flags. if !strings.HasSuffix(waitFor, "milli") && !strings.HasSuffix(waitFor, "sec") && !strings.HasSuffix(waitFor, "min") { - fmt.Println("--waitFor format is wrong. Accepted units are: milli, sec, min (e.g. 500milli, 30sec, 5min)") - os.Exit(1) + return errors.Wrapf(errors.KindUsage, "--waitFor format is wrong. Accepted units are: milli, sec, min (e.g. 500milli, 30sec, 5min)") } // Collect optional HTTPS transport flags. @@ -92,22 +86,19 @@ func NewTestCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { if strings.HasSuffix(waitFor, "milli") { n, err := strconv.ParseInt(waitFor[:len(waitFor)-5], 0, 64) if err != nil { - fmt.Printf("--waitFor value %q is not a valid number\n", waitFor) - os.Exit(1) + return errors.Wrapf(errors.KindUsage, "--waitFor value %q is not a valid number", waitFor) } waitForMilliseconds = n } else if strings.HasSuffix(waitFor, "sec") { n, err := strconv.ParseInt(waitFor[:len(waitFor)-3], 0, 64) if err != nil { - fmt.Printf("--waitFor value %q is not a valid number\n", waitFor) - os.Exit(1) + return errors.Wrapf(errors.KindUsage, "--waitFor value %q is not a valid number", waitFor) } waitForMilliseconds = n * 1000 } else if strings.HasSuffix(waitFor, "min") { n, err := strconv.ParseInt(waitFor[:len(waitFor)-3], 0, 64) if err != nil { - fmt.Printf("--waitFor value %q is not a valid number\n", waitFor) - os.Exit(1) + return errors.Wrapf(errors.KindUsage, "--waitFor value %q is not a valid number", waitFor) } waitForMilliseconds = n * 60 * 1000 } @@ -125,32 +116,26 @@ func NewTestCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { if !dryRun { if artifact != "" { - fmt.Println("--artifact is only valid together with --dry-run") - os.Exit(1) + return errors.Wrapf(errors.KindUsage, "--artifact is only valid together with --dry-run") } if watch { - fmt.Println("--watch is only valid together with --dry-run") - os.Exit(1) + return errors.Wrapf(errors.KindUsage, "--watch is only valid together with --dry-run") } if driver != "" { - fmt.Println("--driver is only valid together with --dry-run") - os.Exit(1) + return errors.Wrapf(errors.KindUsage, "--driver is only valid together with --dry-run") } } if dryRun { // Ephemeral path: no server, no Keycloak, no prior import needed. - if !runDryRunTest(dryRunOptions{ + return runDryRunTest(dryRunOptions{ artifact: artifact, image: image, readyTimeout: readyTimeout, watch: watch, driver: driver, params: params, - }) { - os.Exit(1) - } - return + }) } var mc connectors.MicrocksClient @@ -160,25 +145,29 @@ func NewTestCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { // create client with server address serverAddr = globalClientOpts.ServerAddr - mc = connectors.NewMicrocksClient(serverAddr) + var err error + mc, err = connectors.NewMicrocksClient(serverAddr) + if err != nil { + return err + } keycloakURL, err := mc.GetKeycloakURL() if err != nil { - fmt.Printf("Got error when invoking Microcks client retrieving config: %s", err) - os.Exit(1) + return err } - var oauthToken string = "unauthenticated-token" + oauthToken := "unauthenticated-token" if keycloakURL != "null" { // If Keycloak is enabled, retrieve an OAuth token using Keycloak Client. - kc := connectors.NewKeycloakClient(keycloakURL, globalClientOpts.ClientId, globalClientOpts.ClientSecret) + kc, err := connectors.NewKeycloakClient(keycloakURL, globalClientOpts.ClientId, globalClientOpts.ClientSecret) + if err != nil { + return err + } oauthToken, err = kc.ConnectAndGetToken() if err != nil { - fmt.Printf("Got error when invoking Keycloak client: %s", err) - os.Exit(1) + return err } - //fmt.Printf("Retrieve OAuthToken: %s", oauthToken) } // Then - launch the test on Microcks Server. @@ -187,13 +176,11 @@ func NewTestCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { } else { localConfig, err := config.ReadLocalConfig(globalClientOpts.ConfigPath) if err != nil { - fmt.Println(err) - os.Exit(1) + return err } if localConfig == nil { - fmt.Println("Please login to perform operation...") - os.Exit(1) + return errors.Wrapf(errors.KindUsage, "please login to perform this operation") } if globalClientOpts.Context == "" { @@ -202,27 +189,28 @@ func NewTestCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { mc, err = connectors.NewClient(*globalClientOpts) if err != nil { - fmt.Printf("error %v", err) - os.Exit(1) + return err } ctx, err := localConfig.ResolveContext(globalClientOpts.Context) - errors.CheckError(err) + if err != nil { + return errors.Wrap(errors.KindNotFound, err) + } serverAddr = ctx.Server.Server } success, testResultID, err := runTestAndWait(mc, params) if err != nil { - fmt.Print(err) - os.Exit(1) + return err } fmt.Printf("Full TestResult details are available here: %s/#/tests/%s \n", serverAddr, testResultID) if !success { - os.Exit(1) + return errors.ErrTestFailed } + return nil }, } @@ -240,4 +228,3 @@ func NewTestCommand(globalClientOpts *connectors.ClientOptions) *cobra.Command { return testCmd } - diff --git a/cmd/testDryRun.go b/cmd/testDryRun.go index 9d3ff604..5f0144cb 100644 --- a/cmd/testDryRun.go +++ b/cmd/testDryRun.go @@ -30,6 +30,7 @@ import ( "github.com/fsnotify/fsnotify" "github.com/microcks/microcks-cli/pkg/connectors" + "github.com/microcks/microcks-cli/pkg/errors" "github.com/testcontainers/testcontainers-go" microcks "microcks.io/testcontainers-go" ) @@ -64,7 +65,7 @@ func configureDriver(driver string) error { } return nil default: - return fmt.Errorf("unsupported --driver %q (use 'docker' or 'podman')", driver) + return errors.Wrapf(errors.KindUsage, "unsupported --driver %q (use 'docker' or 'podman')", driver) } } @@ -81,15 +82,15 @@ func shouldUsePodman() bool { func setupPodman() error { if err := connectors.ConfigurePodmanHost(); err != nil { - return err + return errors.Wrap(errors.KindEnvironment, err) } // testcontainers-go silently falls back to Docker when the podman endpoint // isn't reachable, which would make "--driver podman" a lie. Verify the // connection now and fail loudly instead. if err := connectors.PingDockerHost(); err != nil { - return fmt.Errorf("--driver podman selected but the podman endpoint is not reachable. "+ + return errors.Wrapf(errors.KindEnvironment, "--driver podman selected but the podman endpoint is not reachable. "+ "Start it with 'podman machine start' (macOS/Windows) or "+ - "'systemctl --user start podman.socket' (Linux). Underlying error: %w", err) + "'systemctl --user start podman.socket' (Linux). Underlying error: %v", err) } // Ryuk (Testcontainers' reaper) needs privileges rootless Podman doesn't // grant; our signal-driven Terminate already guarantees cleanup, so disable @@ -141,16 +142,14 @@ func rewriteLocalEndpoint(testEndpoint string) (string, int, bool) { return u.String(), port, true } -func runDryRunTest(opts dryRunOptions) bool { +func runDryRunTest(opts dryRunOptions) error { if err := validateDryRunOptions(opts); err != nil { - fmt.Println(err) - return false + return errors.Wrap(errors.KindUsage, err) } // Select the container runtime (docker default, podman wired via DOCKER_HOST). if err := configureDriver(opts.driver); err != nil { - fmt.Println(err) - return false + return err } // Ctrl+C / SIGTERM cancels the context so teardown still runs. @@ -175,35 +174,38 @@ func runDryRunTest(opts dryRunOptions) bool { container, err := microcks.Run(startCtx, opts.image, containerOpts...) if err != nil { - fmt.Printf("Failed to start ephemeral Microcks container: %s\n", err) - fmt.Println("Check that the container runtime is running, the port is free and the image is reachable (or raise --ready-timeout).") if container != nil { terminateContainer(container) } - return false + return errors.Wrapf(errors.KindEnvironment, "failed to start ephemeral Microcks container: %v. "+ + "Check that the container runtime is running, the port is free and the image is reachable (or raise --ready-timeout)", err) } defer terminateContainer(container) endpoint, err := container.HttpEndpoint(ctx) if err != nil { - fmt.Printf("Failed to resolve ephemeral Microcks endpoint: %s\n", err) - return false + return errors.Wrapf(errors.KindEnvironment, "failed to resolve ephemeral Microcks endpoint: %v", err) } fmt.Printf("Ephemeral Microcks is ready at %s\n", endpoint) // The uber-native image runs without Keycloak: a headless client with // the unauthenticated token is enough. - mc := connectors.NewMicrocksClient(endpoint) + mc, err := connectors.NewMicrocksClient(endpoint) + if err != nil { + return err + } mc.SetOAuthToken("unauthenticated-token") success, testResultID, err := runTestAndWait(mc, opts.params) if err != nil { - fmt.Println(err) - return false + return err } if !opts.watch { - return success + if success { + return nil + } + return errors.ErrTestFailed } printDetailsLink(endpoint, testResultID) return watchAndRerun(ctx, mc, endpoint, opts) @@ -218,11 +220,10 @@ func terminateContainer(container *microcks.MicrocksContainer) { } } -func watchAndRerun(ctx context.Context, mc connectors.MicrocksClient, serverAddr string, opts dryRunOptions) bool { +func watchAndRerun(ctx context.Context, mc connectors.MicrocksClient, serverAddr string, opts dryRunOptions) error { watcher, err := fsnotify.NewWatcher() if err != nil { - fmt.Printf("Failed to create file watcher: %s\n", err) - return false + return errors.Wrap(errors.KindEnvironment, fmt.Errorf("failed to create file watcher: %w", err)) } defer watcher.Close() @@ -230,12 +231,10 @@ func watchAndRerun(ctx context.Context, mc connectors.MicrocksClient, serverAddr // (rename + create), which silently drops a watch set on the file itself. artifactPath, err := filepath.Abs(opts.artifact) if err != nil { - fmt.Printf("Failed to resolve artifact path: %s\n", err) - return false + return errors.Wrap(errors.KindUsage, fmt.Errorf("failed to resolve artifact path: %w", err)) } if err := watcher.Add(filepath.Dir(artifactPath)); err != nil { - fmt.Printf("Failed to watch %s: %s\n", filepath.Dir(artifactPath), err) - return false + return errors.Wrap(errors.KindEnvironment, fmt.Errorf("failed to watch %s: %w", filepath.Dir(artifactPath), err)) } fmt.Printf("\nWatching %s for changes — press Ctrl+C to stop.\n", opts.artifact) @@ -247,11 +246,11 @@ func watchAndRerun(ctx context.Context, mc connectors.MicrocksClient, serverAddr select { case <-ctx.Done(): fmt.Println("\nStopping watch mode.") - return true + return nil case event, ok := <-watcher.Events: if !ok { - return true + return nil } eventPath, err := filepath.Abs(event.Name) if err != nil || eventPath != artifactPath { @@ -273,7 +272,7 @@ func watchAndRerun(ctx context.Context, mc connectors.MicrocksClient, serverAddr case err, ok := <-watcher.Errors: if !ok { - return true + return nil } fmt.Printf("Watch error: %s\n", err) diff --git a/cmd/testExecutor.go b/cmd/testExecutor.go index 8fc7e575..87aecc5e 100644 --- a/cmd/testExecutor.go +++ b/cmd/testExecutor.go @@ -41,7 +41,7 @@ func runTestAndWait(mc connectors.MicrocksClient, params testParams) (bool, stri testResultID, err := mc.CreateTestResult(params.serviceRef, params.testEndpoint, params.runnerType, params.secretName, params.waitForMillis, params.filteredOperations, params.operationsHeaders, params.oAuth2Context) if err != nil { - return false, "", fmt.Errorf("Got error when invoking Microcks client creating Test: %s", err) + return false, "", fmt.Errorf("creating test: %w", err) } // Finally - wait before checking and loop for some time @@ -55,7 +55,7 @@ func runTestAndWait(mc connectors.MicrocksClient, params testParams) (bool, stri for nowInMilliseconds() < future { testResultSummary, err := mc.GetTestResult(testResultID) if err != nil { - return false, "", fmt.Errorf("Got error when invoking Microcks client check TestResult: %s", err) + return false, "", fmt.Errorf("checking test result: %w", err) } success = testResultSummary.Success inProgress := testResultSummary.InProgress diff --git a/cmd/test_test.go b/cmd/test_test.go index 5cff0962..a73682f0 100644 --- a/cmd/test_test.go +++ b/cmd/test_test.go @@ -38,10 +38,13 @@ func TestTestCommandMissingRunnerWithGlobalFlagsDoesNotPanic(t *testing.T) { "http://localhost:3000", } - err := NewCommand().Execute() + command, err := NewCommand() if err != nil { t.Fatal(err) } + if err := command.Execute(); err != nil { + t.Fatal(err) + } return } diff --git a/cmd/usage.go b/cmd/usage.go new file mode 100644 index 00000000..5d503859 --- /dev/null +++ b/cmd/usage.go @@ -0,0 +1,40 @@ +/* + * Copyright The Microcks 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 cmd + +import ( + "github.com/microcks/microcks-cli/pkg/errors" + "github.com/spf13/cobra" +) + +type usageError struct { + err error + usage string +} + +func (e *usageError) Error() string { return e.err.Error() } +func (e *usageError) Unwrap() error { return e.err } + +func usageErrorf(cmd *cobra.Command, format string, a ...any) error { + usage := "" + if cmd != nil { + usage = cmd.UsageString() + } + return &usageError{ + err: errors.Wrapf(errors.KindUsage, format, a...), + usage: usage, + } +} diff --git a/cmd/version.go b/cmd/version.go index 39897784..d870a3b1 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -27,8 +27,9 @@ func NewVersionCommand() *cobra.Command { Use: "version", Short: "Print the version number of microcks CLI", Long: `Print the version number of microcks CLI`, - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { fmt.Printf("Microcks-CLI %s\n", version.Version) + return nil }, } diff --git a/documentation/error-handling.md b/documentation/error-handling.md new file mode 100644 index 00000000..c902bb65 --- /dev/null +++ b/documentation/error-handling.md @@ -0,0 +1,66 @@ +# Error handling & exit codes + +How the `microcks` CLI reports failures — for users scripting it in CI, and for +contributors adding code. + +## Exit codes + +The CLI returns a distinct exit code per outcome so a pipeline can branch on *why* +a run failed — for example, retry a flaky infrastructure problem but fail the +build on a genuinely broken contract. + +| Code | Meaning | +| ---- | ------- | +| 0 | Success — the operation completed, or the contract test conformed | +| 1 | Contract test failed — a clean run whose result did **not** conform | +| 2 | Usage — bad arguments or flags | +| 11 | Connection — could not reach the Microcks or Keycloak endpoint | +| 12 | API — a server rejected the request or returned an unusable response | +| 13 | Not found — a requested remote resource does not exist | +| 14 | Environment — a local precondition failed (container runtime, image, readiness) | +| 20 | Generic — an unclassified failure | + +Note that `1` means "the tool ran fine and the API violated its contract" — not +"the tool errored". This mirrors `kubectl diff` (0 = no diff, 1 = diff found, +>1 = errored) and pytest (1 = tests failed). It lets CI tell "your API is broken" +apart from "my pipeline is broken". + +## Terminology + +- **Failure Kind** — the category of *why* an operation could not complete + (connection, API, not-found, usage, environment). The library's vocabulary for + failure; it never mentions exit codes. +- **Exit Code** — the integer status the process returns, derived from a Failure + Kind. A CLI-only concept. +- **Conformance / Contract Test Result** — the outcome of a completed test run: + the API either conforms or does not. A *does-not-conform* result is the tool + working correctly, **not** a failure. +- **Environment failure** — a local precondition wasn't met (the container runtime + is down/unreachable, an image can't be pulled, the ephemeral server wasn't ready + in time). Distinct from a *connection* failure, which is about reaching the + Microcks server. + +## How it works + +- **The library (`pkg/*`) never exits or panics on a runtime error.** It returns + errors classified by Failure Kind via `errors.Wrap(kind, err)`. A consumer that + embeds the client (e.g. an editor extension) reads the kind with + `errors.KindOf(err)` — exit codes are not a library concern. +- **Commands use Cobra `RunE`** and return kind-tagged errors instead of exiting. +- **`cmd.Handle`, called only by `main()`, is the single exit point.** It prints + the error to stderr and maps Failure Kind → exit code (`cmd/exit.go`). +- **A non-conforming test is a Result, not a failure.** It travels as the silent + `errors.ErrTestFailed` sentinel: the command already rendered the result, so + `Handle` exits `1` and prints nothing further. + +## Adding code — the rule + +- In `pkg/*`: `return errors.Wrap(errors.KindConnection, err)` (or the fitting + kind). Never `os.Exit`, `panic`, or `log.Fatal`. +- In a command: return the error from `RunE`; don't exit yourself. +- Only `main()` / `cmd.Handle` exits the process. + +Classify at the point with the most context: transport failures (`httpClient.Do`) +are `KindConnection`; non-2xx responses are `KindAPI` (a `404` for a missing +resource is `KindNotFound`); a missing local input file is `KindUsage`; a failed +container runtime is `KindEnvironment`. diff --git a/main.go b/main.go index 860c31c6..99ad6357 100644 --- a/main.go +++ b/main.go @@ -1,16 +1,15 @@ package main import ( - "fmt" - "os" - "github.com/microcks/microcks-cli/cmd" ) func main() { - command := cmd.NewCommand() - if err := command.Execute(); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) + // cmd.Handle is the single exit point: it prints the error to stderr and + // maps its Failure Kind to an exit code. Nothing else in the tree exits. + command, err := cmd.NewCommand() + if err != nil { + cmd.Handle(err) } + cmd.Handle(command.Execute()) } diff --git a/pkg/connectors/keycloak_client.go b/pkg/connectors/keycloak_client.go index f44d567d..ed37aedb 100644 --- a/pkg/connectors/keycloak_client.go +++ b/pkg/connectors/keycloak_client.go @@ -46,14 +46,12 @@ type keycloakClient struct { } // NewKeycloakClient build a new KeycloakClient implementation -func NewKeycloakClient(realmURL string, username string, password string) KeycloakClient { +func NewKeycloakClient(realmURL string, username string, password string) (KeycloakClient, error) { kc := keycloakClient{} u, err := url.Parse(realmURL) if err != nil { - // url.Parse only fails on a malformed URL; returning it needs a - // signature change, done with the RunE command migration. - panic(err) + return nil, errors.Wrap(errors.KindUsage, fmt.Errorf("invalid Keycloak URL %q: %w", realmURL, err)) } kc.BaseURL = u kc.Username = username @@ -68,7 +66,7 @@ func NewKeycloakClient(realmURL string, username string, password string) Keyclo } else { kc.httpClient = http.DefaultClient } - return &kc + return &kc, nil } // ConnectAndGetToken implementation on keycloakClient structure diff --git a/pkg/connectors/microcks_client.go b/pkg/connectors/microcks_client.go index e4fb2c66..b13d574e 100644 --- a/pkg/connectors/microcks_client.go +++ b/pkg/connectors/microcks_client.go @@ -182,7 +182,7 @@ func NewClient(opts ClientOptions) (MicrocksClient, error) { } // NewMicrocksClient builds a new headless MicrocksClient without any authtoken and all for general purposes -func NewMicrocksClient(apiURL string) MicrocksClient { +func NewMicrocksClient(apiURL string) (MicrocksClient, error) { mc := microcksClient{} if strings.HasSuffix(apiURL, "/api") { @@ -194,9 +194,7 @@ func NewMicrocksClient(apiURL string) MicrocksClient { u, err := url.Parse(apiURL) if err != nil { - // url.Parse only fails on a malformed URL; returning it needs a - // signature change, done with the RunE command migration. - panic(err) + return nil, errors.Wrap(errors.KindUsage, fmt.Errorf("invalid server URL %q: %w", apiURL, err)) } mc.APIURL = u @@ -209,7 +207,7 @@ func NewMicrocksClient(apiURL string) MicrocksClient { } else { mc.httpClient = http.DefaultClient } - return &mc + return &mc, nil } func (c *microcksClient) HttpClient() *http.Client { return c.httpClient @@ -253,8 +251,12 @@ func (c *microcksClient) GetKeycloakURL() (string, error) { return "", errors.Wrap(errors.KindAPI, fmt.Errorf("parsing Keycloak config response: %w", err)) } + enabled, ok := configResp["enabled"].(bool) + if !ok { + return "", errors.Wrapf(errors.KindAPI, "Keycloak config response missing or invalid enabled field") + } // Return 'null' if Keycloak is disabled. - if enabled, _ := configResp["enabled"].(bool); !enabled { + if !enabled { return "null", nil } @@ -314,7 +316,10 @@ func (c *microcksClient) redeemRefreshToken(auth config.Auth) (string, string, e if err != nil { return "", "", err } - kc := NewKeycloakClient(keyCloakUrl, "", "") + kc, err := NewKeycloakClient(keyCloakUrl, "", "") + if err != nil { + return "", "", err + } oauth2Conf, err := kc.GetOIDCConfig() if err != nil { return "", "", err diff --git a/pkg/connectors/microcks_client_test.go b/pkg/connectors/microcks_client_test.go index 2fe423e7..e3853bb1 100644 --- a/pkg/connectors/microcks_client_test.go +++ b/pkg/connectors/microcks_client_test.go @@ -58,7 +58,10 @@ func TestUploadArtifactStreamsWithoutBuffering(t *testing.T) { })) defer server.Close() - client := NewMicrocksClient(server.URL) + client, err := NewMicrocksClient(server.URL) + if err != nil { + t.Fatalf("NewMicrocksClient returned error: %v", err) + } msg, err := client.UploadArtifact(specPath, true) if err != nil { t.Fatalf("UploadArtifact returned error: %v", err) @@ -92,7 +95,10 @@ func TestDownloadArtifactReturnsResponseBody(t *testing.T) { })) defer server.Close() - client := NewMicrocksClient(server.URL) + client, err := NewMicrocksClient(server.URL) + if err != nil { + t.Fatalf("NewMicrocksClient returned error: %v", err) + } msg, err := client.DownloadArtifact("https://example.com/openapi.yaml", true, "") if err != nil { diff --git a/pkg/errors/error_test.go b/pkg/errors/error_test.go index 27ce9d5d..e9123bf2 100644 --- a/pkg/errors/error_test.go +++ b/pkg/errors/error_test.go @@ -14,7 +14,7 @@ * limitations under the License. */ - package errors +package errors import ( stderrors "errors" diff --git a/pkg/watcher/executor.go b/pkg/watcher/executor.go index ac7362bb..151e9d5f 100644 --- a/pkg/watcher/executor.go +++ b/pkg/watcher/executor.go @@ -36,7 +36,12 @@ func TriggerImport(entry config.WatchEntry) { } } else { // We have no config file, so just create a client with context as server URL. - mc = connectors.NewMicrocksClient(context) + var cerr error + mc, cerr = connectors.NewMicrocksClient(context) + if cerr != nil { + fmt.Printf("[ERROR] Cannot create Microcks client for context '%s': %v\n", context, cerr) + continue + } } _, err = mc.UploadArtifact(entry.FilePath, entry.MainArtifact)