github.com/kyma-project/kyma-environment-broker@v0.0.1/internal/broker/client.go (about)

     1  package broker
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"encoding/json"
     7  	"fmt"
     8  	"io"
     9  	"net/http"
    10  	"time"
    11  
    12  	"github.com/kyma-project/kyma-environment-broker/internal"
    13  	"golang.org/x/oauth2/clientcredentials"
    14  
    15  	log "github.com/sirupsen/logrus"
    16  )
    17  
    18  const (
    19  	kymaClassID       = "47c9dcbf-ff30-448e-ab36-d3bad66ba281"
    20  	AccountCleanupJob = "accountcleanup-job"
    21  
    22  	instancesURL       = "/oauth/v2/service_instances"
    23  	deprovisionTmpl    = "%s%s/%s?service_id=%s&plan_id=%s"
    24  	updateInstanceTmpl = "%s%s/%s"
    25  	getInstanceTmpl    = "%s%s/%s"
    26  )
    27  
    28  type (
    29  	contextDTO struct {
    30  		GlobalAccountID string `json:"globalaccount_id"`
    31  		SubAccountID    string `json:"subaccount_id"`
    32  		Active          *bool  `json:"active"`
    33  	}
    34  
    35  	parametersDTO struct {
    36  		Expired *bool `json:"expired"`
    37  	}
    38  
    39  	serviceUpdatePatchDTO struct {
    40  		ServiceID  string        `json:"service_id"`
    41  		PlanID     string        `json:"plan_id"`
    42  		Context    contextDTO    `json:"context"`
    43  		Parameters parametersDTO `json:"parameters"`
    44  	}
    45  
    46  	serviceInstancesResponseDTO struct {
    47  		Operation string `json:"operation"`
    48  	}
    49  
    50  	errorResponse struct {
    51  		Error       string `json:"error"`
    52  		Description string `json:"description"`
    53  	}
    54  )
    55  
    56  type ClientConfig struct {
    57  	URL          string
    58  	TokenURL     string `envconfig:"optional"`
    59  	ClientID     string `envconfig:"optional"`
    60  	ClientSecret string `envconfig:"optional"`
    61  	Scope        string `envconfig:"optional"`
    62  }
    63  
    64  type Client struct {
    65  	brokerConfig ClientConfig
    66  	httpClient   *http.Client
    67  	poller       Poller
    68  	UserAgent    string
    69  }
    70  
    71  func NewClientConfig(URL string) *ClientConfig {
    72  	return &ClientConfig{
    73  		URL: URL,
    74  	}
    75  }
    76  
    77  func NewClient(ctx context.Context, config ClientConfig) *Client {
    78  	return NewClientWithPoller(ctx, config, NewDefaultPoller())
    79  }
    80  
    81  func NewClientWithPoller(ctx context.Context, config ClientConfig, poller Poller) *Client {
    82  	if config.TokenURL == "" {
    83  		return &Client{
    84  			brokerConfig: config,
    85  			httpClient:   http.DefaultClient,
    86  			poller:       poller,
    87  		}
    88  	}
    89  	cfg := clientcredentials.Config{
    90  		ClientID:     config.ClientID,
    91  		ClientSecret: config.ClientSecret,
    92  		TokenURL:     config.TokenURL,
    93  		Scopes:       []string{config.Scope},
    94  	}
    95  	httpClientOAuth := cfg.Client(ctx)
    96  	httpClientOAuth.Timeout = 30 * time.Second
    97  
    98  	return &Client{
    99  		brokerConfig: config,
   100  		httpClient:   httpClientOAuth,
   101  		poller:       poller,
   102  	}
   103  }
   104  
   105  // Deprovision requests Runtime deprovisioning in KEB with given details
   106  func (c *Client) Deprovision(instance internal.Instance) (string, error) {
   107  	deprovisionURL, err := c.formatDeprovisionUrl(instance)
   108  	if err != nil {
   109  		return "", err
   110  	}
   111  
   112  	response := serviceInstancesResponseDTO{}
   113  	log.Infof("Requesting deprovisioning of the environment with instance id: %q", instance.InstanceID)
   114  	err = c.poller.Invoke(func() (bool, error) {
   115  		err := c.executeRequestWithPoll(http.MethodDelete, deprovisionURL, http.StatusAccepted, nil, &response)
   116  		if err != nil {
   117  			log.Warn(fmt.Sprintf("while executing request: %s", err.Error()))
   118  			return false, nil
   119  		}
   120  		return true, nil
   121  	})
   122  
   123  	if err != nil {
   124  		return "", fmt.Errorf("while waiting for successful deprovision call: %w", err)
   125  	}
   126  
   127  	return response.Operation, nil
   128  }
   129  
   130  // SendExpirationRequest requests Runtime suspension due to expiration
   131  func (c *Client) SendExpirationRequest(instance internal.Instance) (suspensionUnderWay bool, err error) {
   132  	request, err := preparePatchRequest(instance, c.brokerConfig.URL)
   133  	if err != nil {
   134  		return false, err
   135  	}
   136  
   137  	resp, err := c.httpClient.Do(request)
   138  	if err != nil {
   139  		return false, fmt.Errorf("while executing request URL: %s for instanceID: %s: %w", request.URL,
   140  			instance.InstanceID, err)
   141  	}
   142  	defer c.warnOnError(resp.Body.Close)
   143  
   144  	return processResponse(instance.InstanceID, resp.StatusCode, resp)
   145  }
   146  
   147  func (c *Client) GetInstanceRequest(instanceID string) (response *http.Response, err error) {
   148  	request, err := prepareGetRequest(instanceID, c.brokerConfig.URL)
   149  	if err != nil {
   150  		return nil, err
   151  	}
   152  
   153  	resp, err := c.httpClient.Do(request)
   154  	if err != nil {
   155  		return nil, fmt.Errorf("while executing request URL: %s for instanceID: %s: %w", request.URL,
   156  			instanceID, err)
   157  	}
   158  	defer c.warnOnError(resp.Body.Close)
   159  
   160  	return resp, nil
   161  }
   162  
   163  func processResponse(instanceID string, statusCode int, resp *http.Response) (suspensionUnderWay bool, err error) {
   164  	switch statusCode {
   165  	case http.StatusAccepted, http.StatusOK:
   166  		{
   167  			log.Infof("Request for instanceID: %s accepted with status: %+v", instanceID, statusCode)
   168  			operation, err := decodeOperation(resp)
   169  			if err != nil {
   170  				return false, err
   171  			}
   172  			log.Infof("For instanceID: %s we received operation: %s", instanceID, operation)
   173  			return true, nil
   174  		}
   175  	case http.StatusUnprocessableEntity:
   176  		{
   177  			log.Warnf("For instanceID: %s we received entity unprocessable - status: %+v", instanceID, statusCode)
   178  			description, errorString, err := decodeErrorResponse(resp)
   179  			if err != nil {
   180  				return false, fmt.Errorf("for instanceID: %s: %w", instanceID, err)
   181  			}
   182  			log.Warnf("error: %+v description: %+v instanceID: %s", errorString, description, instanceID)
   183  			return false, nil
   184  		}
   185  	default:
   186  		{
   187  			if statusCode >= 200 && statusCode <= 299 {
   188  				return false, fmt.Errorf("for instanceID: %s we received unexpected status: %+v", instanceID, statusCode)
   189  			}
   190  			description, errorString, err := decodeErrorResponse(resp)
   191  			if err != nil {
   192  				return false, fmt.Errorf("for instanceID: %s: %w", instanceID, err)
   193  			}
   194  			return false, fmt.Errorf("error: %+v description: %+v instanceID: %s", errorString, description, instanceID)
   195  		}
   196  	}
   197  }
   198  
   199  func decodeOperation(resp *http.Response) (string, error) {
   200  	response := serviceInstancesResponseDTO{}
   201  	err := json.NewDecoder(resp.Body).Decode(&response)
   202  	if err != nil {
   203  		return "", fmt.Errorf("while decoding response body: %w", err)
   204  	}
   205  	return response.Operation, nil
   206  }
   207  
   208  func decodeErrorResponse(resp *http.Response) (string, string, error) {
   209  	response := errorResponse{}
   210  	err := json.NewDecoder(resp.Body).Decode(&response)
   211  	if err != nil {
   212  		return "", "", fmt.Errorf("while decoding error response body: %w", err)
   213  	}
   214  	return response.Description, response.Error, nil
   215  }
   216  
   217  func preparePatchRequest(instance internal.Instance, brokerConfigURL string) (*http.Request, error) {
   218  	updateInstanceUrl := fmt.Sprintf(updateInstanceTmpl, brokerConfigURL, instancesURL, instance.InstanceID)
   219  
   220  	jsonPayload, err := preparePayload(instance)
   221  	if err != nil {
   222  		return nil, fmt.Errorf("while marshaling payload for instanceID: %s: %w", instance.InstanceID, err)
   223  	}
   224  
   225  	log.Infof("Requesting expiration of the environment with instanceID: %q", instance.InstanceID)
   226  
   227  	request, err := http.NewRequest(http.MethodPatch, updateInstanceUrl, bytes.NewBuffer(jsonPayload))
   228  	if err != nil {
   229  		return nil, fmt.Errorf("while creating request for instanceID: %s: %w", instance.InstanceID, err)
   230  	}
   231  	request.Header.Set("X-Broker-API-Version", "2.14")
   232  	return request, nil
   233  }
   234  
   235  func prepareGetRequest(instanceID string, brokerConfigURL string) (*http.Request, error) {
   236  	getInstanceUrl := fmt.Sprintf(getInstanceTmpl, brokerConfigURL, instancesURL, instanceID)
   237  
   238  	request, err := http.NewRequest(http.MethodGet, getInstanceUrl, nil)
   239  	if err != nil {
   240  		return nil, fmt.Errorf("while creating GET request for instanceID: %s: %w", instanceID, err)
   241  	}
   242  	request.Header.Set("X-Broker-API-Version", "2.14")
   243  	return request, nil
   244  }
   245  
   246  func preparePayload(instance internal.Instance) ([]byte, error) {
   247  	expired := true
   248  	active := false
   249  	payload := serviceUpdatePatchDTO{
   250  		ServiceID: KymaServiceID,
   251  		PlanID:    instance.ServicePlanID,
   252  		Context: contextDTO{
   253  			GlobalAccountID: instance.SubscriptionGlobalAccountID,
   254  			SubAccountID:    instance.SubAccountID,
   255  			Active:          &active},
   256  		Parameters: parametersDTO{Expired: &expired}}
   257  	jsonPayload, err := json.Marshal(payload)
   258  	return jsonPayload, err
   259  }
   260  
   261  func (c *Client) formatDeprovisionUrl(instance internal.Instance) (string, error) {
   262  	if len(instance.ServicePlanID) == 0 {
   263  		return "", fmt.Errorf("empty ServicePlanID")
   264  	}
   265  
   266  	return fmt.Sprintf(deprovisionTmpl, c.brokerConfig.URL, instancesURL, instance.InstanceID, kymaClassID, instance.ServicePlanID), nil
   267  }
   268  
   269  func (c *Client) executeRequestWithPoll(method, url string, expectedStatus int, body io.Reader, responseBody interface{}) error {
   270  	request, err := http.NewRequest(method, url, body)
   271  	if err != nil {
   272  		return fmt.Errorf("while creating request for provisioning: %w", err)
   273  	}
   274  	request.Header.Set("X-Broker-API-Version", "2.14")
   275  	if len(c.UserAgent) != 0 {
   276  		request.Header.Set("User-Agent", c.UserAgent)
   277  	}
   278  
   279  	resp, err := c.httpClient.Do(request)
   280  	if err != nil {
   281  		return fmt.Errorf("while executing request URL: %s: %w", url, err)
   282  	}
   283  	defer c.warnOnError(resp.Body.Close)
   284  	if resp.StatusCode != expectedStatus {
   285  		return fmt.Errorf("got unexpected status code while calling Kyma Environment Broker: want: %d, got: %d",
   286  			expectedStatus, resp.StatusCode)
   287  	}
   288  
   289  	err = json.NewDecoder(resp.Body).Decode(responseBody)
   290  	if err != nil {
   291  		return fmt.Errorf("while decoding body: %w", err)
   292  	}
   293  
   294  	return nil
   295  }
   296  
   297  func (c *Client) warnOnError(do func() error) {
   298  	if err := do(); err != nil {
   299  		log.Warn(err.Error())
   300  	}
   301  }
   302  
   303  // setHttpClient auxiliary method of testing to get rid of oAuth client wrapper
   304  func (c *Client) setHttpClient(httpClient *http.Client) {
   305  	c.httpClient = httpClient
   306  }