Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 11 additions & 7 deletions cmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
},
Expand All @@ -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")
Expand All @@ -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
}
74 changes: 43 additions & 31 deletions cmd/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
}

Expand All @@ -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)
Expand All @@ -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
}
6 changes: 3 additions & 3 deletions cmd/context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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)
Expand All @@ -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")
}

66 changes: 66 additions & 0 deletions cmd/exit.go
Original file line number Diff line number Diff line change
@@ -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))
}
47 changes: 47 additions & 0 deletions cmd/exit_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading
Loading