github.com/kyma-incubator/compass/components/director@v0.0.0-20230623144113-d764f56ff805/internal/destinationfetchersvc/client.go (about)

     1  package destinationfetchersvc
     2  
     3  import (
     4  	"context"
     5  	"crypto/tls"
     6  	"encoding/json"
     7  	"fmt"
     8  	"io"
     9  	"net/http"
    10  	"net/url"
    11  	"strconv"
    12  	"strings"
    13  	"time"
    14  
    15  	"github.com/avast/retry-go/v4"
    16  	"github.com/kyma-incubator/compass/components/director/internal/model"
    17  	"github.com/kyma-incubator/compass/components/director/pkg/apperrors"
    18  	"github.com/kyma-incubator/compass/components/director/pkg/config"
    19  	"github.com/kyma-incubator/compass/components/director/pkg/correlation"
    20  	"github.com/kyma-incubator/compass/components/director/pkg/log"
    21  	"github.com/kyma-incubator/compass/components/director/pkg/resource"
    22  	"github.com/pkg/errors"
    23  )
    24  
    25  const (
    26  	correlationIDPrefix = "sap.s4:communicationScenario:"
    27  	s4HANAType          = "SAP S/4HANA Cloud"
    28  	s4HANABaseURLSuffix = "-api"
    29  	authorizationHeader = "Authorization"
    30  )
    31  
    32  // DestinationServiceAPIConfig destination service api configuration
    33  type DestinationServiceAPIConfig struct {
    34  	GoroutineLimit                int64         `envconfig:"APP_DESTINATIONS_SENSITIVE_GOROUTINE_LIMIT,default=10"`
    35  	RetryInterval                 time.Duration `envconfig:"APP_DESTINATIONS_RETRY_INTERVAL,default=100ms"`
    36  	RetryAttempts                 uint          `envconfig:"APP_DESTINATIONS_RETRY_ATTEMPTS,default=3"`
    37  	EndpointGetTenantDestinations string        `envconfig:"APP_ENDPOINT_GET_TENANT_DESTINATIONS,default=/destination-configuration/v1/subaccountDestinations"`
    38  	EndpointFindDestination       string        `envconfig:"APP_ENDPOINT_FIND_DESTINATION,default=/destination-configuration/v1/destinations"`
    39  	Timeout                       time.Duration `envconfig:"APP_DESTINATIONS_TIMEOUT,default=5s"`
    40  	PageSize                      int           `envconfig:"APP_DESTINATIONS_PAGE_SIZE,default=100"`
    41  	PagingPageParam               string        `envconfig:"APP_DESTINATIONS_PAGE_PARAM,default=$page"`
    42  	PagingSizeParam               string        `envconfig:"APP_DESTINATIONS_PAGE_SIZE_PARAM,default=$pageSize"`
    43  	PagingCountParam              string        `envconfig:"APP_DESTINATIONS_PAGE_COUNT_PARAM,default=$pageCount"`
    44  	PagingCountHeader             string        `envconfig:"APP_DESTINATIONS_PAGE_COUNT_HEADER,default=Page-Count"`
    45  	SkipSSLVerify                 bool          `envconfig:"APP_DESTINATIONS_SKIP_SSL_VERIFY,default=false"`
    46  	OAuthTokenPath                string        `envconfig:"APP_DESTINATION_OAUTH_TOKEN_PATH,default=/oauth/token"`
    47  	ResponseCorrelationIDHeader   string        `envconfig:"APP_DESTINATIONS_RESPONSE_CORRELATION_ID_HEADER,default=x-vcap-request-id"`
    48  }
    49  
    50  // Client destination client
    51  type Client struct {
    52  	HTTPClient        *http.Client
    53  	apiConfig         DestinationServiceAPIConfig
    54  	authConfig        config.InstanceConfig
    55  	authToken         string
    56  	authTokenValidity time.Time
    57  }
    58  
    59  // Close closes idle connections
    60  func (c *Client) Close() {
    61  	c.HTTPClient.CloseIdleConnections()
    62  }
    63  
    64  // destinationFromService destination received from destination service
    65  type destinationFromService struct {
    66  	Name                    string `json:"Name"`
    67  	Type                    string `json:"Type"`
    68  	URL                     string `json:"URL"`
    69  	Authentication          string `json:"Authentication"`
    70  	XFSystemName            string `json:"XFSystemName"`
    71  	CommunicationScenarioID string `json:"communicationScenarioId"`
    72  	ProductName             string `json:"product.name"`
    73  	XCorrelationID          string `json:"x-correlation-id"`
    74  	XSystemTenantID         string `json:"x-system-id"`
    75  	XSystemTenantName       string `json:"x-system-name"`
    76  	XSystemType             string `json:"x-system-type"`
    77  	XSystemBaseURL          string `json:"x-system-base-url"`
    78  }
    79  
    80  func (d *destinationFromService) setDefaults(result *model.DestinationInput) error {
    81  	// Set values from custom properties
    82  	if result.XSystemType == "" {
    83  		result.XSystemType = d.ProductName
    84  	}
    85  	if result.XSystemType != s4HANAType {
    86  		return nil
    87  	}
    88  	if result.XCorrelationID == "" {
    89  		if d.CommunicationScenarioID != "" {
    90  			result.XCorrelationID = correlationIDPrefix + d.CommunicationScenarioID
    91  		}
    92  	}
    93  	if result.XSystemTenantName == "" {
    94  		result.XSystemTenantName = d.XFSystemName
    95  	}
    96  	if result.XSystemBaseURL != "" || result.URL == "" {
    97  		return nil
    98  	}
    99  
   100  	baseURL, err := url.Parse(result.URL)
   101  	if err != nil {
   102  		return errors.Wrapf(err, "%s destination has invalid URL '%s'", s4HANAType, result.URL)
   103  	}
   104  	subdomains := strings.Split(baseURL.Hostname(), ".")
   105  	if len(subdomains) < 2 {
   106  		return fmt.Errorf(
   107  			"%s destination has invalid URL '%s'. Expected at least 2 subdomains", s4HANAType, result.URL)
   108  	}
   109  	subdomains[0] = strings.TrimSuffix(subdomains[0], s4HANABaseURLSuffix)
   110  
   111  	result.XSystemBaseURL = fmt.Sprintf("%s://%s", baseURL.Scheme, strings.Join(subdomains, "."))
   112  	return nil
   113  }
   114  
   115  // ToModel missing godoc
   116  func (d *destinationFromService) ToModel() (model.DestinationInput, error) {
   117  	result := model.DestinationInput{
   118  		Name:              d.Name,
   119  		Type:              d.Type,
   120  		URL:               d.URL,
   121  		Authentication:    d.Authentication,
   122  		XCorrelationID:    d.XCorrelationID,
   123  		XSystemTenantID:   d.XSystemTenantID,
   124  		XSystemTenantName: d.XSystemTenantName,
   125  		XSystemType:       d.XSystemType,
   126  		XSystemBaseURL:    d.XSystemBaseURL,
   127  	}
   128  
   129  	if err := d.setDefaults(&result); err != nil {
   130  		return model.DestinationInput{}, err
   131  	}
   132  
   133  	return result, result.Validate()
   134  }
   135  
   136  // DestinationResponse paged response from destination service
   137  type DestinationResponse struct {
   138  	destinations []destinationFromService
   139  	pageCount    int
   140  }
   141  
   142  func getHTTPClient(instanceConfig config.InstanceConfig, apiConfig DestinationServiceAPIConfig) (*http.Client, error) {
   143  	cert, err := tls.X509KeyPair([]byte(instanceConfig.Cert), []byte(instanceConfig.Key))
   144  	if err != nil {
   145  		return nil, errors.Errorf("failed to create destinations client x509 pair: %v", err)
   146  	}
   147  	return &http.Client{
   148  		Transport: &http.Transport{
   149  			TLSClientConfig: &tls.Config{
   150  				InsecureSkipVerify: apiConfig.SkipSSLVerify,
   151  				Certificates:       []tls.Certificate{cert},
   152  			},
   153  		},
   154  		Timeout: apiConfig.Timeout,
   155  	}, nil
   156  }
   157  
   158  func setInstanceConfigTokenURLForSubdomain(instanceConfig *config.InstanceConfig,
   159  	apiConfig DestinationServiceAPIConfig, subdomain string) error {
   160  	baseTokenURL, err := url.Parse(instanceConfig.TokenURL)
   161  	if err != nil {
   162  		return errors.Errorf("failed to parse auth url '%s': %v", instanceConfig.TokenURL, err)
   163  	}
   164  	parts := strings.Split(baseTokenURL.Hostname(), ".")
   165  	if len(parts) < 2 {
   166  		return errors.Errorf("auth url '%s' should have a subdomain", instanceConfig.TokenURL)
   167  	}
   168  	originalSubdomain := parts[0]
   169  
   170  	instanceConfig.TokenURL = strings.Replace(instanceConfig.TokenURL, originalSubdomain, subdomain, 1) +
   171  		apiConfig.OAuthTokenPath
   172  
   173  	return nil
   174  }
   175  
   176  // NewClient returns new destination client
   177  func NewClient(instanceConfig config.InstanceConfig, apiConfig DestinationServiceAPIConfig,
   178  	subdomain string) (*Client, error) {
   179  	if err := setInstanceConfigTokenURLForSubdomain(&instanceConfig, apiConfig, subdomain); err != nil {
   180  		return nil, err
   181  	}
   182  	httpClient, err := getHTTPClient(instanceConfig, apiConfig)
   183  	if err != nil {
   184  		return nil, err
   185  	}
   186  
   187  	return &Client{
   188  		HTTPClient: httpClient,
   189  		apiConfig:  apiConfig,
   190  		authConfig: instanceConfig,
   191  	}, nil
   192  }
   193  
   194  // FetchTenantDestinationsPage returns a page of destinations
   195  func (c *Client) FetchTenantDestinationsPage(ctx context.Context, tenantID, page string) (*DestinationResponse, error) {
   196  	fetchURL := c.authConfig.URL + c.apiConfig.EndpointGetTenantDestinations
   197  	req, err := c.buildFetchRequest(ctx, fetchURL, page)
   198  	if err != nil {
   199  		return nil, err
   200  	}
   201  
   202  	destinationsPageCallStart := time.Now()
   203  	res, err := c.sendRequestWithRetry(req)
   204  	if err != nil {
   205  		return nil, err
   206  	}
   207  	defer func() {
   208  		if err := res.Body.Close(); err != nil {
   209  			log.C(ctx).WithError(err).Error("Unable to close response body")
   210  		}
   211  	}()
   212  
   213  	if res.StatusCode != http.StatusOK {
   214  		return nil, errors.Errorf("received status code %d when trying to fetch destinations", res.StatusCode)
   215  	}
   216  
   217  	destinationsPageCallHeadersDuration := time.Since(destinationsPageCallStart)
   218  
   219  	var destinations []destinationFromService
   220  	if err := json.NewDecoder(res.Body).Decode(&destinations); err != nil {
   221  		return nil, errors.Wrap(err, "failed to decode response body")
   222  	}
   223  
   224  	destinationsPageCallFullDuration := time.Since(destinationsPageCallStart)
   225  
   226  	pageCountHeader := res.Header.Get(c.apiConfig.PagingCountHeader)
   227  	if pageCountHeader == "" {
   228  		return nil, errors.Errorf("missing '%s' header from destinations response", c.apiConfig.PagingCountHeader)
   229  	}
   230  	pageCount, err := strconv.Atoi(pageCountHeader)
   231  	if err != nil {
   232  		return nil, errors.Errorf("invalid header '%s' '%s'", c.apiConfig.PagingCountHeader, pageCountHeader)
   233  	}
   234  
   235  	logDuration := log.C(ctx).Debugf
   236  	if destinationsPageCallFullDuration > c.apiConfig.Timeout/2 {
   237  		logDuration = log.C(ctx).Warnf
   238  	}
   239  	destinationCorrelationHeader := c.apiConfig.ResponseCorrelationIDHeader
   240  	logDuration("Getting tenant '%s' destinations page %s/%s took %s, %s of which for headers, %s: '%s'",
   241  		tenantID, page, pageCountHeader, destinationsPageCallFullDuration.String(), destinationsPageCallHeadersDuration.String(),
   242  		destinationCorrelationHeader, res.Header.Get(destinationCorrelationHeader))
   243  
   244  	return &DestinationResponse{
   245  		destinations: destinations,
   246  		pageCount:    pageCount,
   247  	}, nil
   248  }
   249  
   250  func (c *Client) setToken(req *http.Request) error {
   251  	token, err := c.getToken(req.Context())
   252  	if err != nil {
   253  		return err
   254  	}
   255  
   256  	req.Header.Set(authorizationHeader, "Bearer "+token)
   257  	return nil
   258  }
   259  
   260  func (c *Client) buildFetchRequest(ctx context.Context, url string, page string) (*http.Request, error) {
   261  	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
   262  	if err != nil {
   263  		return nil, errors.Wrapf(err, "failed to build request")
   264  	}
   265  	headers := correlation.HeadersForRequest(req)
   266  	for key, value := range headers {
   267  		req.Header.Set(key, value)
   268  	}
   269  	query := req.URL.Query()
   270  	query.Add(c.apiConfig.PagingCountParam, "true")
   271  	query.Add(c.apiConfig.PagingPageParam, page)
   272  	query.Add(c.apiConfig.PagingSizeParam, strconv.Itoa(c.apiConfig.PageSize))
   273  	req.URL.RawQuery = query.Encode()
   274  	if err := c.setToken(req); err != nil {
   275  		return nil, err
   276  	}
   277  	return req, nil
   278  }
   279  
   280  type token struct {
   281  	AccessToken      string `json:"access_token,omitempty"`
   282  	ExpiresInSeconds int64  `json:"expires_in,omitempty"`
   283  }
   284  
   285  func (c *Client) getToken(ctx context.Context) (string, error) {
   286  	if c.authToken != "" && time.Now().Before(c.authTokenValidity.Add(-c.apiConfig.Timeout)) {
   287  		return c.authToken, nil
   288  	}
   289  	tokenRequestBody := strings.NewReader(url.Values{
   290  		"grant_type":    {"client_credentials"},
   291  		"client_id":     {c.authConfig.ClientID},
   292  		"client_secret": {c.authConfig.ClientSecret},
   293  	}.Encode())
   294  	tokenRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, c.authConfig.TokenURL, tokenRequestBody)
   295  	if err != nil {
   296  		return "", errors.Wrap(err, "failed to create token request")
   297  	}
   298  	tokenRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
   299  
   300  	authToken, err := c.doTokenRequest(tokenRequest)
   301  	if err != nil {
   302  		return "", errors.Wrap(err, "failed to do token request")
   303  	}
   304  	c.authToken = authToken.AccessToken
   305  	c.authTokenValidity = time.Now().Add(time.Second * time.Duration(authToken.ExpiresInSeconds))
   306  	return c.authToken, nil
   307  }
   308  
   309  func (c *Client) doTokenRequest(tokenRequest *http.Request) (token, error) {
   310  	res, err := c.HTTPClient.Do(tokenRequest)
   311  	if err != nil {
   312  		return token{}, err
   313  	}
   314  	defer func() {
   315  		if err := res.Body.Close(); err != nil {
   316  			log.C(tokenRequest.Context()).WithError(err).Error("Unable to close token response body")
   317  		}
   318  	}()
   319  	if res.StatusCode != http.StatusOK {
   320  		return token{}, fmt.Errorf("token request failed with %s", res.Status)
   321  	}
   322  	authToken := token{}
   323  	if err := json.NewDecoder(res.Body).Decode(&authToken); err != nil {
   324  		return token{}, errors.Wrap(err, "failed to decode token")
   325  	}
   326  	return authToken, nil
   327  }
   328  
   329  // FetchDestinationSensitiveData returns sensitive data of a destination
   330  func (c *Client) FetchDestinationSensitiveData(ctx context.Context, destinationName string) ([]byte, error) {
   331  	fetchURL := fmt.Sprintf("%s%s/%s", c.authConfig.URL, c.apiConfig.EndpointFindDestination, destinationName)
   332  	req, err := http.NewRequestWithContext(ctx, http.MethodGet, fetchURL, nil)
   333  	if err != nil {
   334  		return nil, errors.Wrapf(err, "failed to build request")
   335  	}
   336  	req.Header.Set(correlation.RequestIDHeaderKey, correlation.CorrelationIDForRequest(req))
   337  	if err := c.setToken(req); err != nil {
   338  		return nil, err
   339  	}
   340  	res, err := c.sendRequestWithRetry(req)
   341  	if err != nil {
   342  		return nil, err
   343  	}
   344  	defer func() {
   345  		if err := res.Body.Close(); err != nil {
   346  			log.C(req.Context()).WithError(err).Error("Unable to close response body")
   347  		}
   348  	}()
   349  
   350  	if res.StatusCode == http.StatusNotFound {
   351  		return nil, apperrors.NewNotFoundError(resource.Destination, destinationName)
   352  	}
   353  
   354  	if res.StatusCode != http.StatusOK {
   355  		return nil, errors.Errorf("received status code %d when trying to get destination info for %s",
   356  			res.StatusCode, destinationName)
   357  	}
   358  
   359  	body, err := io.ReadAll(res.Body)
   360  
   361  	if err != nil {
   362  		return nil, errors.Wrap(err, "failed to read response body")
   363  	}
   364  
   365  	return body, nil
   366  }
   367  
   368  func (c *Client) sendRequestWithRetry(req *http.Request) (*http.Response, error) {
   369  	var response *http.Response
   370  	err := retry.Do(func() error {
   371  		res, err := c.HTTPClient.Do(req)
   372  		if err != nil {
   373  			return errors.Wrap(err, "failed to execute HTTP request")
   374  		}
   375  
   376  		if err == nil && res.StatusCode < http.StatusInternalServerError {
   377  			response = res
   378  			return nil
   379  		}
   380  
   381  		defer func() {
   382  			if err := res.Body.Close(); err != nil {
   383  				log.C(req.Context()).WithError(err).Error("Unable to close response body")
   384  			}
   385  		}()
   386  		body, err := io.ReadAll(res.Body)
   387  
   388  		if err != nil {
   389  			return errors.Wrap(err, "failed to read response body")
   390  		}
   391  		return errors.Errorf("request failed with status code %d, error message: %v", res.StatusCode, string(body))
   392  	}, retry.Attempts(c.apiConfig.RetryAttempts), retry.Delay(c.apiConfig.RetryInterval))
   393  
   394  	return response, err
   395  }