Skip to content

Commit a87f00e

Browse files
committed
fix(core)!: return non-zero exit code if command execution fails
1 parent 138d42e commit a87f00e

File tree

9 files changed

+104
-6
lines changed

9 files changed

+104
-6
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
- **Breaking**: Set non-zero exit code if command execution fails.
12+
1013
## [1.5.1] - 2022-07-15
1114
### Fixed
1215
- On `server create`, mount OS disk by default on `virtio` bus. Previously default OS storage address was not explicit and varyed depending on template type.

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,16 @@ $ upctl server list
147147
003c9d77-0237-4ee7-b3a1-306efba456dc server2 1xCPU-2GB sg-sin1 started
148148
```
149149

150+
## Exit codes
151+
152+
Exit code communicates success or the type and number of failures. Possible exit codes of `upctl` are:
153+
154+
Exit code | Description
155+
--------- | -----------
156+
0 | Command(s) executed successfully.
157+
1 - 99 | Number of failed executions. For example, if stopping four servers and API returs error for one of the request, exit code will be 1.
158+
100 - | Other, non-execution related, errors. For example, required flag missing.
159+
150160
## Examples
151161

152162
Every command has a help and examples included and you can find all its options

cmd/upctl/main.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import (
77
)
88

99
func main() {
10-
if err := core.BootstrapCLI(os.Args); err != nil {
11-
os.Exit(1)
12-
}
10+
exitCode := core.Execute()
11+
os.Exit(exitCode)
1312
}

internal/clierrors/client_error.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package clierrors
2+
3+
const UNSPECIFIED_ERROR int = 100
4+
5+
// ClientError declares interface for errors known to the client that set specific error code.
6+
type ClientError interface {
7+
ErrorCode() int
8+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package clierrors
2+
3+
import "fmt"
4+
5+
type CommandFailedError struct {
6+
FailedCount int
7+
}
8+
9+
func min(a, b int) int {
10+
if a < b {
11+
return a
12+
}
13+
return b
14+
}
15+
16+
func (err CommandFailedError) ErrorCode() int {
17+
return min(err.FailedCount, 99)
18+
}
19+
20+
func (err CommandFailedError) Error() string {
21+
return fmt.Sprintf("Command execution failed for %d resource(s)", err.FailedCount)
22+
}

internal/commands/runcommand.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ var (
1818
)
1919

2020
func commandRunE(command Command, service internal.AllServices, config *config.Config, args []string) error {
21+
// Cobra validations were successful
22+
command.Cobra().SilenceUsage = true
23+
2124
cmdLogger := logger.With("command", command.Cobra().CommandPath())
2225
executor := NewExecutor(config, service, cmdLogger)
2326
defer executor.Close()

internal/core/core.go

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"fmt"
55
"os"
66

7+
"github.com/UpCloudLtd/upcloud-cli/internal/clierrors"
78
"github.com/UpCloudLtd/upcloud-cli/internal/commands"
89
"github.com/UpCloudLtd/upcloud-cli/internal/commands/all"
910
"github.com/UpCloudLtd/upcloud-cli/internal/config"
@@ -124,8 +125,18 @@ func BuildCLI() cobra.Command {
124125
return rootCmd
125126
}
126127

127-
// BootstrapCLI is the CLI entrypoint
128-
func BootstrapCLI(args []string) error {
128+
// Execute is the application entrypoint. It returns the exit code that should be forwarded to the shell.
129+
func Execute() int {
129130
rootCmd := BuildCLI()
130-
return rootCmd.Execute()
131+
err := rootCmd.Execute()
132+
133+
if err != nil {
134+
if clierr, ok := err.(clierrors.ClientError); ok {
135+
return clierr.ErrorCode()
136+
}
137+
138+
return clierrors.UNSPECIFIED_ERROR
139+
}
140+
141+
return 0
131142
}

internal/core/core_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,3 +132,30 @@ func TestInputValidation(t *testing.T) {
132132
})
133133
}
134134
}
135+
136+
func TestExecute(t *testing.T) {
137+
realArgs := os.Args
138+
defer func() { os.Args = realArgs }()
139+
140+
for _, test := range []struct {
141+
name string
142+
args []string
143+
expected int
144+
}{
145+
{
146+
name: "Successful command (upctl version)",
147+
args: []string{"upctl", "version"},
148+
expected: 0,
149+
},
150+
{
151+
name: "Failing command (upctl server create)",
152+
args: []string{"upctl", "server", "create"},
153+
expected: 100,
154+
},
155+
} {
156+
t.Run(test.name, func(t *testing.T) {
157+
os.Args = test.args
158+
assert.Equal(t, test.expected, Execute())
159+
})
160+
}
161+
}

internal/output/render.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"io"
88
"strings"
99

10+
"github.com/UpCloudLtd/upcloud-cli/internal/clierrors"
1011
"github.com/UpCloudLtd/upcloud-cli/internal/config"
1112
)
1213

@@ -74,5 +75,19 @@ func Render(writer io.Writer, cfg *config.Config, commandOutputs ...Output) (err
7475
if _, err := writer.Write(output); err != nil {
7576
return err
7677
}
78+
79+
// Count failed outputs
80+
var failedCount = 0
81+
for _, commandOutput := range commandOutputs {
82+
if _, ok := commandOutput.(Error); ok {
83+
failedCount++
84+
}
85+
}
86+
87+
if failedCount > 0 {
88+
return &clierrors.CommandFailedError{
89+
FailedCount: failedCount,
90+
}
91+
}
7792
return nil
7893
}

0 commit comments

Comments
 (0)