Skip to content

Commit 3548b46

Browse files
author
Nathan Sullivan
authored
support multiple exit codes based on what went wrong/right (#1135)
0 = all passed, 3 = at least one failure, 4 = no failures but at least 1 warn 1 as a catch all (generic errors), 2 for invalid input/specs etc ref #1131 docs replicatedhq/troubleshoot.sh#489
1 parent 766469b commit 3548b46

File tree

6 files changed

+233
-28
lines changed

6 files changed

+233
-28
lines changed

.github/workflows/build-test-deploy.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,10 +112,17 @@ jobs:
112112
path: bin/
113113
- run: chmod +x bin/preflight
114114
- run: |
115+
set +e
115116
./bin/preflight --interactive=false --format=json https://preflight.replicated.com > result.json
117+
EXIT_CODE=$?
116118
cat result.json
117119
118120
EXIT_STATUS=0
121+
if [ $EXIT_CODE -ne 3 ]; then
122+
echo "Expected exit code of 3 (some checks failed), got $EXIT_CODE"
123+
EXIT_STATUS=1
124+
fi
125+
119126
if grep -q "was not collected" result.json; then
120127
echo "Some files were not collected"
121128
EXIT_STATUS=1

cmd/preflight/cli/root.go

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
package cli
22

33
import (
4+
"errors"
45
"fmt"
56
"os"
67
"strings"
78

89
"github.com/replicatedhq/troubleshoot/cmd/util"
910
"github.com/replicatedhq/troubleshoot/internal/traces"
11+
"github.com/replicatedhq/troubleshoot/pkg/constants"
1012
"github.com/replicatedhq/troubleshoot/pkg/k8sutil"
1113
"github.com/replicatedhq/troubleshoot/pkg/logger"
1214
"github.com/replicatedhq/troubleshoot/pkg/preflight"
15+
"github.com/replicatedhq/troubleshoot/pkg/types"
1316
"github.com/spf13/cobra"
1417
"github.com/spf13/viper"
1518
"k8s.io/klog/v2"
@@ -22,7 +25,8 @@ func RootCmd() *cobra.Command {
2225
Short: "Run and retrieve preflight checks in a cluster",
2326
Long: `A preflight check is a set of validations that can and should be run to ensure
2427
that a cluster meets the requirements to run an application.`,
25-
SilenceUsage: true,
28+
SilenceUsage: true,
29+
SilenceErrors: true,
2630
PreRun: func(cmd *cobra.Command, args []string) {
2731
v := viper.GetViper()
2832
v.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
@@ -48,6 +52,7 @@ that a cluster meets the requirements to run an application.`,
4852
if v.GetBool("debug") || v.IsSet("v") {
4953
fmt.Printf("\n%s", traces.GetExporterInstance().GetSummary())
5054
}
55+
5156
return err
5257
},
5358
PostRun: func(cmd *cobra.Command, args []string) {
@@ -75,7 +80,24 @@ that a cluster meets the requirements to run an application.`,
7580
}
7681

7782
func InitAndExecute() {
78-
if err := RootCmd().Execute(); err != nil {
83+
cmd := RootCmd()
84+
err := cmd.Execute()
85+
86+
if err != nil {
87+
var exitErr types.ExitError
88+
if errors.As(err, &exitErr) {
89+
// We need to do this, there's situations where we need the non-zero exit code (which comes as part of the custom error struct)
90+
// but there's no actual error, just an exit code.
91+
// If there's also an error to output (eg. invalid format etc) then print it as well
92+
if exitErr.ExitStatus() != constants.EXIT_CODE_FAIL && exitErr.ExitStatus() != constants.EXIT_CODE_WARN {
93+
cmd.PrintErrln("Error:", err.Error())
94+
}
95+
96+
os.Exit(exitErr.ExitStatus())
97+
}
98+
99+
// Fallback, should almost never be used (the above Exit() should handle almost all situations
100+
cmd.PrintErrln("Error:", err.Error())
79101
os.Exit(1)
80102
}
81103
}

pkg/constants/constants.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,10 @@ const (
5151
CLUSTER_RESOURCES_ROLE_BINDINGS = "rolebindings"
5252
CLUSTER_RESOURCES_CLUSTER_ROLES = "clusterroles"
5353
CLUSTER_RESOURCES_CLUSTER_ROLE_BINDINGS = "clusterRoleBindings"
54+
55+
// Custom exit codes
56+
EXIT_CODE_CATCH_ALL = 1
57+
EXIT_CODE_SPEC_ISSUES = 2
58+
EXIT_CODE_FAIL = 3
59+
EXIT_CODE_WARN = 4
5460
)

pkg/preflight/run.go

Lines changed: 63 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,15 @@ import (
1616
"github.com/pkg/errors"
1717
"github.com/replicatedhq/troubleshoot/cmd/util"
1818
analyzer "github.com/replicatedhq/troubleshoot/pkg/analyze"
19+
analyzerunner "github.com/replicatedhq/troubleshoot/pkg/analyze"
1920
troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2"
2021
troubleshootclientsetscheme "github.com/replicatedhq/troubleshoot/pkg/client/troubleshootclientset/scheme"
2122
"github.com/replicatedhq/troubleshoot/pkg/constants"
2223
"github.com/replicatedhq/troubleshoot/pkg/docrewrite"
2324
"github.com/replicatedhq/troubleshoot/pkg/k8sutil"
2425
"github.com/replicatedhq/troubleshoot/pkg/oci"
2526
"github.com/replicatedhq/troubleshoot/pkg/specs"
27+
"github.com/replicatedhq/troubleshoot/pkg/types"
2628
"github.com/spf13/viper"
2729
spin "github.com/tj/go-spin"
2830
"go.opentelemetry.io/otel"
@@ -47,7 +49,8 @@ func RunPreflights(interactive bool, output string, format string, args []string
4749
signalChan := make(chan os.Signal, 1)
4850
signal.Notify(signalChan, os.Interrupt)
4951
<-signalChan
50-
os.Exit(0)
52+
// exiting due to a signal shouldn't be considered successful
53+
os.Exit(1)
5154
}()
5255

5356
var preflightContent []byte
@@ -61,64 +64,66 @@ func RunPreflights(interactive bool, output string, format string, args []string
6164
// format secret/namespace-name/secret-name
6265
pathParts := strings.Split(v, "/")
6366
if len(pathParts) != 3 {
64-
return errors.Errorf("path %s must have 3 components", v)
67+
return types.NewExitCodeError(constants.EXIT_CODE_SPEC_ISSUES, errors.Errorf("path %s must have 3 components", v))
6568
}
6669

6770
spec, err := specs.LoadFromSecret(pathParts[1], pathParts[2], "preflight-spec")
6871
if err != nil {
69-
return errors.Wrap(err, "failed to get spec from secret")
72+
return types.NewExitCodeError(constants.EXIT_CODE_SPEC_ISSUES, errors.Wrap(err, "failed to get spec from secret"))
7073
}
7174

7275
preflightContent = spec
7376
} else if _, err = os.Stat(v); err == nil {
7477
b, err := os.ReadFile(v)
7578
if err != nil {
76-
return err
79+
return types.NewExitCodeError(constants.EXIT_CODE_SPEC_ISSUES, err)
7780
}
7881

7982
preflightContent = b
8083
} else if v == "-" {
8184
b, err := io.ReadAll(os.Stdin)
8285
if err != nil {
83-
return err
86+
return types.NewExitCodeError(constants.EXIT_CODE_CATCH_ALL, err)
8487
}
8588
preflightContent = b
8689
} else {
8790
u, err := url.Parse(v)
8891
if err != nil {
89-
return err
92+
return types.NewExitCodeError(constants.EXIT_CODE_SPEC_ISSUES, err)
9093
}
9194

9295
if u.Scheme == "oci" {
9396
content, err := oci.PullPreflightFromOCI(v)
9497
if err != nil {
9598
if err == oci.ErrNoRelease {
96-
return errors.Errorf("no release found for %s.\nCheck the oci:// uri for errors or contact the application vendor for support.", v)
99+
return types.NewExitCodeError(constants.EXIT_CODE_SPEC_ISSUES, errors.Errorf("no release found for %s.\nCheck the oci:// uri for errors or contact the application vendor for support.", v))
97100
}
98101

99-
return err
102+
return types.NewExitCodeError(constants.EXIT_CODE_SPEC_ISSUES, err)
100103
}
101104

102105
preflightContent = content
103106
} else {
104107
if !util.IsURL(v) {
105-
return fmt.Errorf("%s is not a URL and was not found (err %s)", v, err)
108+
return types.NewExitCodeError(constants.EXIT_CODE_SPEC_ISSUES, fmt.Errorf("%s is not a URL and was not found (err %s)", v, err))
106109
}
107110

108111
req, err := http.NewRequest("GET", v, nil)
109112
if err != nil {
110-
return err
113+
// exit code: should this be catch all or spec issues...?
114+
return types.NewExitCodeError(constants.EXIT_CODE_CATCH_ALL, err)
111115
}
112116
req.Header.Set("User-Agent", "Replicated_Preflight/v1beta2")
113117
resp, err := http.DefaultClient.Do(req)
114118
if err != nil {
115-
return err
119+
// exit code: should this be catch all or spec issues...?
120+
return types.NewExitCodeError(constants.EXIT_CODE_CATCH_ALL, err)
116121
}
117122
defer resp.Body.Close()
118123

119124
body, err := io.ReadAll(resp.Body)
120125
if err != nil {
121-
return err
126+
return types.NewExitCodeError(constants.EXIT_CODE_SPEC_ISSUES, err)
122127
}
123128

124129
preflightContent = body
@@ -137,7 +142,7 @@ func RunPreflights(interactive bool, output string, format string, args []string
137142

138143
err := yaml.Unmarshal([]byte(doc), &parsedDocHead)
139144
if err != nil {
140-
return errors.Wrap(err, "failed to parse yaml")
145+
return types.NewExitCodeError(constants.EXIT_CODE_SPEC_ISSUES, errors.Wrap(err, "failed to parse yaml"))
141146
}
142147

143148
if parsedDocHead.Kind != "Preflight" {
@@ -146,14 +151,14 @@ func RunPreflights(interactive bool, output string, format string, args []string
146151

147152
preflightContent, err = docrewrite.ConvertToV1Beta2([]byte(doc))
148153
if err != nil {
149-
return errors.Wrap(err, "failed to convert to v1beta2")
154+
return types.NewExitCodeError(constants.EXIT_CODE_SPEC_ISSUES, errors.Wrap(err, "failed to convert to v1beta2"))
150155
}
151156

152157
troubleshootclientsetscheme.AddToScheme(scheme.Scheme)
153158
decode := scheme.Codecs.UniversalDeserializer().Decode
154159
obj, _, err := decode([]byte(preflightContent), nil, nil)
155160
if err != nil {
156-
return errors.Wrapf(err, "failed to parse %s", v)
161+
return types.NewExitCodeError(constants.EXIT_CODE_SPEC_ISSUES, errors.Wrapf(err, "failed to parse %s", v))
157162
}
158163

159164
if spec, ok := obj.(*troubleshootv1beta2.Preflight); ok {
@@ -192,7 +197,7 @@ func RunPreflights(interactive bool, output string, format string, args []string
192197
if preflightSpec != nil {
193198
r, err := collectInCluster(ctx, preflightSpec, progressCh)
194199
if err != nil {
195-
return errors.Wrap(err, "failed to collect in cluster")
200+
return types.NewExitCodeError(constants.EXIT_CODE_CATCH_ALL, errors.Wrap(err, "failed to collect in cluster"))
196201
}
197202
collectResults = append(collectResults, *r)
198203
preflightSpecName = preflightSpec.Name
@@ -201,7 +206,7 @@ func RunPreflights(interactive bool, output string, format string, args []string
201206
for _, spec := range uploadResultSpecs {
202207
r, err := collectInCluster(ctx, spec, progressCh)
203208
if err != nil {
204-
return errors.Wrap(err, "failed to collect in cluster")
209+
return types.NewExitCodeError(constants.EXIT_CODE_CATCH_ALL, errors.Wrap(err, "failed to collect in cluster"))
205210
}
206211
uploadResultsMap[spec.Spec.UploadResultsTo] = append(uploadResultsMap[spec.Spec.UploadResultsTo], *r)
207212
uploadCollectResults = append(collectResults, *r)
@@ -212,22 +217,22 @@ func RunPreflights(interactive bool, output string, format string, args []string
212217
if len(hostPreflightSpec.Spec.Collectors) > 0 {
213218
r, err := collectHost(ctx, hostPreflightSpec, progressCh)
214219
if err != nil {
215-
return errors.Wrap(err, "failed to collect from host")
220+
return types.NewExitCodeError(constants.EXIT_CODE_CATCH_ALL, errors.Wrap(err, "failed to collect from host"))
216221
}
217222
collectResults = append(collectResults, *r)
218223
}
219224
if len(hostPreflightSpec.Spec.RemoteCollectors) > 0 {
220225
r, err := collectRemote(ctx, hostPreflightSpec, progressCh)
221226
if err != nil {
222-
return errors.Wrap(err, "failed to collect remotely")
227+
return types.NewExitCodeError(constants.EXIT_CODE_CATCH_ALL, errors.Wrap(err, "failed to collect remotely"))
223228
}
224229
collectResults = append(collectResults, *r)
225230
}
226231
preflightSpecName = hostPreflightSpec.Name
227232
}
228233

229234
if collectResults == nil && uploadCollectResults == nil {
230-
return errors.New("no results")
235+
return types.NewExitCodeError(constants.EXIT_CODE_CATCH_ALL, errors.New("no results"))
231236
}
232237

233238
analyzeResults := []*analyzer.AnalyzeResult{}
@@ -255,14 +260,48 @@ func RunPreflights(interactive bool, output string, format string, args []string
255260
stopProgressCollection()
256261
progressCollection.Wait()
257262

263+
if len(analyzeResults) == 0 {
264+
return types.NewExitCodeError(constants.EXIT_CODE_CATCH_ALL, errors.New("no data has been collected"))
265+
}
266+
258267
if interactive {
259-
if len(analyzeResults) == 0 {
260-
return errors.New("no data has been collected")
268+
err = showInteractiveResults(preflightSpecName, output, analyzeResults)
269+
} else {
270+
err = showTextResults(format, preflightSpecName, output, analyzeResults)
271+
}
272+
273+
if err != nil {
274+
return err
275+
}
276+
277+
exitCode := checkOutcomesToExitCode(analyzeResults)
278+
279+
if exitCode == 0 {
280+
return nil
281+
}
282+
283+
return types.NewExitCodeError(exitCode, errors.New("preflights failed with warnings or errors"))
284+
}
285+
286+
// Determine if any preflight checks passed vs failed vs warned
287+
// If all checks passed: 0
288+
// If 1 or more checks failed: 3
289+
// If no checks failed, but 1 or more warn: 4
290+
func checkOutcomesToExitCode(analyzeResults []*analyzerunner.AnalyzeResult) int {
291+
// Assume pass until they don't
292+
exitCode := 0
293+
294+
for _, analyzeResult := range analyzeResults {
295+
if analyzeResult.IsWarn {
296+
exitCode = constants.EXIT_CODE_WARN
297+
} else if analyzeResult.IsFail {
298+
exitCode = constants.EXIT_CODE_FAIL
299+
// No need to check further, a fail is a fail
300+
return exitCode
261301
}
262-
return showInteractiveResults(preflightSpecName, output, analyzeResults)
263302
}
264303

265-
return showTextResults(format, preflightSpecName, output, analyzeResults)
304+
return exitCode
266305
}
267306

268307
func collectInteractiveProgress(ctx context.Context, progressCh <-chan interface{}) func() error {

0 commit comments

Comments
 (0)