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

     1  package ord
     2  
     3  import (
     4  	"context"
     5  	"encoding/json"
     6  	"io"
     7  	"net/http"
     8  	"net/url"
     9  	"strings"
    10  	"sync"
    11  
    12  	directorresource "github.com/kyma-incubator/compass/components/director/pkg/resource"
    13  
    14  	"github.com/kyma-incubator/compass/components/director/internal/domain/tenant"
    15  
    16  	"github.com/kyma-incubator/compass/components/director/pkg/accessstrategy"
    17  
    18  	"github.com/kyma-incubator/compass/components/director/internal/model"
    19  
    20  	httputil "github.com/kyma-incubator/compass/components/director/pkg/http"
    21  	"github.com/kyma-incubator/compass/components/director/pkg/log"
    22  
    23  	"github.com/pkg/errors"
    24  )
    25  
    26  // ClientConfig contains configuration for the ORD aggregator client
    27  type ClientConfig struct {
    28  	maxParallelDocumentsPerApplication int
    29  }
    30  
    31  // Resource represents a resource that is being aggregated. This would be an Application or Application Template
    32  type Resource struct {
    33  	Type          directorresource.Type
    34  	ID            string
    35  	ParentID      *string
    36  	Name          string
    37  	LocalTenantID *string
    38  }
    39  
    40  // NewClientConfig creates new ClientConfig from the supplied parameters
    41  func NewClientConfig(maxParallelDocumentsPerApplication int) ClientConfig {
    42  	return ClientConfig{
    43  		maxParallelDocumentsPerApplication: maxParallelDocumentsPerApplication,
    44  	}
    45  }
    46  
    47  // Client represents ORD documents client
    48  //
    49  //go:generate mockery --name=Client --output=automock --outpkg=automock --case=underscore --disable-version-string
    50  type Client interface {
    51  	FetchOpenResourceDiscoveryDocuments(ctx context.Context, resource Resource, webhook *model.Webhook) (Documents, string, error)
    52  }
    53  
    54  type client struct {
    55  	config ClientConfig
    56  	*http.Client
    57  	accessStrategyExecutorProvider accessstrategy.ExecutorProvider
    58  }
    59  
    60  // NewClient creates new ORD Client via a provided http.Client
    61  func NewClient(config ClientConfig, httpClient *http.Client, accessStrategyExecutorProvider accessstrategy.ExecutorProvider) *client {
    62  	return &client{
    63  		config:                         config,
    64  		Client:                         httpClient,
    65  		accessStrategyExecutorProvider: accessStrategyExecutorProvider,
    66  	}
    67  }
    68  
    69  // FetchOpenResourceDiscoveryDocuments fetches all the documents for a single ORD .well-known endpoint
    70  func (c *client) FetchOpenResourceDiscoveryDocuments(ctx context.Context, resource Resource, webhook *model.Webhook) (Documents, string, error) {
    71  	var tenantValue string
    72  
    73  	if needsTenantHeader := webhook.ObjectType == model.ApplicationTemplateWebhookReference && resource.Type != directorresource.ApplicationTemplate; needsTenantHeader {
    74  		tntFromCtx, err := tenant.LoadTenantPairFromContext(ctx)
    75  		if err != nil {
    76  			return nil, "", errors.Wrapf(err, "while loading tenant from context for application template webhook flow")
    77  		}
    78  
    79  		tenantValue = tntFromCtx.ExternalID
    80  	}
    81  
    82  	config, err := c.fetchConfig(ctx, resource, webhook, tenantValue)
    83  	if err != nil {
    84  		return nil, "", err
    85  	}
    86  
    87  	baseURL, err := calculateBaseURL(*webhook.URL, *config)
    88  	if err != nil {
    89  		return nil, "", errors.Wrap(err, "while calculating baseURL")
    90  	}
    91  
    92  	err = config.Validate(baseURL)
    93  	if err != nil {
    94  		return nil, "", errors.Wrap(err, "while validating ORD config")
    95  	}
    96  
    97  	docs := make([]*Document, 0)
    98  	docMutex := sync.Mutex{}
    99  	wg := sync.WaitGroup{}
   100  	workers := make(chan struct{}, c.config.maxParallelDocumentsPerApplication)
   101  	fetchDocErrors := make([]error, 0)
   102  	errMutex := sync.Mutex{}
   103  
   104  	for _, docDetails := range config.OpenResourceDiscoveryV1.Documents {
   105  		wg.Add(1)
   106  		workers <- struct{}{}
   107  		go func(docDetails DocumentDetails) {
   108  			defer func() {
   109  				wg.Done()
   110  				<-workers
   111  			}()
   112  
   113  			documentURL, err := buildDocumentURL(docDetails.URL, baseURL)
   114  			if err != nil {
   115  				log.C(ctx).Warn(errors.Wrap(err, "error building document URL").Error())
   116  				addError(&fetchDocErrors, err, &errMutex)
   117  				return
   118  			}
   119  			strategy, ok := docDetails.AccessStrategies.GetSupported()
   120  			if !ok {
   121  				log.C(ctx).Warnf("Unsupported access strategies for ORD Document %q", documentURL)
   122  			}
   123  			doc, err := c.fetchOpenDiscoveryDocumentWithAccessStrategy(ctx, documentURL, strategy, tenantValue)
   124  			if err != nil {
   125  				log.C(ctx).Warn(errors.Wrapf(err, "error fetching ORD document from: %s", documentURL).Error())
   126  				addError(&fetchDocErrors, err, &errMutex)
   127  				return
   128  			}
   129  
   130  			if docDetails.Perspective == SystemVersionPerspective {
   131  				doc.Perspective = SystemVersionPerspective
   132  			} else {
   133  				doc.Perspective = SystemInstancePerspective
   134  			}
   135  			addDocument(&docs, doc, &docMutex)
   136  		}(docDetails)
   137  	}
   138  
   139  	wg.Wait()
   140  
   141  	var fetchDocErr error = nil
   142  	if len(fetchDocErrors) > 0 {
   143  		stringErrors := convertErrorsToStrings(fetchDocErrors)
   144  		fetchDocErr = errors.Errorf(strings.Join(stringErrors, "\n"))
   145  	}
   146  	return docs, baseURL, fetchDocErr
   147  }
   148  
   149  func convertErrorsToStrings(errors []error) (result []string) {
   150  	for _, err := range errors {
   151  		result = append(result, err.Error())
   152  	}
   153  	return result
   154  }
   155  
   156  func (c *client) fetchOpenDiscoveryDocumentWithAccessStrategy(ctx context.Context, documentURL string, accessStrategy accessstrategy.Type, tenantValue string) (*Document, error) {
   157  	log.C(ctx).Infof("Fetching ORD Document %q with Access Strategy %q", documentURL, accessStrategy)
   158  	executor, err := c.accessStrategyExecutorProvider.Provide(accessStrategy)
   159  	if err != nil {
   160  		return nil, err
   161  	}
   162  
   163  	resp, err := executor.Execute(ctx, c.Client, documentURL, tenantValue)
   164  	if err != nil {
   165  		return nil, err
   166  	}
   167  
   168  	defer closeBody(ctx, resp.Body)
   169  
   170  	if resp.StatusCode != http.StatusOK {
   171  		return nil, errors.Errorf("error while fetching open resource discovery document %q: status code %d", documentURL, resp.StatusCode)
   172  	}
   173  
   174  	resp.Body = http.MaxBytesReader(nil, resp.Body, 2097152)
   175  	bodyBytes, err := io.ReadAll(resp.Body)
   176  	if err != nil {
   177  		return nil, errors.Wrap(err, "error reading document body")
   178  	}
   179  	result := &Document{}
   180  	if err := json.Unmarshal(bodyBytes, &result); err != nil {
   181  		return nil, errors.Wrap(err, "error unmarshaling document")
   182  	}
   183  	return result, nil
   184  }
   185  
   186  func closeBody(ctx context.Context, body io.ReadCloser) {
   187  	if err := body.Close(); err != nil {
   188  		log.C(ctx).WithError(err).Warnf("Got error on closing response body")
   189  	}
   190  }
   191  
   192  func addDocument(docs *[]*Document, doc *Document, mutex *sync.Mutex) {
   193  	mutex.Lock()
   194  	defer mutex.Unlock()
   195  	*docs = append(*docs, doc)
   196  }
   197  
   198  func addError(fetchDocErrors *[]error, err error, mutex *sync.Mutex) {
   199  	mutex.Lock()
   200  	defer mutex.Unlock()
   201  	*fetchDocErrors = append(*fetchDocErrors, err)
   202  }
   203  
   204  func (c *client) fetchConfig(ctx context.Context, resource Resource, webhook *model.Webhook, tenantValue string) (*WellKnownConfig, error) {
   205  	var resp *http.Response
   206  	var err error
   207  	if webhook.Auth != nil && webhook.Auth.AccessStrategy != nil && len(*webhook.Auth.AccessStrategy) > 0 {
   208  		log.C(ctx).Infof("%s %q (id = %q) ORD webhook is configured with %q access strategy.", resource.Type, resource.Name, resource.ID, *webhook.Auth.AccessStrategy)
   209  		executor, err := c.accessStrategyExecutorProvider.Provide(accessstrategy.Type(*webhook.Auth.AccessStrategy))
   210  		if err != nil {
   211  			return nil, errors.Wrapf(err, "cannot find executor for access strategy %q as part of webhook processing", *webhook.Auth.AccessStrategy)
   212  		}
   213  		resp, err = executor.Execute(ctx, c.Client, *webhook.URL, tenantValue)
   214  		if err != nil {
   215  			return nil, errors.Wrapf(err, "error while fetching open resource discovery well-known configuration with access strategy %q", *webhook.Auth.AccessStrategy)
   216  		}
   217  	} else if webhook.Auth != nil {
   218  		log.C(ctx).Infof("%s %q (id = %q) configuration endpoint is secured and webhook credentials will be used", resource.Type, resource.Name, resource.ID)
   219  		resp, err = httputil.GetRequestWithCredentials(ctx, c.Client, *webhook.URL, tenantValue, webhook.Auth)
   220  		if err != nil {
   221  			return nil, errors.Wrap(err, "error while fetching open resource discovery well-known configuration with webhook credentials")
   222  		}
   223  	} else {
   224  		log.C(ctx).Infof("%s %q (id = %q) configuration endpoint is not secured", resource.Type, resource.Name, resource.ID)
   225  		resp, err = httputil.GetRequestWithoutCredentials(c.Client, *webhook.URL, tenantValue)
   226  		if err != nil {
   227  			return nil, errors.Wrap(err, "error while fetching open resource discovery well-known configuration")
   228  		}
   229  	}
   230  
   231  	defer closeBody(ctx, resp.Body)
   232  
   233  	bodyBytes, err := io.ReadAll(resp.Body)
   234  	if err != nil {
   235  		return nil, errors.Wrap(err, "error reading response body")
   236  	}
   237  
   238  	if resp.StatusCode != http.StatusOK {
   239  		return nil, errors.Errorf("error while fetching open resource discovery well-known configuration: status code %d Body: %s", resp.StatusCode, string(bodyBytes))
   240  	}
   241  
   242  	config := WellKnownConfig{}
   243  	if err := json.Unmarshal(bodyBytes, &config); err != nil {
   244  		return nil, errors.Wrap(err, "error unmarshaling json body")
   245  	}
   246  
   247  	return &config, nil
   248  }
   249  
   250  func buildDocumentURL(docURL, baseURL string) (string, error) {
   251  	docURLParsed, err := url.Parse(docURL)
   252  	if err != nil {
   253  		return "", err
   254  	}
   255  	if docURLParsed.IsAbs() {
   256  		return docURL, nil
   257  	}
   258  	return baseURL + docURL, nil
   259  }
   260  
   261  // if webhookURL is not /well-known, but there is a valid baseURL provided in the config - use it
   262  // if webhookURL is /well-known, strip the suffix and use it as baseURL. In case both are provided - the config baseURL is used.
   263  func calculateBaseURL(webhookURL string, config WellKnownConfig) (string, error) {
   264  	if config.BaseURL != "" {
   265  		return config.BaseURL, nil
   266  	}
   267  
   268  	parsedWebhookURL, err := url.ParseRequestURI(webhookURL)
   269  	if err != nil {
   270  		return "", errors.New("error while parsing input webhook url")
   271  	}
   272  
   273  	if strings.HasSuffix(parsedWebhookURL.Path, WellKnownEndpoint) {
   274  		strippedPath := strings.ReplaceAll(parsedWebhookURL.Path, WellKnownEndpoint, "")
   275  		parsedWebhookURL.Path = strippedPath
   276  		return parsedWebhookURL.String(), nil
   277  	}
   278  	return "", nil
   279  }