diff --git a/apim-apk-agent/build.gradle b/apim-apk-agent/build.gradle index d4edf5f0..707b6947 100644 --- a/apim-apk-agent/build.gradle +++ b/apim-apk-agent/build.gradle @@ -40,6 +40,7 @@ tasks.register('go_test', Exec) { group 'go' description 'Automates testing the packages named by the import paths.' commandLine 'sh', '-c', "go test -race -coverprofile=coverage.out -covermode=atomic ./..." + environment "APK_HOME", "$rootDir" } tasks.named('go_revive_run').configure { diff --git a/apim-apk-agent/config/default_config.go b/apim-apk-agent/config/default_config.go index 230f8039..cb882cf8 100644 --- a/apim-apk-agent/config/default_config.go +++ b/apim-apk-agent/config/default_config.go @@ -25,6 +25,8 @@ var defaultConfig = &Config{ ServiceURLDeprecated: UnassignedAsDeprecated, Username: "admin", Password: "$env{cp_admin_pwd}", + ClientID: "", + ClientSecret: "", EnvironmentLabels: []string{"Default"}, RetryInterval: 5, SkipSSLVerification: false, diff --git a/apim-apk-agent/config/types.go b/apim-apk-agent/config/types.go index e1c47e04..ee3674f2 100644 --- a/apim-apk-agent/config/types.go +++ b/apim-apk-agent/config/types.go @@ -111,6 +111,8 @@ type controlPlane struct { HTTPClient httpClient RequestWorkerPool requestWorkerPool InternalKeyIssuer string + ClientID string + ClientSecret string } // Dataplane struct contains the configurations related to the APK diff --git a/apim-apk-agent/internal/agent/agent.go b/apim-apk-agent/internal/agent/agent.go index c5d93ae0..fce361f5 100644 --- a/apim-apk-agent/internal/agent/agent.go +++ b/apim-apk-agent/internal/agent/agent.go @@ -158,7 +158,7 @@ func Run(conf *config.Config) { defer wg.Done() logger.LoggerAgent.Info("starting manager") if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { - logger.LoggerAgent.Warn("problem running manager: %v", err) + logger.LoggerAgent.Warnf("problem running manager: %v", err) } }() diff --git a/apim-apk-agent/pkg/loggers/logger.go b/apim-apk-agent/pkg/loggers/logger.go index 5e108d19..ecd97a05 100644 --- a/apim-apk-agent/pkg/loggers/logger.go +++ b/apim-apk-agent/pkg/loggers/logger.go @@ -36,6 +36,7 @@ const ( pkgMsg = "github.com/wso2/product-apim-tooling/apim-apk-agent/pkg/messaging" pkgHealth = "github.com/wso2/product-apim-tooling/apim-apk-agent/pkg/health" pkgTLSUtils = "github.com/wso2/product-apim-tooling/apim-apk-agent/pkg/tlsutils" + pkgUtils = "github.com/wso2/product-apim-tooling/apim-apk-agent/pkg/utils" pkgAdapter = "github.com/wso2/apk/adapter/pkg/adapter" pkgSync = "github.com/wso2/product-apim-tooling/apim-apk-agent/pkg/synchronizer" pkgSoapUtils = "github.com/wso2/apk/adapter/pkg/soaputils" @@ -49,6 +50,7 @@ var ( LoggerMsg logging.Log LoggerHealth logging.Log LoggerTLSUtils logging.Log + LoggerUtils logging.Log LoggerAdapter logging.Log LoggerSync logging.Log LoggerSoapUtils logging.Log @@ -67,6 +69,7 @@ func UpdateLoggers() { LoggerMsg = logging.InitPackageLogger(pkgMsg) LoggerHealth = logging.InitPackageLogger(pkgHealth) LoggerTLSUtils = logging.InitPackageLogger(pkgTLSUtils) + LoggerUtils = logging.InitPackageLogger(pkgUtils) LoggerAdapter = logging.InitPackageLogger(pkgAdapter) LoggerSync = logging.InitPackageLogger(pkgSync) LoggerSoapUtils = logging.InitPackageLogger(pkgSoapUtils) diff --git a/apim-apk-agent/pkg/managementserver/rest_server.go b/apim-apk-agent/pkg/managementserver/rest_server.go index a123a93d..001de889 100644 --- a/apim-apk-agent/pkg/managementserver/rest_server.go +++ b/apim-apk-agent/pkg/managementserver/rest_server.go @@ -17,11 +17,14 @@ package managementserver import ( + "bytes" "fmt" - "net/http" - "github.com/gin-gonic/gin" "github.com/wso2/product-apim-tooling/apim-apk-agent/config" + logger "github.com/wso2/product-apim-tooling/apim-apk-agent/pkg/loggers" + "github.com/wso2/product-apim-tooling/apim-apk-agent/pkg/utils" + "gopkg.in/yaml.v2" + "net/http" ) func init() { @@ -43,7 +46,108 @@ func StartInternalServer(port uint) { applicationMappingList := GetAllApplicationMappings() c.JSON(http.StatusOK, ApplicationMappingList{List: applicationMappingList}) }) + r.POST("/apis", func(c *gin.Context) { + var event APICPEvent + if err := c.ShouldBindJSON(&event); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if event.Event == DeleteEvent { + logger.LoggerMgtServer.Infof("Delete event received with APIUUID: %s", event.API.APIUUID) + // Delete the api + utils.DeleteAPI(event.API.APIUUID) + } else { + apiYaml := createAPIYaml(event) + definition := event.API.Definition + deploymentContent := createDeployementYaml() + zipFiles := []utils.ZipFile{{ + Path: fmt.Sprintf("%s-%s/api.yaml", event.API.APIName, event.API.APIVersion), + Content: apiYaml, + }, { + Path: fmt.Sprintf("%s-%s/deployment_environments.yaml", event.API.APIName, event.API.APIVersion), + Content: deploymentContent, + }, { + Path: fmt.Sprintf("%s-%s/Definitions/swagger.yaml", event.API.APIName, event.API.APIVersion), + Content: definition, + }} + var buf bytes.Buffer + if err := utils.CreateZipFile(&buf, zipFiles); err != nil { + logger.LoggerMgtServer.Errorf("Error while creating apim zip file for api uuid: %s. Error: %+v", event.API.APIUUID, err) + } + + id, err := utils.ImportAPI(fmt.Sprintf("admin-%s-%s.zip", event.API.APIName, event.API.APIVersion), &buf) + if err != nil { + logger.LoggerMgtServer.Errorf("Error while importing API. Sending error response to Adapter.") + c.JSON(http.StatusInternalServerError, err.Error()) + return + } + c.JSON(http.StatusOK, map[string]string{"id": id}) + } + }) gin.SetMode(gin.ReleaseMode) publicKeyLocation, privateKeyLocation, _ := config.GetKeyLocations() r.RunTLS(fmt.Sprintf(":%d", port), publicKeyLocation, privateKeyLocation) } + +func createAPIYaml(apiCPEvent APICPEvent) string { + data := map[string]interface{}{ + "type": "api", + "version": "v4.3.0", + "data": map[string]interface{}{ + "id": apiCPEvent.API.APIUUID, + "name": apiCPEvent.API.APIName, + "context": apiCPEvent.API.BasePath, + "version": apiCPEvent.API.APIVersion, + "organizationId": apiCPEvent.API.Organization, + "provider": "admin", + "lifeCycleStatus": "PUBLISHED", + "responseCachingEnabled": false, + "cacheTimeout": 300, + "hasThumbnail": false, + "isDefaultVersion": apiCPEvent.API.IsDefaultVersion, + "isRevision": false, + "revisionId": apiCPEvent.API.RevisionID, + "enableSchemaValidation": false, + "enableSubscriberVerification": false, + "type": "HTTP", + "endpointConfig": map[string]interface{}{ + "endpoint_type": "http", + "sandbox_endpoints": map[string]interface{}{ + "url": "http://local", + }, + "production_endpoints": map[string]interface{}{ + "url": "http://local", + }, + }, + "policies": []string{"Unlimited"}, + "gatewayType": "wso2/apk", + "gatewayVendor": "wso2", + }, + } + + yamlBytes, _ := yaml.Marshal(data) + return string(yamlBytes) +} + +func createDeployementYaml() string { + config, err := config.ReadConfigs() + envLabel := []string{"Default"} + if (err == nil) { + envLabel = config.ControlPlane.EnvironmentLabels + } + deploymentEnvData := []map[string]string{} + for _, label := range envLabel { + deploymentEnvData = append(deploymentEnvData, map[string]string{ + "displayOnDevportal": "true", + "deploymentEnvironment": label, + }) + } + data := map[string]interface{}{ + "type": "deployment_environments", + "version": "v4.3.0", + "data": deploymentEnvData, + } + + yamlBytes, _ := yaml.Marshal(data) + return string(yamlBytes) +} diff --git a/apim-apk-agent/pkg/managementserver/types.go b/apim-apk-agent/pkg/managementserver/types.go index 750c889f..85631c49 100644 --- a/apim-apk-agent/pkg/managementserver/types.go +++ b/apim-apk-agent/pkg/managementserver/types.go @@ -104,3 +104,41 @@ type ApplicationMapping struct { type ApplicationMappingList struct { List []ApplicationMapping `json:"list"` } + +// APICPEvent holds data of a specific API event from adapter +type APICPEvent struct { + Event EventType `json:"event"` + API API `json:"payload"` +} + +// EventType is the type of api event. One of (CREATE, UPDATE, DELETE) +type EventType string + +const ( + // CreateEvent is create api event + CreateEvent EventType = "CREATE" + // DeleteEvent is delete api event + DeleteEvent EventType = "DELETE" +) + +// API holds the api data from adapter api event +type API struct { + APIUUID string `json:"apiUUID"` + APIName string `json:"apiName"` + APIVersion string `json:"apiVersion"` + IsDefaultVersion bool `json:"isDefaultVersion"` + Definition string `json:"definition"` + APIType string `json:"apiType"` + BasePath string `json:"basePath"` + Organization string `json:"organization"` + SystemAPI bool `json:"systemAPI"` + APIProperties []Property `json:"apiProperties,omitempty"` + Environment string `json:"environment,omitempty"` + RevisionID string `json:"revisionID"` +} + +// Property holds the api property +type Property struct { + Name string `json:"name,omitempty"` + Value string `json:"value,omitempty"` +} diff --git a/apim-apk-agent/pkg/transformer/transformer.go b/apim-apk-agent/pkg/transformer/transformer.go index 1926b875..a7bab51b 100644 --- a/apim-apk-agent/pkg/transformer/transformer.go +++ b/apim-apk-agent/pkg/transformer/transformer.go @@ -815,7 +815,7 @@ func createConfigMaps(certFiles map[string]string, k8sArtifact *K8sArtifacts) { cm.Data[confKey] = confValue certConfigMap := &cm - logger.LoggerTransformer.Debug("New ConfigMap Data: %v", *certConfigMap) + logger.LoggerTransformer.Debugf("New ConfigMap Data: %v", *certConfigMap) k8sArtifact.ConfigMaps[certConfigMap.ObjectMeta.Name] = certConfigMap } } diff --git a/apim-apk-agent/pkg/transformer/utils.go b/apim-apk-agent/pkg/transformer/utils.go index a0264f34..d956ed31 100644 --- a/apim-apk-agent/pkg/transformer/utils.go +++ b/apim-apk-agent/pkg/transformer/utils.go @@ -71,7 +71,7 @@ func readZipFile(file *zip.File) (*APIArtifact, error) { apiArtifact.APIFileName = file.Name zipReader, err := zip.NewReader(bytes.NewReader(content), int64(len(content))) if err != nil { - logger.LoggerTransformer.Errorf("Error reading zip file: ", err) + logger.LoggerTransformer.Errorf("Error reading zip file: %+v", err) return nil, err } for _, file := range zipReader.File { diff --git a/apim-apk-agent/pkg/utils/publisher_rest_api_utils.go b/apim-apk-agent/pkg/utils/publisher_rest_api_utils.go new file mode 100644 index 00000000..2567472a --- /dev/null +++ b/apim-apk-agent/pkg/utils/publisher_rest_api_utils.go @@ -0,0 +1,246 @@ +/* + * Copyright (c) 2024, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package utils + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "mime/multipart" + "net/http" + "net/url" + "strings" + + "github.com/wso2/product-apim-tooling/apim-apk-agent/config" + logger "github.com/wso2/product-apim-tooling/apim-apk-agent/pkg/loggers" + "github.com/wso2/product-apim-tooling/apim-apk-agent/pkg/tlsutils" +) + +// Scope - token scope +type Scope string + +const ( + // APIImportRelativePath is the relative path of API import in publisher rest API + APIImportRelativePath = "api/am/publisher/v4/apis/import?preserveProvider=false&overwrite=true&rotateRevision=true" + // TokenRelativePath is the relative path for getting token in publisher rest API + TokenRelativePath = "oauth2/token" + // APIDeleteRelativePath is the relative path of delete api in publisher rest API + APIDeleteRelativePath = "api/am/publisher/v4/apis/" + payloadJSON = `{ + "callbackUrl": "www.google.lk", + "clientName": "rest_api_publisher", + "owner": "admin", + "grantType": "client_credentials password refresh_token", + "saasApp": true + }` + // AdminScope admin scope + AdminScope Scope = "apim:admin" + // ImportExportScope import export api scope + ImportExportScope Scope = "apim:api_import_export" +) + +var ( + tokenURL string + apiImportURL string + apiDeleteURL string + username string + password string + skipSSL bool + clientID string + clientSecret string + basicAuthHeaderValue string +) + +func init() { + // Read configurations and derive the eventHub details + conf, errReadConfig := config.ReadConfigs() + if errReadConfig != nil { + // This has to be error. For debugging purpose info + logger.LoggerUtils.Errorf("Error reading configs: %v", errReadConfig) + } + // Populate data from the config + cpConfigs := conf.ControlPlane + cpURL := cpConfigs.ServiceURL + // If the eventHub URL is configured with trailing slash + if strings.HasSuffix(cpURL, "/") { + apiImportURL = cpURL + APIImportRelativePath + tokenURL = cpURL + TokenRelativePath + apiDeleteURL = cpURL + APIDeleteRelativePath + } else { + apiImportURL = cpURL + "/" + APIImportRelativePath + tokenURL = cpURL + "/" + TokenRelativePath + apiDeleteURL = cpURL + "/" + APIDeleteRelativePath + } + username = cpConfigs.Username + password = cpConfigs.Password + clientID = cpConfigs.ClientID + clientSecret = cpConfigs.ClientSecret + skipSSL = cpConfigs.SkipSSLVerification + + // If clientId and clientSecret is not provided use username and password as basic auth to access rest apis. + basicAuthHeaderValue = GetBasicAuthHeaderValue(username, password) +} + +// Base64EncodeCredentials encodes the given username and password into a base64 string. +func Base64EncodeCredentials(username, password string) string { + credentials := username + ":" + password + return base64.StdEncoding.EncodeToString([]byte(credentials)) +} + +// GetBasicAuthHeaderValue constructs and returns the Basic authentication header value using the provided username and password. +func GetBasicAuthHeaderValue(username, password string) string { + return fmt.Sprintf("Basic %s", Base64EncodeCredentials(username, password)) +} + +// GetToken retrieves an OAuth token using the provided credentials and scopes. +func GetToken(scopes []string, clientID string, clientSecret string) (string, error) { + form := url.Values{} + form.Set("grant_type", "password") + form.Set("username", username) + form.Set("password", password) + form.Set("scope", strings.Join(scopes, " ")) + req, err := http.NewRequest("POST", tokenURL, strings.NewReader(form.Encode())) + if err != nil { + return "", err + } + req.Header.Set("Authorization", GetBasicAuthHeaderValue(clientID, clientSecret)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := tlsutils.InvokeControlPlane(req, skipSSL) + if err != nil { + return "", err + } + defer resp.Body.Close() + + // Read the response body + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return "", err + } + + // Check for non-200 response status + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("unexpected response status: %s", resp.Status) + } + + // Parse JSON response + var response map[string]interface{} + if err := json.Unmarshal(body, &response); err != nil { + return "", err + } + + // Extract access_token + accessToken, ok := response["access_token"].(string) + if !ok { + return "", fmt.Errorf("access_token not found in response") + } + + return accessToken, nil +} + +// GetSuitableAuthHeadervalue returns an appropriate authentication header value based on whether client credentials are provided. +func GetSuitableAuthHeadervalue(scopes []string) (string, error) { + if clientID != "" && clientSecret != "" { + token, err := GetToken(scopes, clientID, clientSecret) + if err != nil { + return "", err + } + return fmt.Sprintf("Bearer %s", token), nil + } + return basicAuthHeaderValue, nil +} + +// ImportAPI imports an API from a zip file, returning the ID of the imported API. +func ImportAPI(apiZipName string, zipFileBytes *bytes.Buffer) (string, error) { + authHeaderVal, err := GetSuitableAuthHeadervalue([]string{string(AdminScope), string(ImportExportScope)}) + if err != nil { + return "", err + } + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + part, err := writer.CreateFormFile("file", apiZipName) + if err != nil { + return "", err + } + if _, err := io.Copy(part, zipFileBytes); err != nil { + return "", err + } + writer.Close() + req, err := http.NewRequest("POST", apiImportURL, body) + if err != nil { + return "", err + } + + req.Header.Set("Authorization", authHeaderVal) + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("Accept", "application/json") + resp, err := tlsutils.InvokeControlPlane(req, skipSSL) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusConflict { + logger.LoggerTLSUtils.Infof("API already exists in the CP hence ignoring the event. API zip name %s", apiZipName) + return "", nil + } + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("unexpected response status: %s", resp.Status) + } + // try to parse the body as json and extract id from the response. + var responseMap map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&responseMap) + if err != nil { + // TODO after APIM is able to send json response, we should return error here, Until then return nil for error as its expected. + return "", nil + } + + // Assuming the response contains an ID field, you can extract it like this: + id, ok := responseMap["id"].(string) + if !ok { + return "", nil + } + return id, nil +} + +// DeleteAPI deletes an API given its UUID. +func DeleteAPI(apiUUID string) error { + deleteURL := apiDeleteURL + apiUUID + authheaderval, err := GetSuitableAuthHeadervalue([]string{string(AdminScope), string(ImportExportScope)}) + if err != nil { + return err + } + req, err := http.NewRequest("DELETE", deleteURL, nil) + if err != nil { + return err + } + + req.Header.Set("Authorization", authheaderval) + req.Header.Set("Content-Type", "application/json") + resp, err := tlsutils.InvokeControlPlane(req, skipSSL) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("error occured while deleting the API. Status: %s", resp.Status) + } + + return nil +} diff --git a/apim-apk-agent/pkg/utils/zip_utils.go b/apim-apk-agent/pkg/utils/zip_utils.go new file mode 100644 index 00000000..55fd3ee6 --- /dev/null +++ b/apim-apk-agent/pkg/utils/zip_utils.go @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2024, WSO2 LLC. (http://www.wso2.org) All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package utils + +import ( + "archive/zip" + "io" +) + +// ZipFile holds the content and the path of the file inside the zip folder +type ZipFile struct { + Path string + Content string +} + +// CreateZipFile creates a zip file using the provided io.Writer. +// It takes a slice of ZipFile structs containing information about the files to be added to the zip. +// Each ZipFile struct specifies the file path within the zip and its content. +// It returns an error if any operation fails. +func CreateZipFile(writer io.Writer, zipFiles []ZipFile) error { + zipWriter := zip.NewWriter(writer) + for _, zipFile := range zipFiles { + fileWriter, err := zipWriter.Create(zipFile.Path) + if err != nil { + return err + } + _, err = fileWriter.Write([]byte(zipFile.Content)) + if err != nil { + return err + } + } + return zipWriter.Close() +}