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..fe0843dc --- /dev/null +++ b/cmd/exit.go @@ -0,0 +1,66 @@ +/* + * 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" + "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 + } + if !stderrors.Is(err, errors.ErrTestFailed) { + fmt.Fprintln(os.Stderr, err) + } + os.Exit(ExitCodeFor(err)) +} diff --git a/cmd/exit_test.go b/cmd/exit_test.go new file mode 100644 index 00000000..6d474d85 --- /dev/null +++ b/cmd/exit_test.go @@ -0,0 +1,47 @@ +/* + * 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" + "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) + } + } +} diff --git a/cmd/import.go b/cmd/import.go index 2a8f8731..0bb9e98a 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 errors.Wrapf(errors.KindUsage, "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..ae072b38 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 nil }, } 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..47868bfb 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 errors.Wrapf(errors.KindUsage, "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..290b4102 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 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 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 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/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 5d6aeedd..ed37aedb 100644 --- a/pkg/connectors/keycloak_client.go +++ b/pkg/connectors/keycloak_client.go @@ -26,6 +26,7 @@ import ( "strings" "github.com/microcks/microcks-cli/pkg/config" + "github.com/microcks/microcks-cli/pkg/errors" "golang.org/x/oauth2" ) @@ -45,12 +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 { - panic(err) + return nil, errors.Wrap(errors.KindUsage, fmt.Errorf("invalid Keycloak URL %q: %w", realmURL, err)) } kc.BaseURL = u kc.Username = username @@ -65,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 @@ -88,7 +89,7 @@ func (c *keycloakClient) ConnectAndGetToken() (string, error) { resp, err := c.httpClient.Do(req) if err != nil { - return "", err + return "", errors.Wrap(errors.KindConnection, err) } defer resp.Body.Close() @@ -97,16 +98,23 @@ func (c *keycloakClient) ConnectAndGetToken() (string, error) { body, err := io.ReadAll(resp.Body) if err != nil { - panic(err.Error()) + return "", errors.Wrap(errors.KindConnection, fmt.Errorf("reading Keycloak token response: %w", err)) + } + + if resp.StatusCode != http.StatusOK { + return "", errors.Wrapf(errors.KindAPI, "Keycloak returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) } var openIDResp map[string]interface{} if err := json.Unmarshal(body, &openIDResp); err != nil { - panic(err) + return "", errors.Wrap(errors.KindAPI, fmt.Errorf("parsing Keycloak token response: %w", err)) } - accessToken := openIDResp["access_token"].(string) - return accessToken, err + accessToken, ok := openIDResp["access_token"].(string) + if !ok || accessToken == "" { + return "", errors.Wrapf(errors.KindAPI, "Keycloak token response missing access_token") + } + return accessToken, nil } func (c *keycloakClient) GetOIDCConfig() (*oauth2.Config, error) { @@ -116,27 +124,34 @@ func (c *keycloakClient) GetOIDCConfig() (*oauth2.Config, error) { // Create HTTP request req, err := http.NewRequest("GET", u.String(), nil) if err != nil { - fmt.Println("Error creating request:", err) + return nil, errors.Wrap(errors.KindGeneric, fmt.Errorf("creating Keycloak OIDC request: %w", err)) } resp, err := c.httpClient.Do(req) if err != nil { - return nil, err + return nil, errors.Wrap(errors.KindConnection, err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { - panic(err.Error()) + return nil, errors.Wrap(errors.KindConnection, fmt.Errorf("reading Keycloak OIDC config: %w", err)) + } + + if resp.StatusCode != http.StatusOK { + return nil, errors.Wrapf(errors.KindAPI, "Keycloak returned HTTP %d for OIDC config: %s", resp.StatusCode, strings.TrimSpace(string(body))) } var openIDResp map[string]interface{} if err := json.Unmarshal(body, &openIDResp); err != nil { - panic(err) + return nil, errors.Wrap(errors.KindAPI, fmt.Errorf("parsing Keycloak OIDC config: %w", err)) } - authURL := openIDResp["authorization_endpoint"].(string) - tokenURL := openIDResp["token_endpoint"].(string) + authURL, _ := openIDResp["authorization_endpoint"].(string) + tokenURL, _ := openIDResp["token_endpoint"].(string) + if authURL == "" || tokenURL == "" { + return nil, errors.Wrapf(errors.KindAPI, "Keycloak OIDC config missing authorization_endpoint or token_endpoint") + } return &oauth2.Config{ Endpoint: oauth2.Endpoint{ @@ -160,7 +175,7 @@ func (c *keycloakClient) ConnectAndGetTokenAndRefreshToken(username, password st // Create HTTP request req, err := http.NewRequest("POST", u.String(), bytes.NewBufferString(data.Encode())) if err != nil { - fmt.Println("Error creating request:", err) + return "", "", errors.Wrap(errors.KindGeneric, fmt.Errorf("creating Keycloak token request: %w", err)) } // Set headers @@ -168,22 +183,29 @@ func (c *keycloakClient) ConnectAndGetTokenAndRefreshToken(username, password st resp, err := c.httpClient.Do(req) if err != nil { - return "", "", err + return "", "", errors.Wrap(errors.KindConnection, err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { - panic(err.Error()) + return "", "", errors.Wrap(errors.KindConnection, fmt.Errorf("reading Keycloak token response: %w", err)) + } + + if resp.StatusCode != http.StatusOK { + return "", "", errors.Wrapf(errors.KindAPI, "Keycloak returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) } var openIDResp map[string]interface{} if err := json.Unmarshal(body, &openIDResp); err != nil { - panic(err) + return "", "", errors.Wrap(errors.KindAPI, fmt.Errorf("parsing Keycloak token response: %w", err)) } - authToken := openIDResp["access_token"].(string) - refreshToken := openIDResp["refresh_token"].(string) + authToken, _ := openIDResp["access_token"].(string) + refreshToken, _ := openIDResp["refresh_token"].(string) + if authToken == "" || refreshToken == "" { + return "", "", errors.Wrapf(errors.KindAPI, "Keycloak token response missing access_token or refresh_token") + } return authToken, refreshToken, nil } diff --git a/pkg/connectors/microcks_client.go b/pkg/connectors/microcks_client.go index 6021a3c5..6a215f11 100644 --- a/pkg/connectors/microcks_client.go +++ b/pkg/connectors/microcks_client.go @@ -151,7 +151,7 @@ func NewClient(opts ClientOptions) (MicrocksClient, error) { u, err := url.Parse(apiURL) if err != nil { - panic(err) + return nil, errors.Wrap(errors.KindUsage, fmt.Errorf("invalid server URL %q: %w", apiURL, err)) } c.APIURL = u @@ -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,7 +194,7 @@ func NewMicrocksClient(apiURL string) MicrocksClient { u, err := url.Parse(apiURL) if err != nil { - panic(err) + return nil, errors.Wrap(errors.KindUsage, fmt.Errorf("invalid server URL %q: %w", apiURL, err)) } mc.APIURL = u @@ -207,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 @@ -230,7 +230,7 @@ func (c *microcksClient) GetKeycloakURL() (string, error) { resp, err := c.httpClient.Do(req) if err != nil { - return "", err + return "", errors.Wrap(errors.KindConnection, err) } defer resp.Body.Close() @@ -239,24 +239,29 @@ func (c *microcksClient) GetKeycloakURL() (string, error) { body, err := io.ReadAll(resp.Body) if err != nil { - panic(err.Error()) + return "", errors.Wrap(errors.KindConnection, fmt.Errorf("reading Keycloak config response: %w", err)) + } + + if resp.StatusCode != http.StatusOK { + return "", errors.Wrapf(errors.KindAPI, "Microcks returned HTTP %d for Keycloak config: %s", resp.StatusCode, strings.TrimSpace(string(body))) } var configResp map[string]interface{} if err := json.Unmarshal(body, &configResp); err != nil { - panic(err) + return "", errors.Wrap(errors.KindAPI, fmt.Errorf("parsing Keycloak config response: %w", err)) } - // Retrieve auth server url and realm name. - enabled := configResp["enabled"].(bool) - authServerURL := configResp["auth-server-url"].(string) - realmName := configResp["realm"].(string) + // Return 'null' if Keycloak is disabled. + if enabled, _ := configResp["enabled"].(bool); !enabled { + return "null", nil + } - // Return a proper URL or 'null' if Keycloak is disables. - if enabled { - return authServerURL + "/realms/" + realmName + "/", nil + authServerURL, _ := configResp["auth-server-url"].(string) + realmName, _ := configResp["realm"].(string) + if authServerURL == "" || realmName == "" { + return "", errors.Wrapf(errors.KindAPI, "Keycloak config response missing auth-server-url or realm") } - return "null", nil + return authServerURL + "/realms/" + realmName + "/", nil } func (c *microcksClient) refreshAuthToken(localCfg *config.LocalConfig, ctxName, configPath string) error { @@ -304,10 +309,17 @@ func (c *microcksClient) refreshAuthToken(localCfg *config.LocalConfig, ctxName, func (c *microcksClient) redeemRefreshToken(auth config.Auth) (string, string, error) { keyCloakUrl, err := c.GetKeycloakURL() - errors.CheckError(err) - kc := NewKeycloakClient(keyCloakUrl, "", "") + if err != nil { + return "", "", err + } + kc, err := NewKeycloakClient(keyCloakUrl, "", "") + if err != nil { + return "", "", err + } oauth2Conf, err := kc.GetOIDCConfig() - errors.CheckError(err) + if err != nil { + return "", "", err + } oauth2Conf.ClientID = auth.ClientId oauth2Conf.ClientSecret = auth.ClientSecret @@ -372,18 +384,22 @@ func (c *microcksClient) CreateTestResult(serviceID string, testEndpoint string, resp, err := c.httpClient.Do(req) if err != nil { - return "", err + return "", errors.Wrap(errors.KindConnection, err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { - return "", fmt.Errorf("failed to read response body: %w", err) + return "", errors.Wrap(errors.KindConnection, fmt.Errorf("reading test creation response: %w", err)) } // Check HTTP status before attempting to parse. if resp.StatusCode != 201 { - return "", fmt.Errorf("microcks returned HTTP %d: %s (is the service '%s' registered?)", resp.StatusCode, strings.TrimSpace(string(body)), serviceID) + kind := errors.KindAPI + if resp.StatusCode == http.StatusNotFound { + kind = errors.KindNotFound + } + return "", errors.Wrapf(kind, "Microcks returned HTTP %d: %s (is the service '%s' registered?)", resp.StatusCode, strings.TrimSpace(string(body)), serviceID) } var createTestResp map[string]interface{} @@ -416,7 +432,7 @@ func (c *microcksClient) GetTestResult(testResultID string) (*TestResultSummary, resp, err := c.httpClient.Do(req) if err != nil { - return nil, err + return nil, errors.Wrap(errors.KindConnection, err) } defer resp.Body.Close() @@ -425,7 +441,7 @@ func (c *microcksClient) GetTestResult(testResultID string) (*TestResultSummary, body, err := io.ReadAll(resp.Body) if err != nil { - panic(err.Error()) + return nil, errors.Wrap(errors.KindConnection, fmt.Errorf("reading test result response: %w", err)) } result := TestResultSummary{} @@ -440,7 +456,7 @@ func (c *microcksClient) UploadArtifact(specificationFilePath string, mainArtifa // Ensure file exists on fs. file, err := os.Open(specificationFilePath) if err != nil { - return "", err + return "", errors.Wrap(errors.KindUsage, fmt.Errorf("cannot read artifact %q: %w", specificationFilePath, err)) } defer file.Close() @@ -490,7 +506,7 @@ func (c *microcksClient) UploadArtifact(specificationFilePath string, mainArtifa resp, err := c.httpClient.Do(req) if err != nil { - return "", err + return "", errors.Wrap(errors.KindConnection, err) } defer resp.Body.Close() @@ -509,7 +525,7 @@ func (c *microcksClient) UploadArtifact(specificationFilePath string, mainArtifa // Raise exception if not created. if resp.StatusCode != 201 { - return "", errs.New(string(respBody)) + return "", errors.Wrap(errors.KindAPI, errs.New(strings.TrimSpace(string(respBody)))) } return string(respBody), nil @@ -549,7 +565,7 @@ func (c *microcksClient) DownloadArtifact(artifactURL string, mainArtifact bool, resp, err := c.httpClient.Do(req) if err != nil { - return "", err + return "", errors.Wrap(errors.KindConnection, err) } defer resp.Body.Close() @@ -558,15 +574,15 @@ func (c *microcksClient) DownloadArtifact(artifactURL string, mainArtifact bool, respBody, err := io.ReadAll(resp.Body) if err != nil { - panic(err.Error()) + return "", errors.Wrap(errors.KindConnection, fmt.Errorf("reading download response: %w", err)) } // Raise exception if not created. if resp.StatusCode != 201 { - return "", errs.New(string(respBody)) + return "", errors.Wrap(errors.KindAPI, errs.New(strings.TrimSpace(string(respBody)))) } - return string(respBody), err + return string(respBody), nil } func ensureValidOperationsList(filteredOperations string) bool { 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.go b/pkg/errors/error.go index 8438c936..6172ef27 100644 --- a/pkg/errors/error.go +++ b/pkg/errors/error.go @@ -1,10 +1,16 @@ package errors import ( + stderrors "errors" + "fmt" "log" "os" ) +// Deprecated: these numeric codes and the Check*/Fatal helpers below are the +// legacy exit mechanism. New code classifies failures with a Kind (see Wrap) and +// lets cmd.Handle map Kind -> exit code. Kept as a shim until every call site is +// migrated, then removed. const ( // ErrorCommandSpecific is reserved for command specific indications ErrorCommandSpecific = 1 @@ -18,23 +24,84 @@ const ( ErrorGeneric = 20 ) +// Deprecated: return errors.Wrap(kind, err) from a RunE command instead. func CheckError(err error) { if err != nil { Fatal(ErrorGeneric, err) } } +// Deprecated: return a KindNotFound-wrapped error instead. func CheckConfigNil(isNil bool, path string) { if isNil { Fatal(ErrorGeneric, "No contexts defined in "+path) } } - -// Fatal is a wrapper for log.Fatal() to exit with custom code +// Deprecated: only main/cmd.Handle should exit the process. Fatal is a wrapper +// for log.Fatal() to exit with a custom code. func Fatal(exitcode int, args ...interface{}) { log.Println(args...) os.Exit(exitcode) } +// Kind classifies why an operation failed. The library returns kinds; the cmd +// layer maps them to exit codes, so pkg/* never depends on exit codes and stays +// safe to embed. +type Kind int + +const ( + // KindGeneric is an unclassified failure. It is the zero value, so an + // unwrapped error is treated as generic. + KindGeneric Kind = iota + // KindUsage is a bad argument or flag supplied by the user. + KindUsage + // KindConnection is a failure to reach the Microcks or Keycloak endpoint. + KindConnection + // KindAPI is a server that rejected the request or returned an unusable response. + KindAPI + // KindNotFound is a requested remote resource that does not exist. + KindNotFound + // KindEnvironment is a local precondition not met — container runtime down, + // image unpullable, or ephemeral server not ready. Not KindConnection, which + // is about reaching the Microcks server. + KindEnvironment +) + +// KindError wraps an error with a Failure Kind. +type KindError struct { + Kind Kind + Err error +} + +func (e *KindError) Error() string { return e.Err.Error() } +func (e *KindError) Unwrap() error { return e.Err } + +// Wrap tags err with a Failure Kind. It returns nil when err is nil, so it is +// safe to write `return errors.Wrap(KindAPI, doThing())`. +func Wrap(kind Kind, err error) error { + if err == nil { + return nil + } + return &KindError{Kind: kind, Err: err} +} + +// Wrapf builds a kind-tagged error from a format string. +func Wrapf(kind Kind, format string, a ...any) error { + return &KindError{Kind: kind, Err: fmt.Errorf(format, a...)} +} + +// KindOf reports the Failure Kind carried by err's chain, defaulting to +// KindGeneric when none is present (including when err is nil). +func KindOf(err error) Kind { + var ke *KindError + if stderrors.As(err, &ke) { + return ke.Kind + } + return KindGeneric +} +// ErrTestFailed signals a completed test run whose result does not conform to the +// contract. It is not a failure to *run*: the command has already rendered the +// result, so the CLI exits non-zero without printing this sentinel. See cmd.Handle. +var ErrTestFailed = stderrors.New("contract test failed") diff --git a/pkg/errors/error_test.go b/pkg/errors/error_test.go new file mode 100644 index 00000000..68dd8bd9 --- /dev/null +++ b/pkg/errors/error_test.go @@ -0,0 +1,41 @@ +package errors + +import ( + stderrors "errors" + "fmt" + "testing" +) + +func TestWrapNilReturnsNil(t *testing.T) { + if Wrap(KindAPI, nil) != nil { + t.Fatal("Wrap of a nil error should return nil") + } +} + +func TestKindOf(t *testing.T) { + cases := []struct { + name string + err error + want Kind + }{ + {"nil", nil, KindGeneric}, + {"plain error", stderrors.New("boom"), KindGeneric}, + {"wrapped", Wrap(KindConnection, stderrors.New("refused")), KindConnection}, + {"wrapped again", fmt.Errorf("outer: %w", Wrap(KindNotFound, stderrors.New("404"))), KindNotFound}, + } + for _, c := range cases { + if got := KindOf(c.err); got != c.want { + t.Errorf("%s: KindOf = %v, want %v", c.name, got, c.want) + } + } +} + +func TestWrapfPreservesKindAndMessage(t *testing.T) { + err := Wrapf(KindEnvironment, "docker unreachable on attempt %d", 2) + if KindOf(err) != KindEnvironment { + t.Errorf("KindOf = %v, want KindEnvironment", KindOf(err)) + } + if got := err.Error(); got != "docker unreachable on attempt 2" { + t.Errorf("Error() = %q", got) + } +} 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) diff --git a/pkg/watcher/watchManager.go b/pkg/watcher/watchManager.go index 5b0f717a..6e0167c7 100644 --- a/pkg/watcher/watchManager.go +++ b/pkg/watcher/watchManager.go @@ -7,7 +7,6 @@ import ( "github.com/fsnotify/fsnotify" "github.com/microcks/microcks-cli/pkg/config" - "github.com/microcks/microcks-cli/pkg/errors" ) type WatchManager struct { @@ -88,7 +87,9 @@ func (wm *WatchManager) Run() { err := wm.Reload() wm.lock.Unlock() if err != nil { - errors.CheckError(err) + // A bad config edit shouldn't kill the watcher; log and + // keep the previous config until the next valid save. + log.Printf("[ERROR] Config reload failed, keeping previous config: %v", err) } } else { wm.lock.Lock()