diff --git a/cmd/abapLandscapePortalUpdateAddOnProduct.go b/cmd/abapLandscapePortalUpdateAddOnProduct.go new file mode 100644 index 0000000000..a38f09adca --- /dev/null +++ b/cmd/abapLandscapePortalUpdateAddOnProduct.go @@ -0,0 +1,474 @@ +package cmd + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/SAP/jenkins-library/pkg/abaputils" + "github.com/SAP/jenkins-library/pkg/log" + "github.com/SAP/jenkins-library/pkg/telemetry" + "golang.org/x/exp/slices" +) + +const ( + StatusComplete = "C" + StatusError = "E" + StatusInProgress = "I" + StatusScheduled = "S" + StatusAborted = "X" + maxRuntimeInMinute = time.Duration(120) * time.Minute + pollIntervalInSecond = time.Duration(30) * time.Second +) + +type httpClient interface { + Do(*http.Request) (*http.Response, error) +} + +type uaa struct { + CertUrl string `json:"certurl"` + ClientId string `json:"clientid"` + Certificate string `json:"certificate"` + Key string `json:"key"` +} + +type serviceKey struct { + Url string `json:"url"` + Uaa uaa `json:"uaa"` +} + +type accessTokenResp struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + Scope string `json:"scope"` +} + +type systemEntity struct { + SystemId string `json:"SystemId"` + SystemNumber string `json:"SystemNumber"` + ZoneId string `json:"zone_id"` +} + +type reqEntity struct { + RequestId string `json:"RequestId"` + ZoneId string `json:"zone_id"` + Status string `json:"Status"` + SystemId string `json:"SystemId"` +} + +type updateAddOnReq struct { + ProductName string `json:"productName"` + ProductVersion string `json:"productVersion"` +} + +type updateAddOnResp struct { + RequestId string `json:"requestId"` + ZoneId string `json:"zoneId"` + Status string `json:"status"` + SystemId string `json:"systemId"` +} + +var client, clientToken httpClient +var servKey serviceKey + +func abapLandscapePortalUpdateAddOnProduct(config abapLandscapePortalUpdateAddOnProductOptions, telemetryData *telemetry.CustomData) { + client = &http.Client{} + + if prepareErr := parseServiceKeyAndPrepareAccessTokenHttpClient(config.LandscapePortalAPIServiceKey, &clientToken, &servKey); prepareErr != nil { + err := fmt.Errorf("Failed to prepare credentials to get access token of LP API. Error: %v\n", prepareErr) + log.Entry().WithError(err).Fatal("step execution failed") + } + // Error situations should be bubbled up until they reach the line below which will then stop execution + // through the log.Entry().Fatal() call leading to an os.Exit(1) in the end. + err := runAbapLandscapePortalUpdateAddOnProduct(&config, client, clientToken, servKey, maxRuntimeInMinute, pollIntervalInSecond) + if err != nil { + log.Entry().WithError(err).Fatal("step execution failed") + } +} + +func runAbapLandscapePortalUpdateAddOnProduct(config *abapLandscapePortalUpdateAddOnProductOptions, client httpClient, clientToken httpClient, servKey serviceKey, maxRuntimeInMinute time.Duration, pollIntervalInSecond time.Duration) error { + var systemId, reqId, reqStatus string + var getStatusReq http.Request + var err error + + // get system + if getSystemErr := getSystemBySystemNumber(config, client, clientToken, servKey, &systemId); getSystemErr != nil { + err = fmt.Errorf("Failed to get system with systemNumber %v. Error: %v\n", config.AbapSystemNumber, getSystemErr) + return err + } + + // update addon in the system + if updateAddOnErr := updateAddOn(config.AddonDescriptorFileName, client, clientToken, servKey, systemId, &reqId); updateAddOnErr != nil { + err = fmt.Errorf("Failed to update addon in the system with systemId %v. Error: %v\n", systemId, updateAddOnErr) + return err + } + + // prepare http request to poll status of addon update + if prepareGetStatusHttpRequestErr := prepareGetStatusHttpRequest(clientToken, servKey, reqId, &getStatusReq); prepareGetStatusHttpRequestErr != nil { + err = fmt.Errorf("Failed to prepare http request to poll status of addon update request %v. Error: %v\n", reqId, prepareGetStatusHttpRequestErr) + return err + } + + // keep polling request status until it reaches a final status or timeout + if waitToBeFinishedErr := waitToBeFinished(maxRuntimeInMinute, pollIntervalInSecond, client, &getStatusReq, reqId, &reqStatus); waitToBeFinishedErr != nil { + err = fmt.Errorf("Error occurred before a final status can be reached. Error: %v\n", waitToBeFinishedErr) + return err + } + + // respond to the final status of addon update + if respondToUpdateAddOnFinalStatusErr := respondToUpdateAddOnFinalStatus(client, clientToken, servKey, reqId, reqStatus); respondToUpdateAddOnFinalStatusErr != nil { + err = fmt.Errorf("The final status of addon update is %v. Error: %v\n", reqStatus, respondToUpdateAddOnFinalStatusErr) + return err + } + + return nil +} + +// this function is used to parse service key JSON and prepare http client for access token +func parseServiceKeyAndPrepareAccessTokenHttpClient(servKeyJSON string, clientToken *httpClient, servKey *serviceKey) error { + // parse the service key from JSON string to struct + if parseServiceKeyErr := json.Unmarshal([]byte(servKeyJSON), servKey); parseServiceKeyErr != nil { + return parseServiceKeyErr + } + + // configure http client with certificate authorization for getLPAPIAccessToken + certSource := servKey.Uaa.Certificate + keySource := servKey.Uaa.Key + + certPem := strings.Replace(certSource, `\n`, "\n", -1) + keyPem := strings.Replace(keySource, `\n`, "\n", -1) + + certificate, certErr := tls.X509KeyPair([]byte(certPem), []byte(keyPem)) + if certErr != nil { + return certErr + } + + *clientToken = &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + Certificates: []tls.Certificate{certificate}, + }, + }, + } + + return nil +} + +// this function is used to get access token of Landscape Portal API +func getLPAPIAccessToken(clientToken httpClient, servKey serviceKey) (string, error) { + authRawURL := servKey.Uaa.CertUrl + "/oauth/token" + + // configure request body + reqBody := url.Values{} + reqBody.Set("grant_type", "client_credentials") + reqBody.Set("client_id", servKey.Uaa.ClientId) + + encodedReqBody := reqBody.Encode() + + // generate http request and configure header + req, reqErr := http.NewRequest(http.MethodPost, authRawURL, strings.NewReader(encodedReqBody)) + if reqErr != nil { + return "", reqErr + } + + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + + resp, getAccessTokenErr := clientToken.Do(req) + if getAccessTokenErr != nil { + return "", getAccessTokenErr + } + + defer resp.Body.Close() + + // error case of response status code being non 200 + if resp.StatusCode != http.StatusOK { + err := fmt.Errorf("Unexpected response status %v received when getting access token of LP API.\n", resp.Status) + return "", err + } + + // read and parse response body + respBody := accessTokenResp{} + if parseRespBodyErr := parseRespBody[accessTokenResp](resp, &respBody); parseRespBodyErr != nil { + return "", parseRespBodyErr + } + + return respBody.AccessToken, nil +} + +// this function is used to check the existence of integration test system +func getSystemBySystemNumber(config *abapLandscapePortalUpdateAddOnProductOptions, client httpClient, clientToken httpClient, servKey serviceKey, systemId *string) error { + accessToken, getAccessTokenErr := getLPAPIAccessToken(clientToken, servKey) + if getAccessTokenErr != nil { + return getAccessTokenErr + } + + // define the raw url of the request and parse it into required form used in http.Request + getSystemRawURL := servKey.Url + "/api/systems/" + config.AbapSystemNumber + getSystemURL, urlParseErr := url.Parse(getSystemRawURL) + if urlParseErr != nil { + return urlParseErr + } + + req := http.Request{ + Method: http.MethodGet, + URL: getSystemURL, + Header: map[string][]string{ + "Authorization": {"Bearer " + accessToken}, + "Content-Type": {"application/json"}, + "Accept": {"application/json"}, + }, + } + + resp, getSystemErr := client.Do(&req) + if getSystemErr != nil { + return getSystemErr + } + + defer resp.Body.Close() + + // error case of response status code being non 200 + if resp.StatusCode != http.StatusOK { + err := fmt.Errorf("Unexpected response status %v received when getting system with systemNumber %v.\n", resp.Status, config.AbapSystemNumber) + return err + } + + // read and parse response body + respBody := systemEntity{} + if parseRespBodyErr := parseRespBody[systemEntity](resp, &respBody); parseRespBodyErr != nil { + return parseRespBodyErr + } + + *systemId = respBody.SystemId + + fmt.Printf("Successfully got ABAP system with systemNumber %v and systemId %v.\n", respBody.SystemNumber, respBody.SystemId) + return nil +} + +// this function is used to define and maintain the request body of querying status of addon update request +func prepareGetStatusHttpRequest(clientToken httpClient, servKey serviceKey, reqId string, getStatusReq *http.Request) error { + accessToken, getAccessTokenErr := getLPAPIAccessToken(clientToken, servKey) + if getAccessTokenErr != nil { + return getAccessTokenErr + } + + // define the raw url of the request and parse it into required form used in http.Request + getStatusRawURL := servKey.Url + "/api/requests/" + reqId + getStatusURL, urlParseErr := url.Parse(getStatusRawURL) + if urlParseErr != nil { + return urlParseErr + } + + req := http.Request{ + Method: http.MethodGet, + URL: getStatusURL, + Header: map[string][]string{ + "Authorization": {"Bearer " + accessToken}, + "Content-Type": {"application/json"}, + "Accept": {"application/json"}, + }, + } + + // store the req in the global variable for later usage + *getStatusReq = req + + return nil +} + +// this function is used to poll status of addon update request and maintain the status +func pollStatusOfUpdateAddOn(client httpClient, req *http.Request, reqId string, status *string) error { + resp, getStatusErr := client.Do(req) + if getStatusErr != nil { + return getStatusErr + } + + defer resp.Body.Close() + + // error case of response status code being non 200 + if resp.StatusCode != http.StatusOK { + err := fmt.Errorf("Unexpected response status %v received when polling status of request %v.\n", resp.Status, reqId) + return err + } + + // read and parse response body + respBody := reqEntity{} + if parseRespBodyErr := parseRespBody[reqEntity](resp, &respBody); parseRespBodyErr != nil { + return parseRespBodyErr + } + + *status = respBody.Status + + fmt.Printf("Successfully polled status %v of request %v.\n", respBody.Status, respBody.RequestId) + return nil +} + +// this function is used to update addon +func updateAddOn(addOnFileName string, client httpClient, clientToken httpClient, servKey serviceKey, systemId string, reqId *string) error { + accessToken, getAccessTokenErr := getLPAPIAccessToken(clientToken, servKey) + if getAccessTokenErr != nil { + return getAccessTokenErr + } + + // read productName and productVersion from addon.yml + addOnDescriptor, readAddOnErr := abaputils.ReadAddonDescriptor(addOnFileName) + if readAddOnErr != nil { + return readAddOnErr + } + + // define the raw url of the request and parse it into required form used in http.Request + updateAddOnRawURL := servKey.Url + "/api/systems/" + systemId + "/deployProduct" + + // define the request body as a struct + reqBody := updateAddOnReq{ + ProductName: addOnDescriptor.AddonProduct, + ProductVersion: addOnDescriptor.AddonVersionYAML, + } + + // encode the request body to JSON + var reqBuff bytes.Buffer + json.NewEncoder(&reqBuff).Encode(reqBody) + + req, reqErr := http.NewRequest(http.MethodPost, updateAddOnRawURL, &reqBuff) + if reqErr != nil { + return reqErr + } + + req.Header = map[string][]string{ + "Authorization": {"Bearer " + accessToken}, + "Content-Type": {"application/json"}, + "Accept": {"application/json"}, + } + + resp, updateAddOnErr := client.Do(req) + if updateAddOnErr != nil { + return updateAddOnErr + } + + defer resp.Body.Close() + + // error case of response status code being non 200 + if resp.StatusCode != http.StatusOK { + err := fmt.Errorf("Unexpected response status %v received when updating addon in system with systemId %v.\n", resp.Status, systemId) + return err + } + + // read and parse response body + respBody := updateAddOnResp{} + if parseRespBodyErr := parseRespBody[updateAddOnResp](resp, &respBody); parseRespBodyErr != nil { + return parseRespBodyErr + } + + *reqId = respBody.RequestId + + fmt.Printf("Successfully triggered addon update in system with systemId %v, the returned request id is %v.\n", systemId, respBody.RequestId) + return nil +} + +// this function is used to cancel addon update +func cancelUpdateAddOn(client httpClient, clientToken httpClient, servKey serviceKey, reqId string) error { + accessToken, getAccessTokenErr := getLPAPIAccessToken(clientToken, servKey) + if getAccessTokenErr != nil { + return getAccessTokenErr + } + + // define the raw url of the request and parse it into required form used in http.Request + cancelUpdateAddOnRawURL := servKey.Url + "/api/requests/" + reqId + cancelUpdateAddOnURL, urlParseErr := url.Parse(cancelUpdateAddOnRawURL) + if urlParseErr != nil { + return urlParseErr + } + + req := http.Request{ + Method: http.MethodDelete, + URL: cancelUpdateAddOnURL, + Header: map[string][]string{ + "Authorization": {"Bearer " + accessToken}, + "Content-Type": {"application/json"}, + "Accept": {"application/json"}, + }, + } + + resp, cancelUpdateAddOnErr := client.Do(&req) + if cancelUpdateAddOnErr != nil { + return cancelUpdateAddOnErr + } + + defer resp.Body.Close() + + // error case of response status code being non 204 + if resp.StatusCode != http.StatusNoContent { + err := fmt.Errorf("Unexpected response status %v received when canceling addon update request %v.\n", resp.Status, reqId) + return err + } + + fmt.Printf("Successfully canceled addon update request %v.\n", reqId) + return nil +} + +// this function is used to respond to a final status of addon update +func respondToUpdateAddOnFinalStatus(client httpClient, clientToken httpClient, servKey serviceKey, reqId string, status string) error { + switch status { + case StatusComplete: + fmt.Println("Addon update succeeded.") + case StatusError: + fmt.Println("Addon update failed and will be canceled.") + + if cancelUpdateAddOnErr := cancelUpdateAddOn(client, clientToken, servKey, reqId); cancelUpdateAddOnErr != nil { + err := fmt.Errorf("Failed to cancel addon update. Error: %v\n", cancelUpdateAddOnErr) + return err + } + + err := fmt.Errorf("Addon update failed.\n") + return err + + case StatusAborted: + fmt.Println("Addon update was aborted.") + err := fmt.Errorf("Addon update was aborted.\n") + return err + } + + return nil +} + +// this function is used to parse response body of http request +func parseRespBody[T comparable](resp *http.Response, respBody *T) error { + respBodyRaw, readRespErr := io.ReadAll(resp.Body) + if readRespErr != nil { + return readRespErr + } + + if decodeRespBodyErr := json.Unmarshal(respBodyRaw, &respBody); decodeRespBodyErr != nil { + return decodeRespBodyErr + } + + return nil +} + +// this function is used to wait for a final status/timeout +func waitToBeFinished(maxRuntimeInMinute time.Duration, pollIntervalInSecond time.Duration, client httpClient, getStatusReq *http.Request, reqId string, reqStatus *string) error { + timeout := time.After(maxRuntimeInMinute) + ticker := time.Tick(pollIntervalInSecond) + reqFinalStatus := []string{StatusComplete, StatusError, StatusAborted} + for { + select { + case <-timeout: + return fmt.Errorf("Timed out: max runtime %v reached.", maxRuntimeInMinute) + case <-ticker: + if pollStatusOfUpdateAddOnErr := pollStatusOfUpdateAddOn(client, getStatusReq, reqId, reqStatus); pollStatusOfUpdateAddOnErr != nil { + err := fmt.Errorf("Error happened when waiting for the addon update request %v to reach a final status. Error: %v\n", reqId, pollStatusOfUpdateAddOnErr) + return err + } + if !slices.Contains(reqFinalStatus, *reqStatus) { + fmt.Printf("Addon update request %v is still in progress, will poll the status in %v.\n", reqId, pollIntervalInSecond) + } else { + return nil + } + } + } +} diff --git a/cmd/abapLandscapePortalUpdateAddOnProduct_generated.go b/cmd/abapLandscapePortalUpdateAddOnProduct_generated.go new file mode 100644 index 0000000000..3ebebd7824 --- /dev/null +++ b/cmd/abapLandscapePortalUpdateAddOnProduct_generated.go @@ -0,0 +1,185 @@ +// Code generated by piper's step-generator. DO NOT EDIT. + +package cmd + +import ( + "fmt" + "os" + "time" + + "github.com/SAP/jenkins-library/pkg/config" + "github.com/SAP/jenkins-library/pkg/log" + "github.com/SAP/jenkins-library/pkg/splunk" + "github.com/SAP/jenkins-library/pkg/telemetry" + "github.com/SAP/jenkins-library/pkg/validation" + "github.com/spf13/cobra" +) + +type abapLandscapePortalUpdateAddOnProductOptions struct { + LandscapePortalAPIServiceKey string `json:"landscapePortalAPIServiceKey,omitempty"` + AbapSystemNumber string `json:"abapSystemNumber,omitempty"` + AddonDescriptorFileName string `json:"addonDescriptorFileName,omitempty"` +} + +// AbapLandscapePortalUpdateAddOnProductCommand Update the AddOn product in SAP BTP ABAP Environment system of Landscape Portal +func AbapLandscapePortalUpdateAddOnProductCommand() *cobra.Command { + const STEP_NAME = "abapLandscapePortalUpdateAddOnProduct" + + metadata := abapLandscapePortalUpdateAddOnProductMetadata() + var stepConfig abapLandscapePortalUpdateAddOnProductOptions + var startTime time.Time + var logCollector *log.CollectorHook + var splunkClient *splunk.Splunk + telemetryClient := &telemetry.Telemetry{} + + var createAbapLandscapePortalUpdateAddOnProductCmd = &cobra.Command{ + Use: STEP_NAME, + Short: "Update the AddOn product in SAP BTP ABAP Environment system of Landscape Portal", + Long: `This step describes the AddOn product update in SAP BTP ABAP Environment system of Landscape Portal`, + PreRunE: func(cmd *cobra.Command, _ []string) error { + startTime = time.Now() + log.SetStepName(STEP_NAME) + log.SetVerbose(GeneralConfig.Verbose) + + GeneralConfig.GitHubAccessTokens = ResolveAccessTokens(GeneralConfig.GitHubTokens) + + path, _ := os.Getwd() + fatalHook := &log.FatalHook{CorrelationID: GeneralConfig.CorrelationID, Path: path} + log.RegisterHook(fatalHook) + + err := PrepareConfig(cmd, &metadata, STEP_NAME, &stepConfig, config.OpenPiperFile) + if err != nil { + log.SetErrorCategory(log.ErrorConfiguration) + return err + } + log.RegisterSecret(stepConfig.LandscapePortalAPIServiceKey) + + if len(GeneralConfig.HookConfig.SentryConfig.Dsn) > 0 { + sentryHook := log.NewSentryHook(GeneralConfig.HookConfig.SentryConfig.Dsn, GeneralConfig.CorrelationID) + log.RegisterHook(&sentryHook) + } + + if len(GeneralConfig.HookConfig.SplunkConfig.Dsn) > 0 || len(GeneralConfig.HookConfig.SplunkConfig.ProdCriblEndpoint) > 0 { + splunkClient = &splunk.Splunk{} + logCollector = &log.CollectorHook{CorrelationID: GeneralConfig.CorrelationID} + log.RegisterHook(logCollector) + } + + if err = log.RegisterANSHookIfConfigured(GeneralConfig.CorrelationID); err != nil { + log.Entry().WithError(err).Warn("failed to set up SAP Alert Notification Service log hook") + } + + validation, err := validation.New(validation.WithJSONNamesForStructFields(), validation.WithPredefinedErrorMessages()) + if err != nil { + return err + } + if err = validation.ValidateStruct(stepConfig); err != nil { + log.SetErrorCategory(log.ErrorConfiguration) + return err + } + + return nil + }, + Run: func(_ *cobra.Command, _ []string) { + stepTelemetryData := telemetry.CustomData{} + stepTelemetryData.ErrorCode = "1" + handler := func() { + config.RemoveVaultSecretFiles() + stepTelemetryData.Duration = fmt.Sprintf("%v", time.Since(startTime).Milliseconds()) + stepTelemetryData.ErrorCategory = log.GetErrorCategory().String() + stepTelemetryData.PiperCommitHash = GitCommit + telemetryClient.SetData(&stepTelemetryData) + telemetryClient.Send() + if len(GeneralConfig.HookConfig.SplunkConfig.Dsn) > 0 { + splunkClient.Initialize(GeneralConfig.CorrelationID, + GeneralConfig.HookConfig.SplunkConfig.Dsn, + GeneralConfig.HookConfig.SplunkConfig.Token, + GeneralConfig.HookConfig.SplunkConfig.Index, + GeneralConfig.HookConfig.SplunkConfig.SendLogs) + splunkClient.Send(telemetryClient.GetData(), logCollector) + } + if len(GeneralConfig.HookConfig.SplunkConfig.ProdCriblEndpoint) > 0 { + splunkClient.Initialize(GeneralConfig.CorrelationID, + GeneralConfig.HookConfig.SplunkConfig.ProdCriblEndpoint, + GeneralConfig.HookConfig.SplunkConfig.ProdCriblToken, + GeneralConfig.HookConfig.SplunkConfig.ProdCriblIndex, + GeneralConfig.HookConfig.SplunkConfig.SendLogs) + splunkClient.Send(telemetryClient.GetData(), logCollector) + } + } + log.DeferExitHandler(handler) + defer handler() + telemetryClient.Initialize(GeneralConfig.NoTelemetry, STEP_NAME, GeneralConfig.HookConfig.PendoConfig.Token) + abapLandscapePortalUpdateAddOnProduct(stepConfig, &stepTelemetryData) + stepTelemetryData.ErrorCode = "0" + log.Entry().Info("SUCCESS") + }, + } + + addAbapLandscapePortalUpdateAddOnProductFlags(createAbapLandscapePortalUpdateAddOnProductCmd, &stepConfig) + return createAbapLandscapePortalUpdateAddOnProductCmd +} + +func addAbapLandscapePortalUpdateAddOnProductFlags(cmd *cobra.Command, stepConfig *abapLandscapePortalUpdateAddOnProductOptions) { + cmd.Flags().StringVar(&stepConfig.LandscapePortalAPIServiceKey, "landscapePortalAPIServiceKey", os.Getenv("PIPER_landscapePortalAPIServiceKey"), "Service key JSON string to access the Landscape Portal Access API") + cmd.Flags().StringVar(&stepConfig.AbapSystemNumber, "abapSystemNumber", os.Getenv("PIPER_abapSystemNumber"), "System Number of the abap integration test system") + cmd.Flags().StringVar(&stepConfig.AddonDescriptorFileName, "addonDescriptorFileName", `addon.yml`, "File name of the YAML file which describes the Product Version and corresponding Software Component Versions") + + cmd.MarkFlagRequired("landscapePortalAPIServiceKey") + cmd.MarkFlagRequired("abapSystemNumber") + cmd.MarkFlagRequired("addonDescriptorFileName") +} + +// retrieve step metadata +func abapLandscapePortalUpdateAddOnProductMetadata() config.StepData { + var theMetaData = config.StepData{ + Metadata: config.StepMetadata{ + Name: "abapLandscapePortalUpdateAddOnProduct", + Aliases: []config.Alias{}, + Description: "Update the AddOn product in SAP BTP ABAP Environment system of Landscape Portal", + }, + Spec: config.StepSpec{ + Inputs: config.StepInputs{ + Secrets: []config.StepSecrets{ + {Name: "landscapePortalAPICredentialsId", Description: "Jenkins secret text credential ID containing the service key to access the Landscape Portal Access API", Type: "jenkins"}, + }, + Parameters: []config.StepParameters{ + { + Name: "landscapePortalAPIServiceKey", + ResourceRef: []config.ResourceReference{ + { + Name: "landscapePortalAPICredentialsId", + Param: "landscapePortalAPIServiceKey", + Type: "secret", + }, + }, + Scope: []string{"PARAMETERS"}, + Type: "string", + Mandatory: true, + Aliases: []config.Alias{}, + Default: os.Getenv("PIPER_landscapePortalAPIServiceKey"), + }, + { + Name: "abapSystemNumber", + ResourceRef: []config.ResourceReference{}, + Scope: []string{"PARAMETERS", "STAGES", "STEPS"}, + Type: "string", + Mandatory: true, + Aliases: []config.Alias{}, + Default: os.Getenv("PIPER_abapSystemNumber"), + }, + { + Name: "addonDescriptorFileName", + ResourceRef: []config.ResourceReference{}, + Scope: []string{"PARAMETERS", "STAGES", "STEPS", "GENERAL"}, + Type: "string", + Mandatory: true, + Aliases: []config.Alias{}, + Default: `addon.yml`, + }, + }, + }, + }, + } + return theMetaData +} diff --git a/cmd/abapLandscapePortalUpdateAddOnProduct_generated_test.go b/cmd/abapLandscapePortalUpdateAddOnProduct_generated_test.go new file mode 100644 index 0000000000..9c9227e642 --- /dev/null +++ b/cmd/abapLandscapePortalUpdateAddOnProduct_generated_test.go @@ -0,0 +1,20 @@ +//go:build unit +// +build unit + +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAbapLandscapePortalUpdateAddOnProductCommand(t *testing.T) { + t.Parallel() + + testCmd := AbapLandscapePortalUpdateAddOnProductCommand() + + // only high level testing performed - details are tested in step generation procedure + assert.Equal(t, "abapLandscapePortalUpdateAddOnProduct", testCmd.Use, "command name incorrect") + +} diff --git a/cmd/abapLandscapePortalUpdateAddOnProduct_test.go b/cmd/abapLandscapePortalUpdateAddOnProduct_test.go new file mode 100644 index 0000000000..7c1725db3f --- /dev/null +++ b/cmd/abapLandscapePortalUpdateAddOnProduct_test.go @@ -0,0 +1,655 @@ +package cmd + +import ( + "bytes" + "fmt" + "io" + "net/http" + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +const ( + resBodyJSON_token = `{"access_token": "some-access-token", "token_type": "bearer", "expires_in": 86400, "scope": "some-scope"}` + resBodyJSON_sys = `{"SystemId": "some-system-id", "SystemNumber": "some-system-number", "zone_id": "some-zone-id"}` + resBodyJSON_req_S = `{"RequestId": "some-request-id","zone_id": "some-zone-id", "Status": "S", "SystemId": "some-system-id"}` + resBodyJSON_req_I = `{"RequestId": "some-request-id","zone_id": "some-zone-id", "Status": "I", "SystemId": "some-system-id"}` + resBodyJSON_req_C = `{"RequestId": "some-request-id","zone_id": "some-zone-id", "Status": "C", "SystemId": "some-system-id"}` + resBodyJSON_req_E = `{"RequestId": "some-request-id","zone_id": "some-zone-id", "Status": "E", "SystemId": "some-system-id"}` + resBodyJSON_req_X = `{"RequestId": "some-request-id","zone_id": "some-zone-id", "Status": "X", "SystemId": "some-system-id"}` +) + +type mockClient struct { + DoFunc func(*http.Request) (*http.Response, error) +} + +var GetDoFunc func(req *http.Request) (*http.Response, error) + +var testUaa = uaa{ + CertUrl: "https://some-cert-url.com", + ClientId: "some-client-id", + Certificate: "-----BEGIN CERTIFICATE-----\nMIIBhTCCASugAwIBAgIQIRi6zePL6mKjOipn+dNuaTAKBggqhkjOPQQDAjASMRAw\nDgYDVQQKEwdBY21lIENvMB4XDTE3MTAyMDE5NDMwNloXDTE4MTAyMDE5NDMwNlow\nEjEQMA4GA1UEChMHQWNtZSBDbzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABD0d\n7VNhbWvZLWPuj/RtHFjvtJBEwOkhbN/BnnE8rnZR8+sbwnc/KhCk3FhnpHZnQz7B\n5aETbbIgmuvewdjvSBSjYzBhMA4GA1UdDwEB/wQEAwICpDATBgNVHSUEDDAKBggr\nBgEFBQcDATAPBgNVHRMBAf8EBTADAQH/MCkGA1UdEQQiMCCCDmxvY2FsaG9zdDo1\nNDUzgg4xMjcuMC4wLjE6NTQ1MzAKBggqhkjOPQQDAgNIADBFAiEA2zpJEPQyz6/l\nWf86aX6PepsntZv2GYlA5UpabfT2EZICICpJ5h/iI+i341gBmLiAFQOyTDT+/wQc\n6MF9+Yw1Yy0t\n-----END CERTIFICATE-----", + Key: "-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIIrYSSNQFaA2Hwf1duRSxKtLYX5CB04fSeQ6tF1aY/PuoAoGCCqGSM49\nAwEHoUQDQgAEPR3tU2Fta9ktY+6P9G0cWO+0kETA6SFs38GecTyudlHz6xvCdz8q\nEKTcWGekdmdDPsHloRNtsiCa697B2O9IFA==\n-----END EC PRIVATE KEY-----", +} +var mockServKey = serviceKey{ + Url: "https://some-url.com", + Uaa: testUaa, +} + +var mockServiceKeyJSON = `{ + "url": "https://some-url.com", + "uaa": { + "clientid": "some-client-id", + "url": "https://some-uaa-url.com", + "certificate": "-----BEGIN CERTIFICATE-----\nMIIBhTCCASugAwIBAgIQIRi6zePL6mKjOipn+dNuaTAKBggqhkjOPQQDAjASMRAw\nDgYDVQQKEwdBY21lIENvMB4XDTE3MTAyMDE5NDMwNloXDTE4MTAyMDE5NDMwNlow\nEjEQMA4GA1UEChMHQWNtZSBDbzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABD0d\n7VNhbWvZLWPuj/RtHFjvtJBEwOkhbN/BnnE8rnZR8+sbwnc/KhCk3FhnpHZnQz7B\n5aETbbIgmuvewdjvSBSjYzBhMA4GA1UdDwEB/wQEAwICpDATBgNVHSUEDDAKBggr\nBgEFBQcDATAPBgNVHRMBAf8EBTADAQH/MCkGA1UdEQQiMCCCDmxvY2FsaG9zdDo1\nNDUzgg4xMjcuMC4wLjE6NTQ1MzAKBggqhkjOPQQDAgNIADBFAiEA2zpJEPQyz6/l\nWf86aX6PepsntZv2GYlA5UpabfT2EZICICpJ5h/iI+i341gBmLiAFQOyTDT+/wQc\n6MF9+Yw1Yy0t\n-----END CERTIFICATE-----", + "certurl": "https://some-cert-url.com", + "key": "-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIIrYSSNQFaA2Hwf1duRSxKtLYX5CB04fSeQ6tF1aY/PuoAoGCCqGSM49\nAwEHoUQDQgAEPR3tU2Fta9ktY+6P9G0cWO+0kETA6SFs38GecTyudlHz6xvCdz8q\nEKTcWGekdmdDPsHloRNtsiCa697B2O9IFA==\n-----END EC PRIVATE KEY-----" + }, + "vendor": "SAP" + }` + +var mockUpdateAddOnConfig = abapLandscapePortalUpdateAddOnProductOptions{ + LandscapePortalAPIServiceKey: mockServiceKeyJSON, + AbapSystemNumber: "some-system-number", + AddonDescriptorFileName: "addon.yml", +} + +func (m *mockClient) Do(req *http.Request) (*http.Response, error) { + return GetDoFunc(req) +} + +func init() { + client = &mockClient{} + clientToken = &mockClient{} +} + +func TestParseServiceKeyAndPrepareAccessTokenHttpClient(t *testing.T) { + t.Run("Successfully parsed service key", func(t *testing.T) { + var testServKey serviceKey + clientParseServKey := clientToken + + err := parseServiceKeyAndPrepareAccessTokenHttpClient(mockUpdateAddOnConfig.LandscapePortalAPIServiceKey, &clientParseServKey, &testServKey) + + assert.Equal(t, nil, err) + assert.Equal(t, "https://some-url.com", testServKey.Url) + assert.Equal(t, "some-client-id", testServKey.Uaa.ClientId) + assert.Equal(t, "https://some-cert-url.com", testServKey.Uaa.CertUrl) + }) +} + +func TestGetLPAPIAccessToken(t *testing.T) { + t.Run("Successfully got LP API access token", func(t *testing.T) { + GetDoFunc = func(req *http.Request) (*http.Response, error) { + resBodyReader := io.NopCloser(bytes.NewReader([]byte(resBodyJSON_token))) + return &http.Response{ + StatusCode: 200, + Body: resBodyReader, + }, nil + } + + res, err := getLPAPIAccessToken(clientToken, mockServKey) + + assert.Equal(t, "some-access-token", res) + assert.Equal(t, nil, err) + }) + + t.Run("Failed to get LP API access token", func(t *testing.T) { + GetDoFunc = func(req *http.Request) (*http.Response, error) { + return nil, fmt.Errorf("Failed to get access token.") + } + res, err := getLPAPIAccessToken(clientToken, mockServKey) + + assert.Equal(t, "", res) + assert.Equal(t, fmt.Errorf("Failed to get access token."), err) + }) +} + +func TestGetSystemBySystemNumber(t *testing.T) { + reqUrl_token := mockServKey.Uaa.CertUrl + "/oauth/token" + reqUrl_sys := mockServKey.Url + "/api/systems/" + mockUpdateAddOnConfig.AbapSystemNumber + + t.Run("Successfully got ABAP system", func(t *testing.T) { + var testSysId string + + GetDoFunc = func(req *http.Request) (*http.Response, error) { + if req.URL.String() == reqUrl_token { + resBodyReader_token := io.NopCloser(bytes.NewReader([]byte(resBodyJSON_token))) + return &http.Response{ + StatusCode: 200, + Body: resBodyReader_token, + }, nil + } + + if req.URL.String() == reqUrl_sys { + resBodyReader_sys := io.NopCloser(bytes.NewReader([]byte(resBodyJSON_sys))) + return &http.Response{ + StatusCode: 200, + Body: resBodyReader_sys, + }, nil + } + + return nil, fmt.Errorf("some-unknown-error") + } + + err := getSystemBySystemNumber(&mockUpdateAddOnConfig, client, clientToken, mockServKey, &testSysId) + + assert.Equal(t, "some-system-id", testSysId) + assert.Equal(t, nil, err) + }) + + t.Run("Failed to get ABAP system", func(t *testing.T) { + var testSysId string + + GetDoFunc = func(req *http.Request) (*http.Response, error) { + if req.URL.String() == reqUrl_token { + resBodyReader_token := io.NopCloser(bytes.NewReader([]byte(resBodyJSON_token))) + return &http.Response{ + StatusCode: 200, + Body: resBodyReader_token, + }, nil + } + + if req.URL.String() == reqUrl_sys { + return nil, fmt.Errorf("Failed to get ABAP system.") + } + + return nil, fmt.Errorf("some-unknown-error") + } + + err := getSystemBySystemNumber(&mockUpdateAddOnConfig, client, clientToken, mockServKey, &testSysId) + + assert.Equal(t, "", testSysId) + assert.Equal(t, fmt.Errorf("Failed to get ABAP system."), err) + }) +} + +func TestUpdateAddOn(t *testing.T) { + testSysId := "some-system-id" + reqUrl_token := mockServKey.Uaa.CertUrl + "/oauth/token" + reqUrl_update := mockServKey.Url + "/api/systems/" + testSysId + "/deployProduct" + + t.Run("Successfully updated addon", func(t *testing.T) { + // write addon.yml + dir := t.TempDir() + oldCWD, _ := os.Getwd() + _ = os.Chdir(dir) + // clean up tmp dir + defer func() { + _ = os.Chdir(oldCWD) + }() + + addonYML := `addonProduct: some-addon-product +addonVersion: 1.0.0 +` + addonYMLBytes := []byte(addonYML) + os.WriteFile("addon.yml", addonYMLBytes, 0644) + + // mock Do func + var testReqId string + + GetDoFunc = func(req *http.Request) (*http.Response, error) { + if req.URL.String() == reqUrl_token { + resBodyReader_token := io.NopCloser(bytes.NewReader([]byte(resBodyJSON_token))) + return &http.Response{ + StatusCode: 200, + Body: resBodyReader_token, + }, nil + } + + if req.URL.String() == reqUrl_update { + resBodyReader_req_S := io.NopCloser(bytes.NewReader([]byte(resBodyJSON_req_S))) + return &http.Response{ + StatusCode: 200, + Body: resBodyReader_req_S, + }, nil + } + + return nil, fmt.Errorf("some-unknown-error") + } + + err := updateAddOn(mockUpdateAddOnConfig.AddonDescriptorFileName, client, clientToken, mockServKey, testSysId, &testReqId) + + assert.Equal(t, "some-request-id", testReqId) + assert.Equal(t, nil, err) + }) + + t.Run("Failed to update addon", func(t *testing.T) { + // write addon.yml + dir := t.TempDir() + oldCWD, _ := os.Getwd() + _ = os.Chdir(dir) + // clean up tmp dir + defer func() { + _ = os.Chdir(oldCWD) + }() + + addonYML := `addonProduct: some-addon-product +addonVersion: 1.0.0 +` + addonYMLBytes := []byte(addonYML) + os.WriteFile("addon.yml", addonYMLBytes, 0644) + + // mock Do func + var testReqId string + + GetDoFunc = func(req *http.Request) (*http.Response, error) { + if req.URL.String() == reqUrl_token { + resBodyReader_token := io.NopCloser(bytes.NewReader([]byte(resBodyJSON_token))) + return &http.Response{ + StatusCode: 200, + Body: resBodyReader_token, + }, nil + } + + if req.URL.String() == reqUrl_update { + return nil, fmt.Errorf("Failed to update addon.") + } + + return nil, fmt.Errorf("some-unknown-error") + } + + err := updateAddOn(mockUpdateAddOnConfig.AddonDescriptorFileName, client, clientToken, mockServKey, testSysId, &testReqId) + + assert.Equal(t, "", testReqId) + assert.Equal(t, fmt.Errorf("Failed to update addon."), err) + }) +} + +func TestPollStatusOfUpdateAddOn(t *testing.T) { + var testReq http.Request + + testReqId := "some-request-id" + reqUrl_token := mockServKey.Uaa.CertUrl + "/oauth/token" + reqUrl_pollAndCancel := mockServKey.Url + "/api/requests/" + testReqId + + t.Run("Successfully polled request status", func(t *testing.T) { + var testStatus string + + GetDoFunc = func(req *http.Request) (*http.Response, error) { + if req.URL.String() == reqUrl_token { + resBodyReader_token := io.NopCloser(bytes.NewReader([]byte(resBodyJSON_token))) + return &http.Response{ + StatusCode: 200, + Body: resBodyReader_token, + }, nil + } + + if req.URL.String() == reqUrl_pollAndCancel { + resBodyReader_pollStatus := io.NopCloser(bytes.NewReader([]byte(resBodyJSON_req_I))) + return &http.Response{ + StatusCode: 200, + Body: resBodyReader_pollStatus, + }, nil + } + + return nil, fmt.Errorf("some-unknown-error") + } + + err1 := prepareGetStatusHttpRequest(clientToken, mockServKey, testReqId, &testReq) + err2 := pollStatusOfUpdateAddOn(client, &testReq, testReqId, &testStatus) + + assert.Equal(t, "I", testStatus) + assert.Equal(t, nil, err1) + assert.Equal(t, nil, err2) + }) + + t.Run("Failed to poll request status", func(t *testing.T) { + var testStatus string + + GetDoFunc = func(req *http.Request) (*http.Response, error) { + if req.URL.String() == reqUrl_token { + resBodyReader_token := io.NopCloser(bytes.NewReader([]byte(resBodyJSON_token))) + return &http.Response{ + StatusCode: 200, + Body: resBodyReader_token, + }, nil + } + + if req.URL.String() == reqUrl_pollAndCancel { + return nil, fmt.Errorf("Failed to poll status.") + } + + return nil, fmt.Errorf("some-unknown-error") + } + + err1 := prepareGetStatusHttpRequest(clientToken, mockServKey, testReqId, &testReq) + err2 := pollStatusOfUpdateAddOn(client, &testReq, testReqId, &testStatus) + + assert.Equal(t, "", testStatus) + assert.Equal(t, nil, err1) + assert.Equal(t, fmt.Errorf("Failed to poll status."), err2) + }) +} + +func TestCancelUpdateAddOn(t *testing.T) { + testReqId := "some-request-id" + reqUrl_token := mockServKey.Uaa.CertUrl + "/oauth/token" + reqUrl_pollAndCancel := mockServKey.Url + "/api/requests/" + testReqId + + t.Run("Successfully canceled addon update", func(t *testing.T) { + GetDoFunc = func(req *http.Request) (*http.Response, error) { + resBodyReader_token := io.NopCloser(bytes.NewReader([]byte(resBodyJSON_token))) + if req.URL.String() == reqUrl_token { + return &http.Response{ + StatusCode: 200, + Body: resBodyReader_token, + }, nil + } + + if req.URL.String() == reqUrl_pollAndCancel { + resBodyReader_cancelUpdate := io.NopCloser(nil) + return &http.Response{ + StatusCode: 204, + Body: resBodyReader_cancelUpdate, + }, nil + } + + return nil, fmt.Errorf("some-unknown-error") + } + + err := cancelUpdateAddOn(client, clientToken, mockServKey, testReqId) + + assert.Equal(t, nil, err) + }) + + t.Run("Failed to cancel addon update", func(t *testing.T) { + GetDoFunc = func(req *http.Request) (*http.Response, error) { + if req.URL.String() == reqUrl_token { + resBodyReader_token := io.NopCloser(bytes.NewReader([]byte(resBodyJSON_token))) + return &http.Response{ + StatusCode: 200, + Body: resBodyReader_token, + }, nil + } + + if req.URL.String() == reqUrl_pollAndCancel { + return nil, fmt.Errorf("Failed to cancel addon update.") + } + + return nil, fmt.Errorf("some-unknown-error") + } + + err := cancelUpdateAddOn(client, clientToken, mockServKey, testReqId) + + assert.Equal(t, fmt.Errorf("Failed to cancel addon update."), err) + }) +} + +func TestRunAbapLandscapePortalUpdateAddOnProduct(t *testing.T) { + reqUrl_token := mockServKey.Uaa.CertUrl + "/oauth/token" + reqUrl_sys := mockServKey.Url + "/api/systems/" + mockUpdateAddOnConfig.AbapSystemNumber + reqUrl_update := mockServKey.Url + "/api/systems/" + "some-system-id" + "/deployProduct" + reqUrl_pollAndCancel := mockServKey.Url + "/api/requests/" + "some-request-id" + + t.Run("Successfully ran update addon in ABAP system", func(t *testing.T) { + // write addon.yml + dir := t.TempDir() + oldCWD, _ := os.Getwd() + _ = os.Chdir(dir) + // clean up tmp dir + defer func() { + _ = os.Chdir(oldCWD) + }() + + addonYML := `addonProduct: some-addon-product +addonVersion: 1.0.0 +` + addonYMLBytes := []byte(addonYML) + os.WriteFile("addon.yml", addonYMLBytes, 0644) + + // mock Do func + maxRuntimeInMinute := time.Duration(1) * time.Minute + pollIntervalInSecond := time.Duration(1) * time.Second + + GetDoFunc = func(req *http.Request) (*http.Response, error) { + if req.URL.String() == reqUrl_token { + resBodyReader_token := io.NopCloser(bytes.NewReader([]byte(resBodyJSON_token))) + return &http.Response{ + StatusCode: 200, + Body: resBodyReader_token, + }, nil + } + + if req.URL.String() == reqUrl_sys { + resBodyReader_sys := io.NopCloser(bytes.NewReader([]byte(resBodyJSON_sys))) + return &http.Response{ + StatusCode: 200, + Body: resBodyReader_sys, + }, nil + } + if req.URL.String() == reqUrl_update { + resBodyReader_update := io.NopCloser(bytes.NewReader([]byte(resBodyJSON_req_S))) + return &http.Response{ + StatusCode: 200, + Body: resBodyReader_update, + }, nil + } + + if req.URL.String() == reqUrl_pollAndCancel { + resBodyReader_pollStatus_C := io.NopCloser(bytes.NewReader([]byte(resBodyJSON_req_C))) + return &http.Response{ + StatusCode: 200, + Body: resBodyReader_pollStatus_C, + }, nil + } + + return nil, fmt.Errorf("some-unknown-error") + } + + // execution and assertion + err := runAbapLandscapePortalUpdateAddOnProduct(&mockUpdateAddOnConfig, client, clientToken, mockServKey, maxRuntimeInMinute, pollIntervalInSecond) + + assert.Equal(t, nil, err) + }) + + t.Run("Update addon ended in error", func(t *testing.T) { + // write addon.yml + dir := t.TempDir() + oldCWD, _ := os.Getwd() + _ = os.Chdir(dir) + // clean up tmp dir + defer func() { + _ = os.Chdir(oldCWD) + }() + + addonYML := `addonProduct: some-addon-product +addonVersion: 1.0.0 +` + addonYMLBytes := []byte(addonYML) + os.WriteFile("addon.yml", addonYMLBytes, 0644) + + // mock Do func + maxRuntimeInMinute := time.Duration(1) * time.Minute + pollIntervalInSecond := time.Duration(1) * time.Second + + GetDoFunc = func(req *http.Request) (*http.Response, error) { + if req.URL.String() == reqUrl_token { + resBodyReader_token := io.NopCloser(bytes.NewReader([]byte(resBodyJSON_token))) + return &http.Response{ + StatusCode: 200, + Body: resBodyReader_token, + }, nil + } + + if req.URL.String() == reqUrl_sys { + resBodyReader_sys := io.NopCloser(bytes.NewReader([]byte(resBodyJSON_sys))) + return &http.Response{ + StatusCode: 200, + Body: resBodyReader_sys, + }, nil + } + if req.URL.String() == reqUrl_update { + resBodyReader_update := io.NopCloser(bytes.NewReader([]byte(resBodyJSON_req_S))) + return &http.Response{ + StatusCode: 200, + Body: resBodyReader_update, + }, nil + } + + if req.URL.String() == reqUrl_pollAndCancel && req.Method == "GET" { + resBodyReader_pollStatus_E := io.NopCloser(bytes.NewReader([]byte(resBodyJSON_req_E))) + return &http.Response{ + StatusCode: 200, + Body: resBodyReader_pollStatus_E, + }, nil + } + + if req.URL.String() == reqUrl_pollAndCancel && req.Method == "DELETE" { + resBodyReader_cancelUpdate := io.NopCloser(nil) + return &http.Response{ + StatusCode: 204, + Body: resBodyReader_cancelUpdate, + }, nil + } + + return nil, fmt.Errorf("some-unknown-error") + } + + // execution and assertion + expectedErr1 := fmt.Errorf("Addon update failed.\n") + expectedErr2 := fmt.Errorf("The final status of addon update is E. Error: %v\n", expectedErr1) + + err := runAbapLandscapePortalUpdateAddOnProduct(&mockUpdateAddOnConfig, client, clientToken, mockServKey, maxRuntimeInMinute, pollIntervalInSecond) + + assert.Equal(t, expectedErr2, err) + }) + + t.Run("Update addon was aborted", func(t *testing.T) { + // write addon.yml + dir := t.TempDir() + oldCWD, _ := os.Getwd() + _ = os.Chdir(dir) + // clean up tmp dir + defer func() { + _ = os.Chdir(oldCWD) + }() + + addonYML := `addonProduct: some-addon-product +addonVersion: 1.0.0 +` + addonYMLBytes := []byte(addonYML) + os.WriteFile("addon.yml", addonYMLBytes, 0644) + + // mock Do func + maxRuntimeInMinute := time.Duration(1) * time.Minute + pollIntervalInSecond := time.Duration(1) * time.Second + + GetDoFunc = func(req *http.Request) (*http.Response, error) { + if req.URL.String() == reqUrl_token { + resBodyReader_token := io.NopCloser(bytes.NewReader([]byte(resBodyJSON_token))) + return &http.Response{ + StatusCode: 200, + Body: resBodyReader_token, + }, nil + } + + if req.URL.String() == reqUrl_sys { + resBodyReader_sys := io.NopCloser(bytes.NewReader([]byte(resBodyJSON_sys))) + return &http.Response{ + StatusCode: 200, + Body: resBodyReader_sys, + }, nil + } + if req.URL.String() == reqUrl_update { + resBodyReader_update := io.NopCloser(bytes.NewReader([]byte(resBodyJSON_req_S))) + return &http.Response{ + StatusCode: 200, + Body: resBodyReader_update, + }, nil + } + + if req.URL.String() == reqUrl_pollAndCancel && req.Method == "GET" { + resBodyReader_pollStatus_X := io.NopCloser(bytes.NewReader([]byte(resBodyJSON_req_X))) + return &http.Response{ + StatusCode: 200, + Body: resBodyReader_pollStatus_X, + }, nil + } + + if req.URL.String() == reqUrl_pollAndCancel && req.Method == "DELETE" { + resBodyReader_cancelUpdate := io.NopCloser(nil) + return &http.Response{ + StatusCode: 204, + Body: resBodyReader_cancelUpdate, + }, nil + } + + return nil, fmt.Errorf("some-unknown-error") + } + + // execution and assertion + expectedErr1 := fmt.Errorf("Addon update was aborted.\n") + expectedErr2 := fmt.Errorf("The final status of addon update is X. Error: %v\n", expectedErr1) + + err := runAbapLandscapePortalUpdateAddOnProduct(&mockUpdateAddOnConfig, client, clientToken, mockServKey, maxRuntimeInMinute, pollIntervalInSecond) + + assert.Equal(t, expectedErr2, err) + }) + + t.Run("Update addon reached timeout", func(t *testing.T) { + // write addon.yml + dir := t.TempDir() + oldCWD, _ := os.Getwd() + _ = os.Chdir(dir) + // clean up tmp dir + defer func() { + _ = os.Chdir(oldCWD) + }() + + addonYML := `addonProduct: some-addon-product +addonVersion: 1.0.0 +` + addonYMLBytes := []byte(addonYML) + os.WriteFile("addon.yml", addonYMLBytes, 0644) + + // mock Do func + maxRuntimeInMinute := time.Duration(3) * time.Second + pollIntervalInSecond := time.Duration(1) * time.Second + + GetDoFunc = func(req *http.Request) (*http.Response, error) { + if req.URL.String() == reqUrl_token { + resBodyReader_token := io.NopCloser(bytes.NewReader([]byte(resBodyJSON_token))) + return &http.Response{ + StatusCode: 200, + Body: resBodyReader_token, + }, nil + } + + if req.URL.String() == reqUrl_sys { + resBodyReader_sys := io.NopCloser(bytes.NewReader([]byte(resBodyJSON_sys))) + return &http.Response{ + StatusCode: 200, + Body: resBodyReader_sys, + }, nil + } + if req.URL.String() == reqUrl_update { + resBodyReader_update := io.NopCloser(bytes.NewReader([]byte(resBodyJSON_req_S))) + return &http.Response{ + StatusCode: 200, + Body: resBodyReader_update, + }, nil + } + + if req.URL.String() == reqUrl_pollAndCancel && req.Method == "GET" { + resBodyReader_pollStatus_I := io.NopCloser(bytes.NewReader([]byte(resBodyJSON_req_I))) + return &http.Response{ + StatusCode: 200, + Body: resBodyReader_pollStatus_I, + }, nil + } + + return nil, fmt.Errorf("some-unknown-error") + } + + // execution and assertion + expectedErr1 := fmt.Errorf("Timed out: max runtime %v reached.", maxRuntimeInMinute) + expectedErr2 := fmt.Errorf("Error occurred before a final status can be reached. Error: %v\n", expectedErr1) + + err := runAbapLandscapePortalUpdateAddOnProduct(&mockUpdateAddOnConfig, client, clientToken, mockServKey, maxRuntimeInMinute, pollIntervalInSecond) + + assert.Equal(t, expectedErr2, err) + }) +} diff --git a/cmd/metadata_generated.go b/cmd/metadata_generated.go index 0554696056..89e8b6c320 100644 --- a/cmd/metadata_generated.go +++ b/cmd/metadata_generated.go @@ -25,6 +25,7 @@ func GetAllStepMetadata() map[string]config.StepData { "abapEnvironmentPushATCSystemConfig": abapEnvironmentPushATCSystemConfigMetadata(), "abapEnvironmentRunATCCheck": abapEnvironmentRunATCCheckMetadata(), "abapEnvironmentRunAUnitTest": abapEnvironmentRunAUnitTestMetadata(), + "abapLandscapePortalUpdateAddOnProduct": abapLandscapePortalUpdateAddOnProductMetadata(), "ansSendEvent": ansSendEventMetadata(), "apiKeyValueMapDownload": apiKeyValueMapDownloadMetadata(), "apiKeyValueMapUpload": apiKeyValueMapUploadMetadata(), diff --git a/cmd/piper.go b/cmd/piper.go index a75efe5598..d63c1c91c4 100644 --- a/cmd/piper.go +++ b/cmd/piper.go @@ -206,6 +206,7 @@ func Execute() { rootCmd.AddCommand(TmsExportCommand()) rootCmd.AddCommand(IntegrationArtifactTransportCommand()) rootCmd.AddCommand(AscAppUploadCommand()) + rootCmd.AddCommand(AbapLandscapePortalUpdateAddOnProductCommand()) rootCmd.AddCommand(ImagePushToRegistryCommand()) addRootFlags(rootCmd) diff --git a/documentation/docs/steps/abapLandscapePortalUpdateAddOnProduct.md b/documentation/docs/steps/abapLandscapePortalUpdateAddOnProduct.md new file mode 100644 index 0000000000..216f9ce05b --- /dev/null +++ b/documentation/docs/steps/abapLandscapePortalUpdateAddOnProduct.md @@ -0,0 +1,56 @@ +# ${docGenStepName} + +## ${docGenDescription} + +## Prerequisites + +- Please make sure, that you are under Embedded Steampunk environment. +- Please make sure, that the service landscape-portal-api-for-s4hc with plan api was assigned as entitlement to the subaccount, where you are about to deploy addon product. +- Please make sure, that before deploying addon product, an instance of landscape-portal-api-for-s4hc (plan api) was created, and a service key with x509 authentication mechanism was created for the instance. The service key needs to be stored in the Jenkins Credentials Store. +- Please make sure, that the system to deploy addon product is active, and the descriptor file with deployment information is available. + +## ${docGenParameters} + +## ${docGenConfiguration} + +## ${docJenkinsPluginDependencies} + +## Example: Configuration in the config.yml + +The recommended way to configure your pipeline is via the config.yml file. In this case, calling the step in the Jenkinsfile is reduced to one line: + +```groovy +abapLandscapePortalUpdateAddOnProduct script: this +``` + +The configuration values for the addon update can be passed through the `config.yml` file: + +```yaml +steps: + abapLandscapePortalUpdateAddOnProduct: + landscapePortalAPICredentialsId: 'landscapePortalAPICredentialsId' + abapSystemNumber: 'abapSystemNumber' + addonDescriptorFileName: 'addon.yml' + addonDescriptor: 'addonDescriptor' +``` + +## Example: Configuration in the Jenkinsfile + +The step, including all parameters, can also be called directly from the Jenkinsfile. In the following example, a configuration file is used. + +```groovy +abapLandscapePortalUpdateAddOnProduct ( + script: this, + landscapePortalAPICredentialsId: 'landscapePortalAPICredentialsId' + abapSystemNumber: 'abapSystemNumber' + addonDescriptorFileName: 'addon.yml' + addonDescriptor: 'addonDescriptor' +) +``` + +The file `addon.yml` would look like this: + +```yaml +addonProduct: some-addon-product +addonVersion: some-addon-version +``` diff --git a/go.mod b/go.mod index 04cc1482b3..cda780af34 100644 --- a/go.mod +++ b/go.mod @@ -101,7 +101,7 @@ require ( go.opentelemetry.io/otel/metric v1.21.0 // indirect go.opentelemetry.io/otel/trace v1.21.0 // indirect golang.org/x/image v0.0.0-20220302094943-723b81ca9867 // indirect - golang.org/x/tools v0.14.0 // indirect + golang.org/x/tools v0.17.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20240102182953-50ed04b92917 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240116215550-a9fa1716bcac // indirect ) @@ -238,6 +238,7 @@ require ( go.opencensus.io v0.24.0 // indirect go.starlark.net v0.0.0-20230525235612-a134d8f9ddca // indirect golang.org/x/crypto v0.18.0 + golang.org/x/exp v0.0.0-20240205201215-2c58cdc269a3 golang.org/x/net v0.20.0 // indirect golang.org/x/sync v0.6.0 golang.org/x/sys v0.16.0 // indirect diff --git a/go.sum b/go.sum index b6238ce488..c7a1f181d2 100644 --- a/go.sum +++ b/go.sum @@ -1212,6 +1212,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0 golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/exp v0.0.0-20240205201215-2c58cdc269a3 h1:/RIbNt/Zr7rVhIkQhooTxCxFcdWLGIKnZA4IXNFSrvo= +golang.org/x/exp v0.0.0-20240205201215-2c58cdc269a3/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= @@ -1490,8 +1492,8 @@ golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.14.0 h1:jvNa2pY0M4r62jkRQ6RwEZZyPcymeL9XZMLBbV7U2nc= -golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= +golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc= +golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/resources/com.sap.piper/pipeline/abapEnvironmentPipelineDefaults.yml b/resources/com.sap.piper/pipeline/abapEnvironmentPipelineDefaults.yml index 1399a26b8f..79d294985a 100644 --- a/resources/com.sap.piper/pipeline/abapEnvironmentPipelineDefaults.yml +++ b/resources/com.sap.piper/pipeline/abapEnvironmentPipelineDefaults.yml @@ -47,6 +47,7 @@ stages: abapSystemSizeOfPersistence: 2 abapSystemSizeOfRuntime: 1 confirmDeletion: 'true' + integrationTestOption: 'systemProvisioning' includeAddon: 'true' cfServiceKeyName: 'sap_com_0582' cfServiceKeyConfig: '{"scenario_id":"SAP_COM_0582","type":"basic"}' diff --git a/resources/metadata/abapLandscapePortalUpdateAddOnProduct.yaml b/resources/metadata/abapLandscapePortalUpdateAddOnProduct.yaml new file mode 100644 index 0000000000..660ea6a4e2 --- /dev/null +++ b/resources/metadata/abapLandscapePortalUpdateAddOnProduct.yaml @@ -0,0 +1,41 @@ +metadata: + name: abapLandscapePortalUpdateAddOnProduct + description: "Update the AddOn product in SAP BTP ABAP Environment system of Landscape Portal" + longDescription: | + This step describes the AddOn product update in SAP BTP ABAP Environment system of Landscape Portal +spec: + inputs: + secrets: + - name: landscapePortalAPICredentialsId + description: Jenkins secret text credential ID containing the service key to access the Landscape Portal Access API + type: jenkins + params: + - name: landscapePortalAPIServiceKey + type: string + description: Service key JSON string to access the Landscape Portal Access API + scope: + - PARAMETERS + mandatory: true + secret: true + resourceRef: + - name: landscapePortalAPICredentialsId + type: secret + param: landscapePortalAPIServiceKey + - name: abapSystemNumber + description: System Number of the abap integration test system + type: string + mandatory: true + scope: + - PARAMETERS + - STAGES + - STEPS + - name: addonDescriptorFileName + type: string + description: File name of the YAML file which describes the Product Version and corresponding Software Component Versions + mandatory: true + default: addon.yml + scope: + - PARAMETERS + - STAGES + - STEPS + - GENERAL diff --git a/test/groovy/CommonStepsTest.groovy b/test/groovy/CommonStepsTest.groovy index 46519c0482..9dc0aad304 100644 --- a/test/groovy/CommonStepsTest.groovy +++ b/test/groovy/CommonStepsTest.groovy @@ -125,6 +125,7 @@ public class CommonStepsTest extends BasePiperTest{ 'abapEnvironmentRunAUnitTest', //implementing new golang pattern without fields 'abapEnvironmentCreateSystem', //implementing new golang pattern without fields 'abapEnvironmentPushATCSystemConfig', //implementing new golang pattern without fields + 'abapLandscapePortalUpdateAddOnProduct', //implementing new golang pattern without fields 'artifactPrepareVersion', 'cloudFoundryCreateService', //implementing new golang pattern without fields 'cloudFoundryCreateServiceKey', //implementing new golang pattern without fields @@ -195,7 +196,7 @@ public class CommonStepsTest extends BasePiperTest{ 'integrationArtifactGetServiceEndpoint', //implementing new golang pattern without fields 'integrationArtifactDownload', //implementing new golang pattern without fields 'integrationArtifactUpload', //implementing new golang pattern without fields - 'integrationArtifactTransport', //implementing new golang pattern without fields + 'integrationArtifactTransport', //implementing new golang pattern without fields 'integrationArtifactTriggerIntegrationTest', //implementing new golang pattern without fields 'integrationArtifactUnDeploy', //implementing new golang pattern without fields 'integrationArtifactResource', //implementing new golang pattern without fields @@ -226,7 +227,7 @@ public class CommonStepsTest extends BasePiperTest{ 'azureBlobUpload', 'awsS3Upload', 'ansSendEvent', - 'apiProviderList', //implementing new golang pattern without fields + 'apiProviderList', //implementing new golang pattern without fields 'tmsUpload', 'tmsExport', 'imagePushToRegistry', diff --git a/test/groovy/templates/AbapEnvironmentPipelineStageIntegrationTestsTest.groovy b/test/groovy/templates/AbapEnvironmentPipelineStageIntegrationTestsTest.groovy index 493221a379..56d4830867 100644 --- a/test/groovy/templates/AbapEnvironmentPipelineStageIntegrationTestsTest.groovy +++ b/test/groovy/templates/AbapEnvironmentPipelineStageIntegrationTestsTest.groovy @@ -43,6 +43,7 @@ class abapEnvironmentPipelineStageIntegrationTestsTest extends BasePiperTest { helper.registerAllowedMethod('cloudFoundryDeleteService', [Map.class], {m -> stepsCalled.add('cloudFoundryDeleteService')}) helper.registerAllowedMethod('abapEnvironmentBuild', [Map.class], {m -> stepsCalled.add('abapEnvironmentBuild')}) helper.registerAllowedMethod('cloudFoundryCreateServiceKey', [Map.class], {m -> stepsCalled.add('cloudFoundryCreateServiceKey')}) + helper.registerAllowedMethod('abapLandscapePortalUpdateAddOnProduct', [Map.class], {m -> stepsCalled.add('abapLandscapePortalUpdateAddOnProduct')}) } @Test @@ -51,7 +52,7 @@ class abapEnvironmentPipelineStageIntegrationTestsTest extends BasePiperTest { nullScript.commonPipelineEnvironment.configuration.runStage = [ 'Integration Tests': true ] - jsr.step.abapEnvironmentPipelineStageIntegrationTests(script: nullScript, confirmDeletion: true) + jsr.step.abapEnvironmentPipelineStageIntegrationTests(script: nullScript, integrationTestOption: 'systemProvisioning', confirmDeletion: true) assertThat(stepsCalled, hasItems('input')) assertThat(stepsCalled, hasItems('abapEnvironmentCreateSystem')) @@ -66,7 +67,7 @@ class abapEnvironmentPipelineStageIntegrationTestsTest extends BasePiperTest { nullScript.commonPipelineEnvironment.configuration.runStage = [ 'Integration Tests': true ] - jsr.step.abapEnvironmentPipelineStageIntegrationTests(script: nullScript, confirmDeletion: false) + jsr.step.abapEnvironmentPipelineStageIntegrationTests(script: nullScript, integrationTestOption: 'systemProvisioning', confirmDeletion: false) assertThat(stepsCalled, not(hasItem('input'))) @@ -86,7 +87,7 @@ class abapEnvironmentPipelineStageIntegrationTestsTest extends BasePiperTest { ] try { - jsr.step.abapEnvironmentPipelineStageIntegrationTests(script: nullScript, confirmDeletion: false) + jsr.step.abapEnvironmentPipelineStageIntegrationTests(script: nullScript, integrationTestOption: 'systemProvisioning', confirmDeletion: false) fail("Expected exception") } catch (Exception e) { // failure expected @@ -112,4 +113,40 @@ class abapEnvironmentPipelineStageIntegrationTestsTest extends BasePiperTest { 'cloudFoundryCreateServiceKey'))) } + @Test + void testabapLandscapePortalUpdateAddOnProduct() { + + nullScript.commonPipelineEnvironment.configuration.runStage = [ + 'Integration Tests': true + ] + jsr.step.abapEnvironmentPipelineStageIntegrationTests(script: nullScript, integrationTestOption: 'addOnDeployment') + + + assertThat(stepsCalled, not(hasItems('input', + 'abapEnvironmentCreateSystem', + 'cloudFoundryDeleteService', + 'cloudFoundryCreateServiceKey'))) + assertThat(stepsCalled, hasItems('abapLandscapePortalUpdateAddOnProduct')) + assertThat(stepsCalled, hasItems('abapEnvironmentBuild')) + } + + @Test + void testabapLandscapePortalUpdateAddOnProductFails() { + + helper.registerAllowedMethod('abapLandscapePortalUpdateAddOnProduct', [Map.class], {m -> stepsCalled.add('abapLandscapePortalUpdateAddOnProduct'); error("Failed")}) + + nullScript.commonPipelineEnvironment.configuration.runStage = [ + 'Integration Tests': true + ] + + try { + jsr.step.abapEnvironmentPipelineStageIntegrationTests(script: nullScript, integrationTestOption: 'addOnDeployment') + fail("Expected exception") + } catch (Exception e) { + // failure expected + } + + assertThat(stepsCalled, not(hasItem('input'))) + assertThat(stepsCalled, hasItems('abapLandscapePortalUpdateAddOnProduct')) + } } diff --git a/vars/abapEnvironmentPipelineStageIntegrationTests.groovy b/vars/abapEnvironmentPipelineStageIntegrationTests.groovy index cb3d9a7574..eb9c591a8d 100644 --- a/vars/abapEnvironmentPipelineStageIntegrationTests.groovy +++ b/vars/abapEnvironmentPipelineStageIntegrationTests.groovy @@ -12,9 +12,9 @@ import static com.sap.piper.Prerequisites.checkScript 'cloudFoundryDeleteService', /** If set to true, a confirmation is required to delete the system */ 'confirmDeletion', - /** If set to true, the system is never deleted */ - 'debug', - 'testBuild' // Parameter for test execution mode, if true stage will be skipped + 'debug', // If set to true, the system is never deleted + 'testBuild', // Parameter for test execution mode, if true stage will be skipped + 'integrationTestOption' // Integration test option ] @Field Set STAGE_STEP_KEYS = GENERAL_CONFIG_KEYS @Field Set STEP_CONFIG_KEYS = STAGE_STEP_KEYS @@ -34,12 +34,15 @@ void call(Map parameters = [:]) { .addIfEmpty('confirmDeletion', true) .addIfEmpty('debug', false) .addIfEmpty('testBuild', false) + .addIfEmpty('integrationTestOption', 'systemProvisioning') .use() if (config.testBuild) { echo "Stage 'Integration Tests' skipped as parameter 'testBuild' is active" - } else { - piperStageWrapper (script: script, stageName: stageName, stashContent: [], stageLocking: false) { + return null; + } + piperStageWrapper (script: script, stageName: stageName, stashContent: [], stageLocking: false) { + if (config.integrationTestOption == 'systemProvisioning') { try { abapEnvironmentCreateSystem(script: parameters.script, includeAddon: true) cloudFoundryCreateServiceKey(script: parameters.script) @@ -48,14 +51,25 @@ void call(Map parameters = [:]) { echo "Deployment test of add-on product failed." throw e } finally { - if (config.confirmDeletion) { - input message: "Deployment test has been executed. Once you proceed, the test system will be deleted." - } if (!config.debug) { cloudFoundryDeleteService script: parameters.script } } + } else if (config.integrationTestOption == 'addOnDeployment') { + try { + abapLandscapePortalUpdateAddOnProduct(script: parameters.script) + abapEnvironmentBuild(script: parameters.script, phase: 'GENERATION', downloadAllResultFiles: true, useFieldsOfAddonDescriptor: '[{"use":"Name","renameTo":"SWC"}]') + } catch (Exception e) { + echo "Deployment test of add-on product failed." + throw e + } + } else { + e = new Error('Unsupoorted integration test option.') + throw e } - } + if (config.confirmDeletion) { + input message: "Deployment test has been executed. Once you proceed, the test system will be deleted." + } + } } diff --git a/vars/abapLandscapePortalUpdateAddOnProduct.groovy b/vars/abapLandscapePortalUpdateAddOnProduct.groovy new file mode 100644 index 0000000000..96b0d0ca0b --- /dev/null +++ b/vars/abapLandscapePortalUpdateAddOnProduct.groovy @@ -0,0 +1,11 @@ +import groovy.transform.Field + +@Field String STEP_NAME = getClass().getName() +@Field String METADATA_FILE = 'metadata/abapLandscapePortalUpdateAddOnProduct.yaml' + +void call(Map parameters = [:]) { + List credentials = [ + [type: 'token', id: 'landscapePortalAPICredentialsId', env: ['PIPER_landscapePortalAPIServiceKey']] + ] + piperExecuteBin(parameters, STEP_NAME, METADATA_FILE, credentials) +}