diff --git a/Makefile b/Makefile index d512f5a6..9fa93a30 100644 --- a/Makefile +++ b/Makefile @@ -3,6 +3,15 @@ # # SPDX-License-Identifier: EPL-2.0 # + +# Rather than keep tabs on all of the source folders, lets create the list of things we are dependent upon +# dynamically using a cool shell script. + +# A list of source files. This is completely expanded-out. +# Evaluates to something like this: ./pkg/tokensformatter/tokensFormatter.go ./pkg/tokensformatter/summaryFormatter_test.go +# We want to run some targets if any of the source files have changed. +SOURCE_FILES := $(shell find . -iname "*.go"| tr "\n" " ") embedded_info + all: tests galasactl gendocs-galasactl galasactl: \ @@ -15,51 +24,69 @@ galasactl: \ # 'gendocs-galasactl' is a command-line tool which generates documentation about the galasactl tool. # When executed, the .md produced contain up-to-date information on tool syntax. -gendocs-galasactl: bin/gendocs-galasactl-darwin-arm64 bin/gendocs-galasactl-linux-arm64 bin/gendocs-galasactl-darwin-x86_64 bin/gendocs-galasactl-linux-x86_64 - -tests: galasactl-source build/coverage.txt build/coverage.html - -build/coverage.out : galasactl-source +gendocs-galasactl: \ + bin/gendocs-galasactl-darwin-arm64 \ + bin/gendocs-galasactl-linux-arm64 \ + bin/gendocs-galasactl-darwin-x86_64 \ + bin/gendocs-galasactl-linux-x86_64 + +tests: $(SOURCE_FILES) build/coverage.txt build/coverage.html + +# Build a list of the source packages +# Note: +# - We don't want to include the generated galasaapi +# - We don't want to include the top-level command code at ./cmd +# - We join them into a comma-separated list +# - We smarten-up the begining element (remove ".,") +# - We smarten-up the end element (remove trailing ",") +# - We remove any spaces in the whole thing,. +# So we end up with something like this: ./pkg/api,./pkg/auth,./pkg/cmd,./pkg/embedded,./pkg/errors,./pkg/files ...etc. +COVERAGE_SOURCE_PACKAGES := ${shell find . -iname "*.go" \ + | xargs dirname {} \ + | grep -v "/pkg/galasaapi" \ + | grep -v "[.]/cmd/" \ + | sort \ + | uniq \ + | tr '\n' ',' \ + | sed "s/[.],//g" \ + | sed "s/,$$//g" \ + | sed "s/ //g" \ +} + +# If any source code has changed, re-run the unit tests. +build/coverage.out : $(SOURCE_FILES) mkdir -p build - go test -v -cover -coverprofile=build/coverage.out -coverpkg pkg/api,./pkg/auth,./pkg/cmd,./pkg/runsformatter,./pkg/errors,./pkg/launcher,./pkg/utils,./pkg/runs,./pkg/properties,./pkg/propertiesformatter,./pkg/resources ./pkg/... + echo "Coverage source packages are $(COVERAGE_SOURCE_PACKAGES)" + go test -v -cover -coverprofile=build/coverage.out -coverpkg $(COVERAGE_SOURCE_PACKAGES) ./pkg/... + +build/coverage-sanitised.out : build/coverage.out + cat build/coverage.out \ + | grep -v "Mock" \ + | grep -v "ixture" \ + > build/coverage-sanitised.out -build/coverage.html : build/coverage.out +# Unit test output --> an html report. +build/coverage.html : build/coverage-sanitised.out go tool cover -html=build/coverage.out -o build/coverage.html -build/coverage.txt : build/coverage.out +# Unit test output --> a text file report. +build/coverage.txt : build/coverage-sanitised.out go tool cover -func=build/coverage.out > build/coverage.txt cat build/coverage.txt -galasactl-source : \ - ./cmd/galasactl/*.go \ - ./pkg/api/*.go \ - ./pkg/auth/*.go \ - ./pkg/runsformatter/*.go \ - ./pkg/cmd/*.go \ - ./pkg/utils/*.go \ - ./pkg/runs/*.go \ - ./pkg/launcher/*.go \ - ./pkg/files/*.go \ - ./pkg/images/*.go \ - ./pkg/props/*.go \ - ./pkg/properties/*.go \ - ./pkg/propertiesformatter/*.go \ - ./pkg/resources/*.go \ - ./pkg/spi/*.go \ - ./pkg/tokensformatter/*.go \ - embedded_info +coverage : build/coverage.txt # The build process -embedded_info : \ - pkg/embedded/templates/version/build.properties - +GENERATED_BUILD_PROPERTIES_FILE := pkg/embedded/templates/version/build.properties +embedded_info : $(GENERATED_BUILD_PROPERTIES_FILE) + pkg/embedded/templates/version : mkdir -p $@ # Build a properties file containing versions of things. # Then the galasactl can embed the data and read it at run-time. -pkg/embedded/templates/version/build.properties : VERSION pkg/embedded/templates/version Makefile build.gradle +$(GENERATED_BUILD_PROPERTIES_FILE) : VERSION pkg/embedded/templates/version Makefile build.gradle echo "# Property file generated at build-time" > $@ # Turn the contents of VERSION file into a properties file value. cat VERSION | sed "s/^/galasactl.version = /1" >> $@ ; echo "" >> $@ @@ -72,34 +99,34 @@ pkg/embedded/templates/version/build.properties : VERSION pkg/embedded/templates echo "# version of the rest api that is compiled and the client is expecting from the ecosystem." >> $@ cat build/dependencies/openapi.yaml | grep "version :" | cut -f2 -d'"' | sed "s/^/galasactl.rest.api.version = /" >> $@ -bin/galasactl-linux-x86_64 : galasactl-source +bin/galasactl-linux-x86_64 : $(SOURCE_FILES) CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o bin/galasactl-linux-x86_64 ./cmd/galasactl -bin/galasactl-windows-x86_64.exe : galasactl-source +bin/galasactl-windows-x86_64.exe : $(SOURCE_FILES) CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o bin/galasactl-windows-x86_64.exe ./cmd/galasactl -bin/galasactl-darwin-x86_64 : galasactl-source +bin/galasactl-darwin-x86_64 : $(SOURCE_FILES) CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -o bin/galasactl-darwin-x86_64 ./cmd/galasactl -bin/galasactl-darwin-arm64 : galasactl-source +bin/galasactl-darwin-arm64 : $(SOURCE_FILES) CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -o bin/galasactl-darwin-arm64 ./cmd/galasactl -bin/galasactl-linux-arm64 : galasactl-source +bin/galasactl-linux-arm64 : $(SOURCE_FILES) CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o bin/galasactl-linux-arm64 ./cmd/galasactl -bin/galasactl-linux-s390x : galasactl-source +bin/galasactl-linux-s390x : $(SOURCE_FILES) CGO_ENABLED=0 GOOS=linux GOARCH=s390x go build -o bin/galasactl-linux-s390x ./cmd/galasactl -bin/gendocs-galasactl-darwin-arm64 : galasactl-source +bin/gendocs-galasactl-darwin-arm64 : $(SOURCE_FILES) CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -o bin/gendocs-galasactl-darwin-arm64 ./cmd/gendocs-galasactl -bin/gendocs-galasactl-linux-arm64 : galasactl-source +bin/gendocs-galasactl-linux-arm64 : $(SOURCE_FILES) CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o bin/gendocs-galasactl-linux-arm64 ./cmd/gendocs-galasactl -bin/gendocs-galasactl-linux-x86_64 : galasactl-source +bin/gendocs-galasactl-linux-x86_64 : $(SOURCE_FILES) CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o bin/gendocs-galasactl-linux-x86_64 ./cmd/gendocs-galasactl -bin/gendocs-galasactl-darwin-x86_64 : galasactl-source +bin/gendocs-galasactl-darwin-x86_64 : $(SOURCE_FILES) CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -o bin/gendocs-galasactl-darwin-x86_64 ./cmd/gendocs-galasactl clean: diff --git a/README.md b/README.md index c9e00991..7bf1641a 100644 --- a/README.md +++ b/README.md @@ -384,6 +384,20 @@ galasactl runs get --name C1234 --format badFormatterName ``` For a complete list of supported parameters see [here](./docs/generated/galasactl_runs_get.md). +## runs delete + +This command deletes a test run from an ecosystem's RAS. The name of the test run to delete can be provided to delete it along with any associated artifacts that have been stored. + +### Examples + +A run named "C1234" can be deleted using the following command: + +``` +galasactl runs delete --name C1234 +``` + +A complete list of supported parameters for the `runs delete` command is available [here](./docs/generated/galasactl_runs_delete.md) + ## runs download This command downloads all artifacts for a test run that are stored in an ecosystem's RAS. diff --git a/docs/generated/errors-list.md b/docs/generated/errors-list.md index 19496af7..3b75dd85 100644 --- a/docs/generated/errors-list.md +++ b/docs/generated/errors-list.md @@ -152,6 +152,14 @@ The `galasactl` tool can generate the following errors: - GAL1154E: The provided token ID, '{}', does not match formatting requirements. The token ID can contain any character in the 'a'-'z', 'A'-'Z', '0'-'9', '-' (dash), or '_' (underscore) ranges only. - GAL1155E: The id provided by the --id field cannot be an empty string. - GAL1156E: '{}' is not supported as a valid value. Valid values are 'me'. +- GAL1157E: An attempt to delete a run named '{}' failed. Cause is {} +- GAL1158E: An attempt to delete a run named '{}' failed. Sending the delete request to the Galasa service failed. Cause is {} +- GAL1159E: An attempt to delete a run named '{}' failed. Unexpected http status code {} received from the server. +- GAL1160E: An attempt to delete a run named '{}' failed. Unexpected http status code {} received from the server. Error details from the server could not be read. Cause: {} +- GAL1161E: An attempt to delete a run named '{}' failed. Unexpected http status code {} received from the server. Error details from the server are not in a valid json format. Cause: '{}' +- GAL1162E: An attempt to delete a run named '{}' failed. Unexpected http status code {} received from the server. Error details from the server are: '{}' +- GAL1163E: The run named '{}' could not be deleted because it was not found by the Galasa service. Try listing runs using 'galasactl runs get' to identify the one you wish to delete +- GAL1164E: An attempt to delete a run named '{}' failed. Unexpected http status code {} received from the server. Error details from the server are not in the json format. - GAL1225E: Failed to open file '{}' cause: {}. Check that this file exists, and that you have read permissions. - GAL1226E: Internal failure. Contents of gzip could be read, but not decoded. New gzip reader failed: file: {} error: {} - GAL1227E: Internal failure. Contents of gzip could not be decoded. {} error: {} diff --git a/docs/generated/galasactl_runs.md b/docs/generated/galasactl_runs.md index ab8c3551..f85b7169 100644 --- a/docs/generated/galasactl_runs.md +++ b/docs/generated/galasactl_runs.md @@ -24,6 +24,7 @@ Assembles, submits and monitors test runs in Galasa Ecosystem * [galasactl](galasactl.md) - CLI for Galasa * [galasactl runs cancel](galasactl_runs_cancel.md) - cancel an active run in the ecosystem +* [galasactl runs delete](galasactl_runs_delete.md) - Delete a named test run. * [galasactl runs download](galasactl_runs_download.md) - Download the artifacts of a test run which ran. * [galasactl runs get](galasactl_runs_get.md) - Get the details of a test runname which ran or is running. * [galasactl runs prepare](galasactl_runs_prepare.md) - prepares a list of tests diff --git a/docs/generated/galasactl_runs_delete.md b/docs/generated/galasactl_runs_delete.md new file mode 100644 index 00000000..0e54110f --- /dev/null +++ b/docs/generated/galasactl_runs_delete.md @@ -0,0 +1,31 @@ +## galasactl runs delete + +Delete a named test run. + +### Synopsis + +Delete a named test run. + +``` +galasactl runs delete [flags] +``` + +### Options + +``` + -h, --help Displays the options for the 'runs delete' command. + --name string the name of the test run we want to delete. +``` + +### Options inherited from parent commands + +``` + -b, --bootstrap string Bootstrap URL. Should start with 'http://' or 'file://'. If it starts with neither, it is assumed to be a fully-qualified path. If missing, it defaults to use the 'bootstrap.properties' file in your GALASA_HOME. Example: http://example.com/bootstrap, file:///user/myuserid/.galasa/bootstrap.properties , file://C:/Users/myuserid/.galasa/bootstrap.properties + --galasahome string Path to a folder where Galasa will read and write files and configuration settings. The default is '${HOME}/.galasa'. This overrides the GALASA_HOME environment variable which may be set instead. + -l, --log string File to which log information will be sent. Any folder referred to must exist. An existing file will be overwritten. Specify "-" to log to stderr. Defaults to not logging. +``` + +### SEE ALSO + +* [galasactl runs](galasactl_runs.md) - Manage test runs in the ecosystem + diff --git a/pkg/cmd/commandCollection.go b/pkg/cmd/commandCollection.go index a8048eb6..dec89295 100644 --- a/pkg/cmd/commandCollection.go +++ b/pkg/cmd/commandCollection.go @@ -53,6 +53,7 @@ const ( COMMAND_NAME_RUNS_SUBMIT_LOCAL = "runs submit local" COMMAND_NAME_RUNS_RESET = "runs reset" COMMAND_NAME_RUNS_CANCEL = "runs cancel" + COMMAND_NAME_RUNS_DELETE = "runs delete" COMMAND_NAME_RESOURCES = "resources" COMMAND_NAME_RESOURCES_APPLY = "resources apply" COMMAND_NAME_RESOURCES_CREATE = "resources create" @@ -297,6 +298,7 @@ func (commands *commandCollectionImpl) addRunsCommands(factory spi.Factory, root var runsSubmitLocalCommand spi.GalasaCommand var runsResetCommand spi.GalasaCommand var runsCancelCommand spi.GalasaCommand + var runsDeleteCommand spi.GalasaCommand runsCommand, err = NewRunsCmd(rootCommand) if err == nil { @@ -313,6 +315,9 @@ func (commands *commandCollectionImpl) addRunsCommands(factory spi.Factory, root runsResetCommand, err = NewRunsResetCommand(factory, runsCommand, rootCommand) if err == nil { runsCancelCommand, err = NewRunsCancelCommand(factory, runsCommand, rootCommand) + if err == nil { + runsDeleteCommand, err = NewRunsDeleteCommand(factory, runsCommand, rootCommand) + } } } } @@ -330,6 +335,7 @@ func (commands *commandCollectionImpl) addRunsCommands(factory spi.Factory, root commands.commandMap[runsSubmitLocalCommand.Name()] = runsSubmitLocalCommand commands.commandMap[runsResetCommand.Name()] = runsResetCommand commands.commandMap[runsCancelCommand.Name()] = runsCancelCommand + commands.commandMap[runsDeleteCommand.Name()] = runsDeleteCommand } return err diff --git a/pkg/cmd/factory.go b/pkg/cmd/factory.go index 148a364e..f8d2aca3 100644 --- a/pkg/cmd/factory.go +++ b/pkg/cmd/factory.go @@ -60,3 +60,7 @@ func (factory *RealFactory) GetAuthenticator(apiServerUrl string, galasaHome spi jwtCache := auth.NewJwtCache(factory.GetFileSystem(), galasaHome, factory.GetTimeService()) return auth.NewAuthenticator(apiServerUrl, factory.GetFileSystem(), galasaHome, factory.GetTimeService(), factory.GetEnvironment(), jwtCache) } + +func (*RealFactory) GetByteReader() spi.ByteReader { + return utils.NewByteReader() +} diff --git a/pkg/cmd/runsDelete.go b/pkg/cmd/runsDelete.go new file mode 100644 index 00000000..4ffc4720 --- /dev/null +++ b/pkg/cmd/runsDelete.go @@ -0,0 +1,157 @@ +/* + * Copyright contributors to the Galasa project + * + * SPDX-License-Identifier: EPL-2.0 + */ +package cmd + +import ( + "log" + + "github.com/galasa-dev/cli/pkg/api" + "github.com/galasa-dev/cli/pkg/galasaapi" + "github.com/galasa-dev/cli/pkg/runs" + "github.com/galasa-dev/cli/pkg/spi" + "github.com/galasa-dev/cli/pkg/utils" + "github.com/spf13/cobra" +) + +// Objective: Allow the user to do this: +// runs delete --name 12345 +// And then show the results in a human-readable form. + +// Variables set by cobra's command-line parsing. +type RunsDeleteCmdValues struct { + runName string +} + +type RunsDeleteCommand struct { + values *RunsDeleteCmdValues + cobraCommand *cobra.Command +} + +func NewRunsDeleteCommand(factory spi.Factory, runsCommand spi.GalasaCommand, rootCommand spi.GalasaCommand) (spi.GalasaCommand, error) { + cmd := new(RunsDeleteCommand) + err := cmd.init(factory, runsCommand, rootCommand) + return cmd, err +} + +// ------------------------------------------------------------------------------------------------ +// Public methods +// ------------------------------------------------------------------------------------------------ +func (cmd *RunsDeleteCommand) Name() string { + return COMMAND_NAME_RUNS_DELETE +} + +func (cmd *RunsDeleteCommand) CobraCommand() *cobra.Command { + return cmd.cobraCommand +} + +func (cmd *RunsDeleteCommand) Values() interface{} { + return cmd.values +} + +// ------------------------------------------------------------------------------------------------ +// Private methods +// ------------------------------------------------------------------------------------------------ + +func (cmd *RunsDeleteCommand) init(factory spi.Factory, runsCommand spi.GalasaCommand, rootCommand spi.GalasaCommand) error { + var err error + cmd.values = &RunsDeleteCmdValues{} + cmd.cobraCommand, err = cmd.createCobraCommand(factory, runsCommand, rootCommand.Values().(*RootCmdValues)) + return err +} + +func (cmd *RunsDeleteCommand) createCobraCommand( + factory spi.Factory, + runsCommand spi.GalasaCommand, + rootCmdValues *RootCmdValues, +) (*cobra.Command, error) { + + var err error + runsCmdValues := runsCommand.Values().(*RunsCmdValues) + + runsDeleteCobraCmd := &cobra.Command{ + Use: "delete", + Short: "Delete a named test run.", + Long: "Delete a named test run.", + Args: cobra.NoArgs, + Aliases: []string{"runs delete"}, + RunE: func(cobraCmd *cobra.Command, args []string) error { + return cmd.executeRunsDelete(factory, runsCmdValues, rootCmdValues) + }, + } + + runsDeleteCobraCmd.Flags().StringVar(&cmd.values.runName, "name", "", "the name of the test run we want to delete.") + + runsDeleteCobraCmd.MarkFlagRequired("name") + + runsCommand.CobraCommand().AddCommand(runsDeleteCobraCmd) + + return runsDeleteCobraCmd, err +} + +func (cmd *RunsDeleteCommand) executeRunsDelete( + factory spi.Factory, + runsCmdValues *RunsCmdValues, + rootCmdValues *RootCmdValues, +) error { + + var err error + + // Operations on the file system will all be relative to the current folder. + fileSystem := factory.GetFileSystem() + + err = utils.CaptureLog(fileSystem, rootCmdValues.logFileName) + if err == nil { + rootCmdValues.isCapturingLogs = true + + log.Println("Galasa CLI - Delete runs about to execute") + + // Get the ability to query environment variables. + env := factory.GetEnvironment() + + var galasaHome spi.GalasaHome + galasaHome, err = utils.NewGalasaHome(fileSystem, env, rootCmdValues.CmdParamGalasaHomePath) + if err == nil { + + // Read the bootstrap properties. + var urlService *api.RealUrlResolutionService = new(api.RealUrlResolutionService) + var bootstrapData *api.BootstrapData + bootstrapData, err = api.LoadBootstrap(galasaHome, fileSystem, env, runsCmdValues.bootstrap, urlService) + if err == nil { + + var console = factory.GetStdOutConsole() + timeService := factory.GetTimeService() + + apiServerUrl := bootstrapData.ApiServerURL + log.Printf("The API server is at '%s'\n", apiServerUrl) + + authenticator := factory.GetAuthenticator( + apiServerUrl, + galasaHome, + ) + + var apiClient *galasaapi.APIClient + apiClient, err = authenticator.GetAuthenticatedAPIClient() + + byteReader := factory.GetByteReader() + + if err == nil { + // Call to process the command in a unit-testable way. + err = runs.RunsDelete( + cmd.values.runName, + console, + apiServerUrl, + apiClient, + timeService, + byteReader, + ) + } + } + } + } + + log.Printf("executeRunsDelete returning %v", err) + return err +} diff --git a/pkg/cmd/runsSubmit.go b/pkg/cmd/runsSubmit.go index db8ce46c..4251cc01 100644 --- a/pkg/cmd/runsSubmit.go +++ b/pkg/cmd/runsSubmit.go @@ -170,7 +170,7 @@ func (cmd *RunsSubmitCommand) executeSubmit( if err == nil { timeService := factory.GetTimeService() - var launcherInstance launcher.Launcher = nil + var launcherInstance launcher.Launcher // The launcher we are going to use to start/monitor tests. apiServerUrl := bootstrapData.ApiServerURL diff --git a/pkg/errors/errorMessage.go b/pkg/errors/errorMessage.go index 517fb77c..69206fbd 100644 --- a/pkg/errors/errorMessage.go +++ b/pkg/errors/errorMessage.go @@ -249,6 +249,17 @@ var ( GALASA_ERROR_INVALID_TOKEN_ID_FORMAT = NewMessageType("GAL1154E: The provided token ID, '%s', does not match formatting requirements. The token ID can contain any character in the 'a'-'z', 'A'-'Z', '0'-'9', '-' (dash), or '_' (underscore) ranges only.", 1154, STACK_TRACE_NOT_WANTED) GALASA_ERROR_MISSING_USER_LOGIN_ID_FLAG = NewMessageType("GAL1155E: The id provided by the --id field cannot be an empty string.", 1155, STACK_TRACE_NOT_WANTED) GALASA_ERROR_LOGIN_ID_NOT_SUPPORTED = NewMessageType("GAL1156E: '%s' is not supported as a valid value. Valid values are 'me'.", 1156, STACK_TRACE_NOT_WANTED) + GALASA_ERROR_DELETE_RUN_FAILED = NewMessageType("GAL1157E: An attempt to delete a run named '%s' failed. Cause is %s", 1157, STACK_TRACE_NOT_WANTED) + GALASA_ERROR_SERVER_DELETE_RUNS_FAILED = NewMessageType("GAL1158E: An attempt to delete a run named '%s' failed. Sending the delete request to the Galasa service failed. Cause is %v", 1158, STACK_TRACE_NOT_WANTED) + + // 4 related but slightly different errors, when an HTTP response arrives from the Galasa server, and we can/can't parse the payload to get the message details out. + GALASA_ERROR_DELETE_RUNS_NO_RESPONSE_CONTENT = NewMessageType("GAL1159E: An attempt to delete a run named '%s' failed. Unexpected http status code %v received from the server.", 1159, STACK_TRACE_NOT_WANTED) + GALASA_ERROR_DELETE_RUNS_RESPONSE_PAYLOAD_UNREADABLE = NewMessageType("GAL1160E: An attempt to delete a run named '%s' failed. Unexpected http status code %v received from the server. Error details from the server could not be read. Cause: %s", 1160, STACK_TRACE_NOT_WANTED) + GALASA_ERROR_DELETE_RUNS_UNPARSEABLE_CONTENT = NewMessageType("GAL1161E: An attempt to delete a run named '%s' failed. Unexpected http status code %v received from the server. Error details from the server are not in a valid json format. Cause: '%s'", 1161, STACK_TRACE_NOT_WANTED) + GALASA_ERROR_DELETE_RUNS_SERVER_REPORTED_ERROR = NewMessageType("GAL1162E: An attempt to delete a run named '%s' failed. Unexpected http status code %v received from the server. Error details from the server are: '%s'", 1162, STACK_TRACE_NOT_WANTED) + + GALASA_ERROR_SERVER_DELETE_RUN_NOT_FOUND = NewMessageType("GAL1163E: The run named '%s' could not be deleted because it was not found by the Galasa service. Try listing runs using 'galasactl runs get' to identify the one you wish to delete", 1163, STACK_TRACE_NOT_WANTED) + GALASA_ERROR_DELETE_RUNS_EXPLANATION_NOT_JSON = NewMessageType("GAL1164E: An attempt to delete a run named '%s' failed. Unexpected http status code %v received from the server. Error details from the server are not in the json format.", 1164, STACK_TRACE_NOT_WANTED) // Warnings... GALASA_WARNING_MAVEN_NO_GALASA_OBR_REPO = NewMessageType("GAL2000W: Warning: Maven configuration file settings.xml should contain a reference to a Galasa repository so that the galasa OBR can be resolved. The official release repository is '%s', and 'pre-release' repository is '%s'", 2000, STACK_TRACE_WANTED) diff --git a/pkg/errors/galasaAPIError.go b/pkg/errors/galasaAPIError.go index 4b756744..eb04bef8 100644 --- a/pkg/errors/galasaAPIError.go +++ b/pkg/errors/galasaAPIError.go @@ -20,7 +20,15 @@ type GalasaAPIError struct { // the reason for the failure. // NOTE: when this function is called ensure that the calling function has the `defer resp.Body.Close()` // called in order to ensure that the response body is closed when the function completes -func GetApiErrorFromResponse(body []byte) (*GalasaAPIError, error){ +func GetApiErrorFromResponse(body []byte) (*GalasaAPIError, error) { + return GetApiErrorFromResponseBytes(body, func(marshallingError error) error{ + err := NewGalasaError(GALASA_ERROR_UNABLE_TO_READ_RESPONSE_BODY, marshallingError) + return err + }, + ) +} + +func GetApiErrorFromResponseBytes(body []byte, marshallingErrorLambda func(marshallingError error) error) (*GalasaAPIError, error) { var err error apiError := new(GalasaAPIError) @@ -28,8 +36,8 @@ func GetApiErrorFromResponse(body []byte) (*GalasaAPIError, error){ err = json.Unmarshal(body, &apiError) if err != nil { - log.Printf("GetApiErrorFromResponse FAIL - %v", err) - err = NewGalasaError(GALASA_ERROR_UNABLE_TO_READ_RESPONSE_BODY, err.Error()) + log.Printf("GetApiErrorFromResponseBytes failed to unmarshal bytes into a galasa api error structure. %v", err.Error()) + err = marshallingErrorLambda(err) } return apiError, err } diff --git a/pkg/runs/runsDelete.go b/pkg/runs/runsDelete.go new file mode 100644 index 00000000..7aae4f46 --- /dev/null +++ b/pkg/runs/runsDelete.go @@ -0,0 +1,165 @@ +/* + * Copyright contributors to the Galasa project + * + * SPDX-License-Identifier: EPL-2.0 + */ +package runs + +import ( + "context" + "log" + "net/http" + + "github.com/galasa-dev/cli/pkg/embedded" + galasaErrors "github.com/galasa-dev/cli/pkg/errors" + "github.com/galasa-dev/cli/pkg/galasaapi" + "github.com/galasa-dev/cli/pkg/spi" +) + +// --------------------------------------------------- + +// RunsDelete - performs all the logic to implement the `galasactl runs delete` command, +// but in a unit-testable manner. +func RunsDelete( + runName string, + console spi.Console, + apiServerUrl string, + apiClient *galasaapi.APIClient, + timeService spi.TimeService, + byteReader spi.ByteReader, +) error { + var err error + + log.Printf("RunsDelete entered.") + + if runName != "" { + // Validate the runName as best we can without contacting the ecosystem. + err = ValidateRunName(runName) + } + + if err == nil { + + requestorParameter := "" + resultParameter := "" + fromAgeHours := 0 + toAgeHours := 0 + shouldGetActive := false + var runs []galasaapi.Run + runs, err = GetRunsFromRestApi(runName, requestorParameter, resultParameter, fromAgeHours, toAgeHours, shouldGetActive, timeService, apiClient) + + if err == nil { + + if len(runs) == 0 { + err = galasaErrors.NewGalasaError(galasaErrors.GALASA_ERROR_SERVER_DELETE_RUN_NOT_FOUND, runName) + } else { + err = deleteRuns(runs, apiClient, byteReader) + } + } + + if err != nil { + console.WriteString(err.Error()) + } + } + log.Printf("RunsDelete exiting. err is %v\n", err) + return err +} + +func deleteRuns( + runs []galasaapi.Run, + apiClient *galasaapi.APIClient, + byteReader spi.ByteReader, +) error { + var err error + + var restApiVersion string + var context context.Context = nil + + var httpResponse *http.Response + + restApiVersion, err = embedded.GetGalasactlRestApiVersion() + if err == nil { + for _, run := range runs { + runId := run.GetRunId() + runName := *run.GetTestStructure().RunName + + apicall := apiClient.ResultArchiveStoreAPIApi.DeleteRasRunById(context, runId).ClientApiVersion(restApiVersion) + httpResponse, err = apicall.Execute() + + // 200-299 http status codes manifest in an error. + if err != nil { + if httpResponse == nil { + // We never got a response, error sending it or something ? + err = galasaErrors.NewGalasaError(galasaErrors.GALASA_ERROR_SERVER_DELETE_RUNS_FAILED, err.Error()) + } else { + err = httpResponseToGalasaError( + httpResponse, + runName, + byteReader, + galasaErrors.GALASA_ERROR_DELETE_RUNS_NO_RESPONSE_CONTENT, + galasaErrors.GALASA_ERROR_DELETE_RUNS_RESPONSE_PAYLOAD_UNREADABLE, + galasaErrors.GALASA_ERROR_DELETE_RUNS_UNPARSEABLE_CONTENT, + galasaErrors.GALASA_ERROR_DELETE_RUNS_SERVER_REPORTED_ERROR, + galasaErrors.GALASA_ERROR_DELETE_RUNS_EXPLANATION_NOT_JSON, + ) + } + } + + if err != nil { + break + } else { + log.Printf("Run with runId '%s' and runName '%s', was deleted OK.\n", runId, run.TestStructure.GetRunName()) + } + } + } + + return err +} + +func httpResponseToGalasaError( + response *http.Response, + identifier string, + byteReader spi.ByteReader, + errorMsgUnexpectedStatusCodeNoResponseBody *galasaErrors.MessageType, + errorMsgUnableToReadResponseBody *galasaErrors.MessageType, + errorMsgResponsePayloadInWrongFormat *galasaErrors.MessageType, + errorMsgReceivedFromApiServer *galasaErrors.MessageType, + errorMsgResponseContentTypeNotJson *galasaErrors.MessageType, +) error { + defer response.Body.Close() + var err error + var responseBodyBytes []byte + statusCode := response.StatusCode + + if response.ContentLength == 0 { + log.Printf("Failed - HTTP response - status code: '%v'\n", statusCode) + err = galasaErrors.NewGalasaError(errorMsgUnexpectedStatusCodeNoResponseBody, identifier, statusCode) + } else { + + contentType := response.Header.Get("Content-Type") + if contentType != "application/json" { + err = galasaErrors.NewGalasaError(errorMsgResponseContentTypeNotJson, identifier, statusCode) + } else { + responseBodyBytes, err = byteReader.ReadAll(response.Body) + if err != nil { + err = galasaErrors.NewGalasaError(errorMsgUnableToReadResponseBody, identifier, statusCode, err.Error()) + } else { + + var errorFromServer *galasaErrors.GalasaAPIError + errorFromServer, err = galasaErrors.GetApiErrorFromResponseBytes( + responseBodyBytes, + func (marshallingError error) error { + log.Printf("Failed - HTTP response - status code: '%v' payload in response is not json: '%v' \n", statusCode, string(responseBodyBytes)) + return galasaErrors.NewGalasaError(errorMsgResponsePayloadInWrongFormat, identifier, statusCode, marshallingError) + }, + ) + + if err == nil { + // server returned galasa api error structure we understand. + log.Printf("Failed - HTTP response - status code: '%v' server responded with error message: '%v' \n", statusCode, errorMsgReceivedFromApiServer) + err = galasaErrors.NewGalasaError(errorMsgReceivedFromApiServer, identifier, statusCode, errorFromServer.Message) + } + } + } + } + return err +} diff --git a/pkg/runs/runsDelete_test.go b/pkg/runs/runsDelete_test.go new file mode 100644 index 00000000..950a4685 --- /dev/null +++ b/pkg/runs/runsDelete_test.go @@ -0,0 +1,473 @@ +/* + * Copyright contributors to the Galasa project + * + * SPDX-License-Identifier: EPL-2.0 + */ +package runs + +import ( + "encoding/json" + "net/http" + "strconv" + "testing" + + "github.com/galasa-dev/cli/pkg/api" + "github.com/galasa-dev/cli/pkg/errors" + "github.com/galasa-dev/cli/pkg/galasaapi" + "github.com/galasa-dev/cli/pkg/utils" + "github.com/stretchr/testify/assert" +) + +func createMockRun(runName string, runId string) galasaapi.Run { + run := *galasaapi.NewRun() + run.SetRunId(runId) + testStructure := *galasaapi.NewTestStructure() + testStructure.SetRunName(runName) + + run.SetTestStructure(testStructure) + return run +} + +func TestCanDeleteARun(t *testing.T) { + // Given... + runName := "J20" + runId := "J234567890" + + // Create the mock run to be deleted + runToDelete := createMockRun(runName, runId) + runToDeleteBytes, _ := json.Marshal(runToDelete) + runToDeleteJson := string(runToDeleteBytes) + + // Create the expected HTTP interactions with the API server + getRunsInteraction := utils.NewHttpInteraction("/ras/runs", http.MethodGet) + getRunsInteraction.WriteHttpResponseFunc = func(writer http.ResponseWriter, req *http.Request) { + WriteMockRasRunsResponse(t, writer, req, runName, []string{ runToDeleteJson }) + } + + deleteRunsInteraction := utils.NewHttpInteraction("/ras/runs/" + runId, http.MethodDelete) + deleteRunsInteraction.WriteHttpResponseFunc = func(writer http.ResponseWriter, req *http.Request) { + writer.WriteHeader(http.StatusNoContent) + } + + interactions := []utils.HttpInteraction{ + getRunsInteraction, + deleteRunsInteraction, + } + + server := utils.NewMockHttpServer(t, interactions) + defer server.Server.Close() + + console := utils.NewMockConsole() + apiServerUrl := server.Server.URL + apiClient := api.InitialiseAPI(apiServerUrl) + mockTimeService := utils.NewMockTimeService() + mockByteReader := utils.NewMockByteReader() + + // When... + err := RunsDelete( + runName, + console, + apiServerUrl, + apiClient, + mockTimeService, + mockByteReader) + + // Then... + assert.Nil(t, err, "RunsDelete returned an unexpected error") + assert.Empty(t, console.ReadText(), "The console was written to on a successful deletion, it should be empty") +} + +func TestCanDeleteRunAndReruns(t *testing.T) { + // Given... + runName := "J20" + runId := "J234567890" + reRun1Id := "ABC123" + reRun2Id := "DEF456" + + // Create the mock runs to be deleted - re-runs should have the same run name but different run IDs + runToDelete := createMockRun(runName, runId) + runToDeleteBytes, _ := json.Marshal(runToDelete) + runToDeleteJson := string(runToDeleteBytes) + + reRun1 := createMockRun(runName, reRun1Id) + reRun1Bytes, _ := json.Marshal(reRun1) + reRun1Json := string(reRun1Bytes) + + reRun2 := createMockRun(runName, reRun2Id) + reRun2Bytes, _ := json.Marshal(reRun2) + reRun2Json := string(reRun2Bytes) + + runsAsJsonStrings := []string{ + runToDeleteJson, + reRun1Json, + reRun2Json, + } + + // Create the expected HTTP interactions with the API server + getRunsInteraction := utils.NewHttpInteraction("/ras/runs", http.MethodGet) + getRunsInteraction.WriteHttpResponseFunc = func(writer http.ResponseWriter, req *http.Request) { + WriteMockRasRunsResponse(t, writer, req, runName, runsAsJsonStrings) + } + + successfulDeleteFunc := func(writer http.ResponseWriter, req *http.Request) { + writer.WriteHeader(http.StatusNoContent) + } + + deleteRunInteraction := utils.NewHttpInteraction("/ras/runs/" + runId, http.MethodDelete) + deleteRunInteraction.WriteHttpResponseFunc = successfulDeleteFunc + + deleteRerun1Interaction := utils.NewHttpInteraction("/ras/runs/" + reRun1Id, http.MethodDelete) + deleteRerun1Interaction.WriteHttpResponseFunc = successfulDeleteFunc + + deleteRerun2Interaction := utils.NewHttpInteraction("/ras/runs/" + reRun2Id, http.MethodDelete) + deleteRerun2Interaction.WriteHttpResponseFunc = successfulDeleteFunc + + interactions := []utils.HttpInteraction{ + getRunsInteraction, + deleteRunInteraction, + deleteRerun1Interaction, + deleteRerun2Interaction, + } + + server := utils.NewMockHttpServer(t, interactions) + defer server.Server.Close() + + console := utils.NewMockConsole() + apiServerUrl := server.Server.URL + apiClient := api.InitialiseAPI(apiServerUrl) + mockTimeService := utils.NewMockTimeService() + mockByteReader := utils.NewMockByteReader() + + // When... + err := RunsDelete( + runName, + console, + apiServerUrl, + apiClient, + mockTimeService, + mockByteReader) + + // Then... + assert.Nil(t, err, "RunsDelete returned an unexpected error") + assert.Empty(t, console.ReadText(), "The console was written to on a successful deletion, it should be empty") +} + +func TestDeleteNonExistantRunDisplaysError(t *testing.T) { + // Given... + nonExistantRunName := "runDoesNotExist123" + + // Create the expected HTTP interactions with the API server + getRunsInteraction := utils.NewHttpInteraction("/ras/runs", http.MethodGet) + getRunsInteraction.WriteHttpResponseFunc = func(writer http.ResponseWriter, req *http.Request) { + WriteMockRasRunsResponse(t, writer, req, nonExistantRunName, []string{}) + } + + interactions := []utils.HttpInteraction{ getRunsInteraction } + + server := utils.NewMockHttpServer(t, interactions) + defer server.Server.Close() + + console := utils.NewMockConsole() + apiServerUrl := server.Server.URL + apiClient := api.InitialiseAPI(apiServerUrl) + mockTimeService := utils.NewMockTimeService() + mockByteReader := utils.NewMockByteReader() + + // When... + err := RunsDelete( + nonExistantRunName, + console, + apiServerUrl, + apiClient, + mockTimeService, + mockByteReader) + + // Then... + assert.NotNil(t, err, "RunsDelete did not return an error but it should have") + consoleOutputText := console.ReadText() + assert.Contains(t, consoleOutputText, nonExistantRunName) + assert.Contains(t, consoleOutputText, "GAL1163E") + assert.Contains(t, consoleOutputText, "The run named 'runDoesNotExist123' could not be deleted") +} + +func TestRunsDeleteFailsWithNoExplanationErrorPayloadGivesCorrectMessage(t *testing.T) { + // Given... + runName := "J20" + runId := "J234567890" + + // Create the mock run to be deleted + runToDelete := createMockRun(runName, runId) + runToDeleteBytes, _ := json.Marshal(runToDelete) + runToDeleteJson := string(runToDeleteBytes) + + // Create the expected HTTP interactions with the API server + getRunsInteraction := utils.NewHttpInteraction("/ras/runs", http.MethodGet) + getRunsInteraction.WriteHttpResponseFunc = func(writer http.ResponseWriter, req *http.Request) { + WriteMockRasRunsResponse(t, writer, req, runName, []string{ runToDeleteJson }) + } + + deleteRunsInteraction := utils.NewHttpInteraction("/ras/runs/" + runId, http.MethodDelete) + deleteRunsInteraction.WriteHttpResponseFunc = func(writer http.ResponseWriter, req *http.Request) { + writer.WriteHeader(http.StatusInternalServerError) + } + + interactions := []utils.HttpInteraction{ + getRunsInteraction, + deleteRunsInteraction, + } + + server := utils.NewMockHttpServer(t, interactions) + defer server.Server.Close() + + console := utils.NewMockConsole() + apiServerUrl := server.Server.URL + apiClient := api.InitialiseAPI(apiServerUrl) + mockTimeService := utils.NewMockTimeService() + mockByteReader := utils.NewMockByteReader() + + // When... + err := RunsDelete( + runName, + console, + apiServerUrl, + apiClient, + mockTimeService, + mockByteReader) + + // Then... + assert.NotNil(t, err, "RunsDelete did not return an error but it should have") + consoleText := console.ReadText() + assert.Contains(t, consoleText , runName) + assert.Contains(t, consoleText , "GAL1159E") +} + +func TestRunsDeleteFailsWithNonJsonContentTypeExplanationErrorPayloadGivesCorrectMessage(t *testing.T) { + // Given... + runName := "J20" + runId := "J234567890" + + // Create the mock run to be deleted + runToDelete := createMockRun(runName, runId) + runToDeleteBytes, _ := json.Marshal(runToDelete) + runToDeleteJson := string(runToDeleteBytes) + + // Create the expected HTTP interactions with the API server + getRunsInteraction := utils.NewHttpInteraction("/ras/runs", http.MethodGet) + getRunsInteraction.WriteHttpResponseFunc = func(writer http.ResponseWriter, req *http.Request) { + WriteMockRasRunsResponse(t, writer, req, runName, []string{ runToDeleteJson }) + } + + deleteRunsInteraction := utils.NewHttpInteraction("/ras/runs/" + runId, http.MethodDelete) + deleteRunsInteraction.WriteHttpResponseFunc = func(writer http.ResponseWriter, req *http.Request) { + writer.WriteHeader(http.StatusInternalServerError) + writer.Header().Set("Content-Type", "application/notJsonOnPurpose") + writer.Write([]byte("something not json but non-zero-length.")) + } + + interactions := []utils.HttpInteraction{ + getRunsInteraction, + deleteRunsInteraction, + } + + server := utils.NewMockHttpServer(t, interactions) + defer server.Server.Close() + + console := utils.NewMockConsole() + apiServerUrl := server.Server.URL + apiClient := api.InitialiseAPI(apiServerUrl) + mockTimeService := utils.NewMockTimeService() + mockByteReader := utils.NewMockByteReader() + + // When... + err := RunsDelete( + runName, + console, + apiServerUrl, + apiClient, + mockTimeService, + mockByteReader) + + // Then... + assert.NotNil(t, err, "RunsDelete did not return an error but it should have") + consoleText := console.ReadText() + assert.Contains(t, consoleText, runName) + assert.Contains(t, consoleText, strconv.Itoa(http.StatusInternalServerError)) + assert.Contains(t, consoleText, "GAL1164E") + assert.Contains(t, consoleText, "Error details from the server are not in the json format") +} + +func TestRunsDeleteFailsWithBadlyFormedJsonContentExplanationErrorPayloadGivesCorrectMessage(t *testing.T) { + // Given... + runName := "J20" + runId := "J234567890" + + // Create the mock run to be deleted + runToDelete := createMockRun(runName, runId) + runToDeleteBytes, _ := json.Marshal(runToDelete) + runToDeleteJson := string(runToDeleteBytes) + + // Create the expected HTTP interactions with the API server + getRunsInteraction := utils.NewHttpInteraction("/ras/runs", http.MethodGet) + getRunsInteraction.WriteHttpResponseFunc = func(writer http.ResponseWriter, req *http.Request) { + WriteMockRasRunsResponse(t, writer, req, runName, []string{ runToDeleteJson }) + } + + deleteRunsInteraction := utils.NewHttpInteraction("/ras/runs/" + runId, http.MethodDelete) + deleteRunsInteraction.WriteHttpResponseFunc = func(writer http.ResponseWriter, req *http.Request) { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusInternalServerError) + writer.Write([]byte(`{ "this": "isBadJson because it doesnt end in a close braces" `)) + } + + interactions := []utils.HttpInteraction{ + getRunsInteraction, + deleteRunsInteraction, + } + + server := utils.NewMockHttpServer(t, interactions) + defer server.Server.Close() + + console := utils.NewMockConsole() + apiServerUrl := server.Server.URL + apiClient := api.InitialiseAPI(apiServerUrl) + mockTimeService := utils.NewMockTimeService() + mockByteReader := utils.NewMockByteReader() + + // When... + err := RunsDelete( + runName, + console, + apiServerUrl, + apiClient, + mockTimeService, + mockByteReader) + + // Then... + assert.NotNil(t, err, "RunsDelete did not return an error but it should have") + consoleText := console.ReadText() + assert.Contains(t, consoleText, runName) + assert.Contains(t, consoleText, strconv.Itoa(http.StatusInternalServerError)) + assert.Contains(t, consoleText, "GAL1161E") + assert.Contains(t, consoleText, "Error details from the server are not in a valid json format") + assert.Contains(t, consoleText, "Cause: 'unexpected end of JSON input'") +} + +func TestRunsDeleteFailsWithValidErrorResponsePayloadGivesCorrectMessage(t *testing.T) { + // Given... + runName := "J20" + runId := "J234567890" + apiErrorCode := 5000 + apiErrorMessage := "this is an error from the API server" + + // Create the mock run to be deleted + runToDelete := createMockRun(runName, runId) + runToDeleteBytes, _ := json.Marshal(runToDelete) + runToDeleteJson := string(runToDeleteBytes) + + // Create the expected HTTP interactions with the API server + getRunsInteraction := utils.NewHttpInteraction("/ras/runs", http.MethodGet) + getRunsInteraction.WriteHttpResponseFunc = func(writer http.ResponseWriter, req *http.Request) { + WriteMockRasRunsResponse(t, writer, req, runName, []string{ runToDeleteJson }) + } + + deleteRunsInteraction := utils.NewHttpInteraction("/ras/runs/" + runId, http.MethodDelete) + deleteRunsInteraction.WriteHttpResponseFunc = func(writer http.ResponseWriter, req *http.Request) { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusInternalServerError) + + apiError := errors.GalasaAPIError{ + Code: apiErrorCode, + Message: apiErrorMessage, + } + apiErrorBytes, _ := json.Marshal(apiError) + writer.Write(apiErrorBytes) + } + + interactions := []utils.HttpInteraction{ + getRunsInteraction, + deleteRunsInteraction, + } + + server := utils.NewMockHttpServer(t, interactions) + defer server.Server.Close() + + console := utils.NewMockConsole() + apiServerUrl := server.Server.URL + apiClient := api.InitialiseAPI(apiServerUrl) + mockTimeService := utils.NewMockTimeService() + mockByteReader := utils.NewMockByteReader() + + // When... + err := RunsDelete( + runName, + console, + apiServerUrl, + apiClient, + mockTimeService, + mockByteReader) + + // Then... + assert.NotNil(t, err, "RunsDelete did not return an error but it should have") + consoleText := console.ReadText() + assert.Contains(t, consoleText, runName) + assert.Contains(t, consoleText, strconv.Itoa(http.StatusInternalServerError)) + assert.Contains(t, consoleText, "GAL1162E") + assert.Contains(t, consoleText, apiErrorMessage) +} + + +func TestRunsDeleteFailsWithFailureToReadResponseBodyGivesCorrectMessage(t *testing.T) { + // Given... + runName := "J20" + runId := "J234567890" + + // Create the mock run to be deleted + runToDelete := createMockRun(runName, runId) + runToDeleteBytes, _ := json.Marshal(runToDelete) + runToDeleteJson := string(runToDeleteBytes) + + // Create the expected HTTP interactions with the API server + getRunsInteraction := utils.NewHttpInteraction("/ras/runs", http.MethodGet) + getRunsInteraction.WriteHttpResponseFunc = func(writer http.ResponseWriter, req *http.Request) { + WriteMockRasRunsResponse(t, writer, req, runName, []string{ runToDeleteJson }) + } + + deleteRunsInteraction := utils.NewHttpInteraction("/ras/runs/" + runId, http.MethodDelete) + deleteRunsInteraction.WriteHttpResponseFunc = func(writer http.ResponseWriter, req *http.Request) { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusInternalServerError) + writer.Write([]byte(`{}`)) + } + + interactions := []utils.HttpInteraction{ + getRunsInteraction, + deleteRunsInteraction, + } + + server := utils.NewMockHttpServer(t, interactions) + defer server.Server.Close() + + console := utils.NewMockConsole() + apiServerUrl := server.Server.URL + apiClient := api.InitialiseAPI(apiServerUrl) + mockTimeService := utils.NewMockTimeService() + mockByteReader := utils.NewMockByteReaderAsMock(true) + + // When... + err := RunsDelete( + runName, + console, + apiServerUrl, + apiClient, + mockTimeService, + mockByteReader) + + // Then... + assert.NotNil(t, err, "RunsDelete returned an unexpected error") + consoleText := console.ReadText() + assert.Contains(t, consoleText, runName) + assert.Contains(t, consoleText, strconv.Itoa(http.StatusInternalServerError)) + assert.Contains(t, consoleText, "GAL1160E") + assert.Contains(t, consoleText, "GAL1160E") + assert.Contains(t, consoleText, "Error details from the server could not be read") +} diff --git a/pkg/runs/submitter.go b/pkg/runs/submitter.go index dc012e3e..d2af4c1c 100644 --- a/pkg/runs/submitter.go +++ b/pkg/runs/submitter.go @@ -186,7 +186,7 @@ func (submitter *Submitter) executeSubmitRuns( now := submitter.timeService.Now() if now.After(nextProgressReport) { //convert TestRun - displayInterrimProgressReport(readyRuns, submittedRuns, finishedRuns, lostRuns, throttle) + submitter.displayInterrimProgressReport(readyRuns, submittedRuns, finishedRuns, lostRuns, throttle) nextProgressReport = now.Add(progressReportInterval) } } @@ -206,7 +206,7 @@ func (submitter *Submitter) executeSubmitRuns( return finishedRuns, lostRuns, err } -func displayInterrimProgressReport(readyRuns []TestRun, +func (submitter *Submitter) displayInterrimProgressReport(readyRuns []TestRun, submittedRuns map[string]*TestRun, finishedRuns map[string]*TestRun, lostRuns map[string]*TestRun, @@ -217,17 +217,16 @@ func displayInterrimProgressReport(readyRuns []TestRun, finished := len(finishedRuns) lost := len(lostRuns) - fmt.Println("Progress report") + log.Println("Progress report") for runName, run := range submittedRuns { log.Printf("*** Run %v is currently %v - %v/%v/%v\n", runName, run.Status, run.Stream, run.Bundle, run.Class) - fmt.Printf("Run %v - %v/%v/%v\n", runName, run.Stream, run.Bundle, run.Class) } - fmt.Println("----------------------------------------------------------------------------") - fmt.Printf("Run status: Ready=%v, Submitted=%v, Finished=%v, Lost=%v\n", ready, submitted, finished, lost) - fmt.Printf("Throttle=%v\n", throttle) + log.Println("----------------------------------------------------------------------------") + log.Printf("Run status: Ready=%v, Submitted=%v, Finished=%v, Lost=%v\n", ready, submitted, finished, lost) + log.Printf("Throttle=%v\n", throttle) if finished > 0 { - displayTestRunResults(finishedRuns, lostRuns) + submitter.displayTestRunResults(finishedRuns, lostRuns) } } @@ -453,7 +452,7 @@ func (submitter *Submitter) createReports(params utils.RunsSubmitCmdValues, finishedRuns map[string]*TestRun, lostRuns map[string]*TestRun) error { //convert TestRun tests into formattable data - displayTestRunResults(finishedRuns, lostRuns) + submitter.displayTestRunResults(finishedRuns, lostRuns) var err error if params.ReportYamlFilename != "" { @@ -475,7 +474,7 @@ func (submitter *Submitter) createReports(params utils.RunsSubmitCmdValues, return err } -func displayTestRunResults(finishedRuns map[string]*TestRun, lostRuns map[string]*TestRun) { +func (submitter *Submitter) displayTestRunResults(finishedRuns map[string]*TestRun, lostRuns map[string]*TestRun) { var formatter = runsformatter.NewSummaryFormatter() var err error var outputText string @@ -483,7 +482,7 @@ func displayTestRunResults(finishedRuns map[string]*TestRun, lostRuns map[string formattableTest := FormattableTestFromTestRun(finishedRuns, lostRuns) outputText, err = formatter.FormatRuns(formattableTest) if err == nil { - print(outputText) + submitter.console.WriteString(outputText) } } diff --git a/pkg/runs/submitter_test.go b/pkg/runs/submitter_test.go index b10a92cb..a4785929 100644 --- a/pkg/runs/submitter_test.go +++ b/pkg/runs/submitter_test.go @@ -466,7 +466,7 @@ func TestLocalLaunchCanUseAPortfolioOk(t *testing.T) { assert.Equal(t, obrName, launchesRecorded[0].ObrFromPortfolio) assert.Equal(t, bundleName+"/"+className, launchesRecorded[0].ClassName) } - + assert.Contains(t, console.ReadText(), bundleName + "/" + className) } func TestSubmitRunwithGherkinFile(t *testing.T) { diff --git a/pkg/spi/byteReader.go b/pkg/spi/byteReader.go new file mode 100644 index 00000000..2c6a22db --- /dev/null +++ b/pkg/spi/byteReader.go @@ -0,0 +1,13 @@ +/* + * Copyright contributors to the Galasa project + * + * SPDX-License-Identifier: EPL-2.0 + */ +package spi + +import "io" + +// An interface to allow for mocking out "io" package reading-related methods +type ByteReader interface { + ReadAll(reader io.Reader) ([]byte, error) +} diff --git a/pkg/spi/factory.go b/pkg/spi/factory.go index 54084ea2..5ef9b5ae 100644 --- a/pkg/spi/factory.go +++ b/pkg/spi/factory.go @@ -24,4 +24,5 @@ type Factory interface { GetStdErrConsole() Console GetTimeService() TimeService GetAuthenticator(apiServerUrl string, galasaHome GalasaHome) Authenticator + GetByteReader() ByteReader } diff --git a/pkg/utils/byteReader.go b/pkg/utils/byteReader.go new file mode 100644 index 00000000..5a8d22be --- /dev/null +++ b/pkg/utils/byteReader.go @@ -0,0 +1,24 @@ +/* + * Copyright contributors to the Galasa project + * + * SPDX-License-Identifier: EPL-2.0 + */ +package utils + +import ( + "io" + + "github.com/galasa-dev/cli/pkg/spi" +) + +// Implementation of a byte reader to allow mocking out methods from the io package +type ByteReaderImpl struct { +} + +func NewByteReader() spi.ByteReader { + return new(ByteReaderImpl) +} + +func (*ByteReaderImpl) ReadAll(reader io.Reader) ([]byte, error) { + return io.ReadAll(reader) +} diff --git a/pkg/utils/byteReaderMock.go b/pkg/utils/byteReaderMock.go new file mode 100644 index 00000000..7c65c206 --- /dev/null +++ b/pkg/utils/byteReaderMock.go @@ -0,0 +1,39 @@ +/* + * Copyright contributors to the Galasa project + * + * SPDX-License-Identifier: EPL-2.0 + */ +package utils + +import ( + "errors" + "io" + + "github.com/galasa-dev/cli/pkg/spi" +) + +// Mock implementation of a byte reader to allow for simulating failed read operations +type MockByteReader struct { + throwReadError bool +} + +func NewMockByteReaderAsMock(throwReadError bool) *MockByteReader { + return &MockByteReader{ + throwReadError: throwReadError, + } +} + +func NewMockByteReader() spi.ByteReader { + return NewMockByteReaderAsMock(false) +} + +func (mockReader *MockByteReader) ReadAll(reader io.Reader) ([]byte, error) { + var err error + var bytes []byte + if mockReader.throwReadError { + err = errors.New("simulating a read failure") + } else { + bytes, err = io.ReadAll(reader) + } + return bytes, err +} diff --git a/pkg/utils/factoryMock.go b/pkg/utils/factoryMock.go index 56b043d2..bb1443e0 100644 --- a/pkg/utils/factoryMock.go +++ b/pkg/utils/factoryMock.go @@ -18,6 +18,7 @@ type MockFactory struct { StdErrConsole spi.Console TimeService spi.TimeService Authenticator spi.Authenticator + ByteReader spi.ByteReader } func NewMockFactory() *MockFactory { @@ -72,3 +73,10 @@ func (factory *MockFactory) GetAuthenticator(apiServerUrl string, galasaHome spi } return factory.Authenticator } + +func (factory *MockFactory) GetByteReader() spi.ByteReader { + if factory.ByteReader == nil { + factory.ByteReader = NewMockByteReader() + } + return factory.ByteReader +} diff --git a/pkg/utils/httpInteractionMock.go b/pkg/utils/httpInteractionMock.go new file mode 100644 index 00000000..6bb754fe --- /dev/null +++ b/pkg/utils/httpInteractionMock.go @@ -0,0 +1,74 @@ +/* + * Copyright contributors to the Galasa project + * + * SPDX-License-Identifier: EPL-2.0 + */ +package utils + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +// The implementation of a HTTP interaction that allows unit tests to define +// interactions with the Galasa API server, with methods to validate requests +// and a lambda to write HTTP responses (which can be overridden as desired) +type HttpInteraction struct { + ExpectedPath string + ExpectedHttpMethod string + + // An override-able function to write a HTTP response for this interaction + WriteHttpResponseFunc func(writer http.ResponseWriter, req *http.Request) +} + +func NewHttpInteraction(expectedPath string, expectedHttpMethod string) HttpInteraction { + httpInteraction := HttpInteraction{ + ExpectedPath: expectedPath, + ExpectedHttpMethod: expectedHttpMethod, + } + + // Set a basic implementation of the lambda to write a default response, which can be overridden by tests + httpInteraction.WriteHttpResponseFunc = func(writer http.ResponseWriter, req *http.Request) { + writer.WriteHeader(http.StatusOK) + } + + return httpInteraction +} + +func (interaction *HttpInteraction) ValidateRequest(t *testing.T, req *http.Request) { + assert.NotEmpty(t, req.Header.Get("ClientApiVersion")) + assert.Equal(t, interaction.ExpectedHttpMethod, req.Method, "Actual HTTP request method did not match the expected method") + assert.Equal(t, interaction.ExpectedPath, req.URL.Path, "Actual request path did not match the expected path") +} + +//----------------------------------------------------------------------------- +// Wrapper of a mock HTTP server that uses HTTP interactions to handle requests +//----------------------------------------------------------------------------- +type MockHttpServer struct { + CurrentInteractionIndex int + Server *httptest.Server +} + +func NewMockHttpServer(t *testing.T, interactions []HttpInteraction) MockHttpServer { + mockHttpServer := MockHttpServer{} + mockHttpServer.CurrentInteractionIndex = 0 + + mockHttpServer.Server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, req *http.Request) { + + currentInteractionIndex := &mockHttpServer.CurrentInteractionIndex + if *currentInteractionIndex >= len(interactions) { + assert.Fail(t, "Mock server received an unexpected request to '%s' when it should not have", req.URL.Path) + } else { + currentInteraction := interactions[*currentInteractionIndex] + currentInteraction.ValidateRequest(t, req) + currentInteraction.WriteHttpResponseFunc(writer, req) + + // The next request to the server should get the next interaction, so advance the index by one + *currentInteractionIndex++ + } + })) + return mockHttpServer +} diff --git a/pkg/utils/java_testUtils.go b/pkg/utils/java_test_fixtures.go similarity index 100% rename from pkg/utils/java_testUtils.go rename to pkg/utils/java_test_fixtures.go diff --git a/test-scripts/runs-tests.sh b/test-scripts/runs-tests.sh index 83539996..104b21ac 100755 --- a/test-scripts/runs-tests.sh +++ b/test-scripts/runs-tests.sh @@ -158,8 +158,6 @@ function runs_download_check_folder_names_during_test_run { # checks the folder names are correct with timestamps where appropriate h2 "Performing runs download while test is running..." - run_name=$1 - mkdir -p ${BASEDIR}/temp cd ${BASEDIR}/temp @@ -186,7 +184,7 @@ function runs_download_check_folder_names_during_test_run { cd ${BASEDIR}/temp - log_file="runs-submit-output.txt" + log_file="runs-submit-output-for-download.txt" cmd="${ORIGINAL_DIR}/bin/${binary} runs submit \ --bootstrap ${bootstrap} \ @@ -523,22 +521,23 @@ function get_result_with_runname { cd ${BASEDIR}/temp # Get the RunName from the output of galasactl runs submit + # The output of runs submit should look like: + # submitted-time(UTC) name requestor status result test-name + # 2024-09-05 12:45:33 C9955 galasa building Passed inttests/dev.galasa.inttests/dev.galasa.inttests.core.local.CoreLocalJava11Ubuntu + # + # Total:1 Passed:1 - # Gets the line from the last part of the output stream the RunName is found in - cat runs-submit-output.txt | grep -o "Run.*-" | tail -1 > line.txt - - # Get just the RunName from the line. - # There is a line in the output like this: - # Run C6967 - inttests/dev.galasa.inttests/dev.galasa.inttests.core.local.CoreLocalJava11Ubuntu - # Environment failure of the test results in "C6976(EnvFail)" ... so the '('...')' part needs removing also. - sed 's/Run //; s/ -//; s/[(].*[)]//;' line.txt > runname.txt - runname=$(cat runname.txt) + # Gets the run name from the second line of the runs submit output (after the headers). + # The run name should be the third field, after the date and time fields. + runname=$(cat runs-submit-output.txt | sed -n "2{p;q;}" | cut -f3 -d' ') if [[ "$runname" == "" ]]; then error "Run name not captured from previous run launch." exit 1 fi + info "Run name is: ${runname}" + cmd="${ORIGINAL_DIR}/bin/${binary} runs get \ --name ${runname} \ --bootstrap ${bootstrap} \ @@ -975,6 +974,79 @@ function launch_test_from_unknown_portfolio { success "Unknown portfolio could not be read. galasactl reported this error correctly." } +#-------------------------------------------------------------------------- +function runs_delete_check_run_can_be_deleted { + run_name=$1 + + h2 "Attempting to delete the run named '${run_name}' using runs delete..." + + mkdir -p ${BASEDIR}/temp + cd ${BASEDIR}/temp + + cmd="${ORIGINAL_DIR}/bin/${binary} runs delete \ + --name ${run_name} \ + --bootstrap ${bootstrap}" + + info "Command is: $cmd" + + # We expect a return code of '0' because the run should have been deleted successfully. + $cmd + rc=$? + if [[ "${rc}" != "0" ]]; then + error "Failed to delete run '${run_name}'" + exit 1 + fi + + h2 "Checking that the run '${run_name}' no longer exists" + + cmd="${ORIGINAL_DIR}/bin/${binary} runs get \ + --name ${run_name} \ + --bootstrap ${bootstrap}" + + output_file="runs-delete-output.txt" + set -o pipefail + $cmd | tee $output_file | grep -q "Total:0" + + # We expect a return code of '0' because there should be no runs with the given run name anymore. + rc=$? + if [[ "${rc}" != "0" ]]; then + error "Failed when checking if run '${run_name}' has been deleted. The run still exists when it should not." + exit 1 + fi + + success "galasactl runs delete was able to delete an existing run OK." +} + +#-------------------------------------------------------------------------- +function runs_delete_non_existant_run_returns_error { + run_name="NonExistantRun123" + + h2 "Attempting to delete the non-existant run named '${run_name}' using runs delete..." + + mkdir -p ${BASEDIR}/temp + cd ${BASEDIR}/temp + + cmd="${ORIGINAL_DIR}/bin/${binary} runs delete \ + --name ${run_name} \ + --bootstrap ${bootstrap}" + + info "Command is: $cmd" + + output_file="runs-delete-output.txt" + set -o pipefail + $cmd | tee $output_file + + # We expect a return code of '1' because the run does not exist and an error should be reported. + rc=$? + if [[ "${rc}" != "1" ]]; then + error "Failed to return an error when attempting to delete non-existant run '${run_name}'" + exit 1 + fi + + success "galasactl runs delete correctly reported an error when attempting to delete a non-existant run." +} + +#-------------------------------------------------------------------------- function test_runs_commands { # Launch test on ecosystem without a portfolio ... launch_test_on_ecosystem_without_portfolio @@ -1016,6 +1088,10 @@ function test_runs_commands { # Attempt to cancel an active run... # Temporarily commented out as failing and will block CLI builds. # runs_cancel_check_test_is_finished_and_cancelled + + # Attempt to delete a run... + runs_delete_check_run_can_be_deleted $RUN_NAME + runs_delete_non_existant_run_returns_error }