diff --git a/cliv2/cmd/cliv2/behavior/maperrortoexitcode.go b/cliv2/cmd/cliv2/behavior/maperrortoexitcode.go new file mode 100644 index 0000000000..96f0b446ed --- /dev/null +++ b/cliv2/cmd/cliv2/behavior/maperrortoexitcode.go @@ -0,0 +1,28 @@ +package behavior + +import ( + "github.com/snyk/error-catalog-golang-public/aibom" + "github.com/snyk/error-catalog-golang-public/code" + "github.com/snyk/error-catalog-golang-public/snyk" + "github.com/snyk/error-catalog-golang-public/snyk_errors" + + "github.com/snyk/cli/cliv2/internal/constants" +) + +var MapErrorCatalogToExitCode func(err *snyk_errors.Error, defaultValue int) int = mapErrorToExitCode + +// mapErrorToExitCode maps error catalog errors to exit codes. Please extend the switch statement if new error codes need to be mapped. +func mapErrorToExitCode(err *snyk_errors.Error, defaultValue int) int { + var errorCatalogToExitCodeMap = map[string]int{ + code.NewUnsupportedProjectError("").ErrorCode: constants.SNYK_EXIT_CODE_UNSUPPORTED_PROJECTS, + aibom.NewNoSupportedFilesError("").ErrorCode: constants.SNYK_EXIT_CODE_UNSUPPORTED_PROJECTS, + snyk.NewMaintenanceWindowError("").ErrorCode: constants.SNYK_EXIT_CODE_EX_TEMPFAIL, + // Add new mappings here + } + + if exitCode, ok := errorCatalogToExitCodeMap[err.ErrorCode]; ok { + return exitCode + } + + return defaultValue +} diff --git a/cliv2/cmd/cliv2/exitcode.go b/cliv2/cmd/cliv2/exitcode.go index 77eeb240b0..44cd35bfbe 100644 --- a/cliv2/cmd/cliv2/exitcode.go +++ b/cliv2/cmd/cliv2/exitcode.go @@ -1,18 +1,21 @@ package main import ( + "context" "encoding/json" "errors" "fmt" + "os/exec" "strings" - "github.com/snyk/error-catalog-golang-public/code" + "github.com/snyk/error-catalog-golang-public/snyk_errors" "github.com/snyk/go-application-framework/pkg/apiclients/testapi" "github.com/snyk/go-application-framework/pkg/local_workflows/content_type" "github.com/snyk/go-application-framework/pkg/local_workflows/json_schemas" "github.com/snyk/go-application-framework/pkg/utils/ufm" "github.com/snyk/go-application-framework/pkg/workflow" + "github.com/snyk/cli/cliv2/cmd/cliv2/behavior" "github.com/snyk/cli/cliv2/internal/constants" cli_errors "github.com/snyk/cli/cliv2/internal/errors" ) @@ -146,8 +149,8 @@ func handleTestSummary(engine workflow.Engine, data workflow.Data) (int, error) // handleDataErrors processes data errors and returns the appropriate exit code and error func handleDataErrors(data workflow.Data) (int, error) { for _, dataError := range data.GetErrorList() { - if dataError.ErrorCode == code.NewUnsupportedProjectError("").ErrorCode { - return constants.SNYK_EXIT_CODE_UNSUPPORTED_PROJECTS, dataError + if exitCode := mapErrorToExitCode(dataError); exitCode != unsetExitCode { + return exitCode, dataError } } return unsetExitCode, nil @@ -168,3 +171,26 @@ func createErrorWithExitCode(exitCode int, err error) error { } return errors.Join(err, errorWithExitCode) } + +// mapErrorToExitCode maps specific errors to an exit code. Unmapped errors will return unsetExitCode. +func mapErrorToExitCode(err error) int { + // no need to map if the error already contains an exit code in some form + exitCodeError := cli_errors.ErrorWithExitCode{} + var exitError *exec.ExitError + if errors.Is(err, exitCodeError) || errors.As(err, &exitError) { + return unsetExitCode + } + + // map external errors for example from golang runtime or other libraries that require a specific exit code + if errors.Is(err, context.DeadlineExceeded) { + return constants.SNYK_EXIT_CODE_EX_UNAVAILABLE + } + + // map error catalog errors + errCatalogError := snyk_errors.Error{} + if errors.As(err, &errCatalogError) { + return behavior.MapErrorCatalogToExitCode(&errCatalogError, unsetExitCode) + } + + return unsetExitCode +} diff --git a/cliv2/cmd/cliv2/exitcode_test.go b/cliv2/cmd/cliv2/exitcode_test.go new file mode 100644 index 0000000000..5afa306d42 --- /dev/null +++ b/cliv2/cmd/cliv2/exitcode_test.go @@ -0,0 +1,101 @@ +package main + +import ( + "context" + "errors" + "os" + "os/exec" + "testing" + + "github.com/snyk/error-catalog-golang-public/code" + "github.com/snyk/error-catalog-golang-public/snyk_errors" + + "github.com/snyk/cli/cliv2/internal/constants" + cli_errors "github.com/snyk/cli/cliv2/internal/errors" +) + +func TestMapErrorToExitCode(t *testing.T) { + t.Run("nil error returns unset", func(t *testing.T) { + exitCode := mapErrorToExitCode(nil) + if exitCode != unsetExitCode { + t.Errorf("expected exit code %d, got %d", unsetExitCode, exitCode) + } + }) + + t.Run("ErrorWithExitCode returns unset", func(t *testing.T) { + exitCodeErr := &cli_errors.ErrorWithExitCode{ExitCode: 42} + exitCode := mapErrorToExitCode(exitCodeErr) + if exitCode != unsetExitCode { + t.Errorf("expected exit code %d, got %d", unsetExitCode, exitCode) + } + }) + + t.Run("wrapped ErrorWithExitCode returns unset", func(t *testing.T) { + exitCodeErr := &cli_errors.ErrorWithExitCode{ExitCode: 42} + wrappedErr := errors.Join(exitCodeErr, errors.New("additional context")) + exitCode := mapErrorToExitCode(wrappedErr) + if exitCode != unsetExitCode { + t.Errorf("expected exit code %d, got %d", unsetExitCode, exitCode) + } + }) + + t.Run("exec.ExitError returns unset", func(t *testing.T) { + execErr := &exec.ExitError{ProcessState: &os.ProcessState{}} + exitCode := mapErrorToExitCode(execErr) + if exitCode != unsetExitCode { + t.Errorf("expected exit code %d, got %d", unsetExitCode, exitCode) + } + }) + + t.Run("wrapped exec.ExitError returns unset", func(t *testing.T) { + execErr := &exec.ExitError{ProcessState: &os.ProcessState{}} + wrappedErr := errors.Join(execErr, errors.New("command failed")) + exitCode := mapErrorToExitCode(wrappedErr) + if exitCode != unsetExitCode { + t.Errorf("expected exit code %d, got %d", unsetExitCode, exitCode) + } + }) + + t.Run("context.DeadlineExceeded returns EX_UNAVAILABLE", func(t *testing.T) { + exitCode := mapErrorToExitCode(context.DeadlineExceeded) + if exitCode != constants.SNYK_EXIT_CODE_EX_UNAVAILABLE { + t.Errorf("expected exit code %d, got %d", constants.SNYK_EXIT_CODE_EX_UNAVAILABLE, exitCode) + } + }) + + t.Run("wrapped context.DeadlineExceeded returns EX_UNAVAILABLE", func(t *testing.T) { + wrappedErr := errors.Join(context.DeadlineExceeded, errors.New("timeout occurred")) + exitCode := mapErrorToExitCode(wrappedErr) + if exitCode != constants.SNYK_EXIT_CODE_EX_UNAVAILABLE { + t.Errorf("expected exit code %d, got %d", constants.SNYK_EXIT_CODE_EX_UNAVAILABLE, exitCode) + } + }) + + t.Run("wrapped unsupported project error returns UNSUPPORTED_PROJECTS", func(t *testing.T) { + unsupportedErr := code.NewUnsupportedProjectError("test project") + wrappedErr := errors.Join(unsupportedErr, errors.New("additional context")) + exitCode := mapErrorToExitCode(wrappedErr) + if exitCode != constants.SNYK_EXIT_CODE_UNSUPPORTED_PROJECTS { + t.Errorf("expected exit code %d, got %d", constants.SNYK_EXIT_CODE_UNSUPPORTED_PROJECTS, exitCode) + } + }) + + t.Run("other error catalog error returns unset", func(t *testing.T) { + otherErr := snyk_errors.Error{ + ErrorCode: "OTHER_ERROR_CODE", + Detail: "some other error", + } + exitCode := mapErrorToExitCode(otherErr) + if exitCode != unsetExitCode { + t.Errorf("expected exit code %d, got %d", unsetExitCode, exitCode) + } + }) + + t.Run("generic error returns unset", func(t *testing.T) { + genericErr := errors.New("some generic error") + exitCode := mapErrorToExitCode(genericErr) + if exitCode != unsetExitCode { + t.Errorf("expected exit code %d, got %d", unsetExitCode, exitCode) + } + }) +} diff --git a/cliv2/cmd/cliv2/main.go b/cliv2/cmd/cliv2/main.go index 8fb3890a69..37e4517752 100644 --- a/cliv2/cmd/cliv2/main.go +++ b/cliv2/cmd/cliv2/main.go @@ -30,15 +30,16 @@ import ( "github.com/spf13/cobra" "github.com/spf13/pflag" - "github.com/snyk/cli/cliv2/cmd/cliv2/behavior/legacy" - "github.com/snyk/cli/cliv2/internal/cliv2" - "github.com/snyk/cli/cliv2/internal/constants" "github.com/snyk/go-application-framework/pkg/analytics" "github.com/snyk/go-application-framework/pkg/app" "github.com/snyk/go-application-framework/pkg/configuration" "github.com/snyk/go-application-framework/pkg/instrumentation" "github.com/snyk/go-application-framework/pkg/logging" + "github.com/snyk/cli/cliv2/cmd/cliv2/behavior/legacy" + "github.com/snyk/cli/cliv2/internal/cliv2" + "github.com/snyk/cli/cliv2/internal/constants" + cliv2utils "github.com/snyk/cli/cliv2/internal/utils" localworkflows "github.com/snyk/go-application-framework/pkg/local_workflows" @@ -188,7 +189,7 @@ func runWorkflowAndProcessData(engine workflow.Engine, logger *zerolog.Logger, n output, err := engine.Invoke(workflow.NewWorkflowIdentifier(name), workflow.WithInstrumentationCollector(ic)) if err != nil { - logger.Print("Failed to execute the command!", err) + logger.Print("Failed to execute the command! ", err) return err } @@ -527,6 +528,9 @@ func MainWithErrorCode() int { ua := networking.UserAgent(networking.UaWithConfig(globalConfiguration), networking.UaWithRuntimeInfo(rInfo), networking.UaWithOS(internalOS)) networkAccess := globalEngine.GetNetworkAccess() networkAccess.AddErrorHandler(func(err error, ctx context.Context) error { + if err == nil { + return nil + } errorListMutex.Lock() defer errorListMutex.Unlock() @@ -623,14 +627,13 @@ func MainWithErrorCode() int { } if err != nil { - err = decorateError(err) + err, errorList = processError(err, errorList) - errorList = append(errorList, err) for _, tempError := range errorList { - cliAnalytics.AddError(tempError) + if tempError != nil { + cliAnalytics.AddError(tempError) + } } - - err = legacyCLITerminated(err, errorList) } displayError(err, globalEngine.GetUserInterface(), globalConfiguration, ctx) @@ -659,13 +662,32 @@ func MainWithErrorCode() int { return exitCode } -func legacyCLITerminated(err error, errorList []error) error { - exitErr, isExitError := err.(*exec.ExitError) - if isExitError && exitErr.ExitCode() == constants.SNYK_EXIT_CODE_TS_CLI_TERMINATED { +func processError(err error, errorList []error) (error, []error) { + // ensure to use generic fallback error catalog error if no other is available + err = decorateError(err) + + // filter legacycli terminate errors since it is only used for internal purposes + if exitErr, isExitError := err.(*exec.ExitError); isExitError && exitErr.ExitCode() == constants.SNYK_EXIT_CODE_TS_CLI_TERMINATED { + err = nil + } + + // add all errors to analytics + if err != nil { errorList = append([]error{err}, errorList...) + } + + // create a single error from all errors + if len(errorList) == 1 { + err = errorList[0] + } else if len(errorList) > 1 { err = errors.Join(errorList...) } - return err + + // ensure to apply exit code mapping based on errors + if exitCode := mapErrorToExitCode(err); exitCode != unsetExitCode { + err = createErrorWithExitCode(exitCode, err) + } + return err, errorList } func setTimeout(config configuration.Configuration, onTimeout func()) { diff --git a/cliv2/internal/cliv2/cliv2.go b/cliv2/internal/cliv2/cliv2.go index 90eac95dbe..a139c3607b 100644 --- a/cliv2/internal/cliv2/cliv2.go +++ b/cliv2/internal/cliv2/cliv2.go @@ -553,8 +553,6 @@ func DeriveExitCode(err error) int { if returnCode < 0 || returnCode == constants.SNYK_EXIT_CODE_TS_CLI_TERMINATED { returnCode = constants.SNYK_EXIT_CODE_ERROR } - } else if errors.Is(err, context.DeadlineExceeded) { - returnCode = constants.SNYK_EXIT_CODE_EX_UNAVAILABLE } else if errors.As(err, &errorWithExitCode) { returnCode = errorWithExitCode.ExitCode } else { diff --git a/cliv2/internal/cliv2/cliv2_test.go b/cliv2/internal/cliv2/cliv2_test.go index 55ee2b45ad..3b3ff6ab43 100644 --- a/cliv2/internal/cliv2/cliv2_test.go +++ b/cliv2/internal/cliv2/cliv2_test.go @@ -561,9 +561,6 @@ func Test_setTimeout(t *testing.T) { err = cli.Execute(getProxyInfoForTest(), []string{"2"}) assert.ErrorIs(t, err, context.DeadlineExceeded) - - // ensure that -1 is correctly mapped if timeout is set - assert.Equal(t, constants.SNYK_EXIT_CODE_EX_UNAVAILABLE, cliv2.DeriveExitCode(err)) } func TestDeriveExitCode(t *testing.T) { @@ -574,7 +571,6 @@ func TestDeriveExitCode(t *testing.T) { }{ {name: "no error", err: nil, expected: constants.SNYK_EXIT_CODE_OK}, {name: "error with exit code", err: &cli_errors.ErrorWithExitCode{ExitCode: 42}, expected: 42}, - {name: "context.DeadlineExceeded", err: context.DeadlineExceeded, expected: constants.SNYK_EXIT_CODE_EX_UNAVAILABLE}, {name: "other error", err: errors.New("some other error"), expected: constants.SNYK_EXIT_CODE_ERROR}, } diff --git a/cliv2/internal/constants/constants.go b/cliv2/internal/constants/constants.go index 1f2ffa5b97..13d60adfbe 100644 --- a/cliv2/internal/constants/constants.go +++ b/cliv2/internal/constants/constants.go @@ -4,6 +4,7 @@ const SNYK_EXIT_CODE_OK = 0 const SNYK_EXIT_CODE_VULNERABILITIES_FOUND = 1 const SNYK_EXIT_CODE_ERROR = 2 const SNYK_EXIT_CODE_UNSUPPORTED_PROJECTS = 3 +const SNYK_EXIT_CODE_EX_TEMPFAIL = 75 // EX_TEMPFAIL, Temporary failure, indicating something that is not really an error. For example that a mailer could not create a connection, and the request should be reattempted later. const SNYK_EXIT_CODE_EX_UNAVAILABLE = 69 const SNYK_EXIT_CODE_TS_CLI_TERMINATED = 44 const SNYK_INTEGRATION_NAME = "CLI_V1_PLUGIN" diff --git a/src/cli/exit-codes.ts b/src/cli/exit-codes.ts index 699001c75d..6a3842b3b3 100644 --- a/src/cli/exit-codes.ts +++ b/src/cli/exit-codes.ts @@ -3,6 +3,7 @@ export const EXIT_CODES = { ERROR: 2, NO_SUPPORTED_PROJECTS_DETECTED: 3, EX_UNAVAILABLE: 69, + EX_TEMPFAIL: 75, EX_NOPERM: 77, EX_TERMINATE: 44, }; diff --git a/test/acceptance/fake-server.ts b/test/acceptance/fake-server.ts index e519f5ba40..995b056f7b 100644 --- a/test/acceptance/fake-server.ts +++ b/test/acceptance/fake-server.ts @@ -55,6 +55,8 @@ export type FakeServer = { setSarifResponse: (next: Record) => void; setNextResponse: (r: any) => void; setNextStatusCode: (c: number) => void; + setGlobalResponse: (response: Record, code: number) => void; + setEndpointResponse: ( endpoint: string, response: Record, @@ -163,6 +165,14 @@ export const fakeServer = (basePath: string, snykToken: string): FakeServer => { statusCodes = codes; }; + const setGlobalResponse = ( + response: Record, + code: number, + ) => { + endpointResponses.set('*', response); + endpointStatusCodes.set('*', code); + }; + const setEndpointResponse = ( endpoint: string, response: Record, @@ -204,8 +214,18 @@ export const fakeServer = (basePath: string, snykToken: string): FakeServer => { app.use((req, res, next) => { const endpoint = req.url; - const endpointResponse = endpointResponses.get(endpoint); - const endpointStatusCode = endpointStatusCodes.get(endpoint); + + const wildcardEndpoint = '*'; + let endpointResponse = endpointResponses.get(wildcardEndpoint); + if (!endpointResponse) { + endpointResponse = endpointResponses.get(endpoint); + } + + let endpointStatusCode = endpointStatusCodes.get(wildcardEndpoint); + if (!endpointStatusCode) { + endpointStatusCode = endpointStatusCodes.get(endpoint); + } + if (endpointResponse) { res.status(endpointStatusCode || 200); res.send(endpointResponse); @@ -1359,6 +1379,7 @@ export const fakeServer = (basePath: string, snykToken: string): FakeServer => { setNextStatusCode, setEndpointResponse, setEndpointStatusCode, + setGlobalResponse, setStatusCode, setStatusCodes, setFeatureFlag, diff --git a/test/jest/acceptance/exitcode.spec.ts b/test/jest/acceptance/exitcode.spec.ts index 7df86e4d29..b2abe21c50 100644 --- a/test/jest/acceptance/exitcode.spec.ts +++ b/test/jest/acceptance/exitcode.spec.ts @@ -1,10 +1,16 @@ import { runSnykCLI } from '../util/runSnykCLI'; import { isWindowsOperatingSystem, describeIf } from '../../utils'; +import { EXIT_CODES } from '../../../src/cli/exit-codes'; +import { fakeServer, getFirstIPv4Address } from '../../acceptance/fake-server'; +import { getAvailableServerPort } from '../util/getServerPort'; +const { promisify } = require('util'); jest.setTimeout(1000 * 60); +const notWindows = !isWindowsOperatingSystem(); + // Address as part CLI-1207 -describeIf(!isWindowsOperatingSystem)('exit code behaviour', () => { +describeIf(notWindows)('exit code behaviour - legacycli', () => { it.each([ { input: 0, expected: 0 }, { input: 1, expected: 1 }, @@ -21,3 +27,79 @@ describeIf(!isWindowsOperatingSystem)('exit code behaviour', () => { }, ); }); + +describe('exit code behaviour - general', () => { + it('Correct exit code when snyk_timeout_secs expires', async () => { + const testEnv = { + ...process.env, + SNYK_TIMEOUT_SECS: '1', + }; + + const { code } = await runSnykCLI(`test --all-projects -d`, { + env: testEnv, + }); + + expect(code).toEqual(EXIT_CODES.EX_UNAVAILABLE); + }); + + it('Test maintenance exit code', async () => { + const serverToken = 'random'; + const apiPath = '/api/v1'; + const apiPort = await getAvailableServerPort(process); + const fakeServerHost = 'http://' + getFirstIPv4Address() + ':' + apiPort; + const fakeServerUrl = fakeServerHost + apiPath; + const fakeServerEnv = { + ...process.env, + SNYK_API: fakeServerUrl, + SNYK_DISABLE_ANALYTICS: '1', + SNYK_HTTP_PROTOCOL_UPGRADE: '0', + INTERNAL_NETWORK_REQUEST_MAX_ATTEMPTS: '1', + }; + + const server = fakeServer(apiPath, serverToken); + const serverClose = promisify(server.close); + const serverListen = promisify(server.listen); + await serverListen(apiPort); + + const errorObject = { + jsonapi: { version: '1.0' }, + errors: [ + { + id: '11111111-2222-3333-4444-555555555555', + links: { + about: + 'https://docs.snyk.io/scan-with-snyk/error-catalog#snyk-0099', + }, + status: '503', + code: 'SNYK-0099', + title: 'Unavailable due to maintenance', + detail: '', + meta: { + links: [ + 'https://status.snyk.io/', + 'https://privatecloudstatus.snyk.io', + ], + isErrorCatalogError: true, + classification: 'UNSUPPORTED', + level: 'error', + }, + }, + ], + description: + 'We are currently unavailable due to a maintenance window. For additional information please visit our status pages. Thank you for your patience.', + }; + server.setGlobalResponse( + errorObject, + parseInt(errorObject['errors'][0].status), + ); + + const { code, stdout } = await runSnykCLI(`test`, { + env: fakeServerEnv, + }); + + await serverClose(); + + expect(stdout).toContain(errorObject['errors'][0].code); + expect(code).toEqual(EXIT_CODES.EX_TEMPFAIL); + }); +}); diff --git a/test/jest/acceptance/snyk-aibom/aibom.spec.ts b/test/jest/acceptance/snyk-aibom/aibom.spec.ts index 0592cfc217..44b62b47f6 100644 --- a/test/jest/acceptance/snyk-aibom/aibom.spec.ts +++ b/test/jest/acceptance/snyk-aibom/aibom.spec.ts @@ -280,7 +280,7 @@ describe('snyk aibom (mocked servers only)', () => { env, }, ); - expect(code).toEqual(2); + expect(code).toEqual(3); expect(stdout).toContain('No supported files (SNYK-AIBOM-0003)'); });