sigs.k8s.io/external-dns@v0.14.1/provider/webhook/webhook.go (about)

     1  /*
     2  Copyright 2023 The Kubernetes Authors.
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8      http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  package webhook
    18  
    19  import (
    20  	"bytes"
    21  	"context"
    22  	"encoding/json"
    23  	"fmt"
    24  	"net/http"
    25  	"net/url"
    26  
    27  	"sigs.k8s.io/external-dns/endpoint"
    28  	"sigs.k8s.io/external-dns/plan"
    29  	webhookapi "sigs.k8s.io/external-dns/provider/webhook/api"
    30  
    31  	backoff "github.com/cenkalti/backoff/v4"
    32  	"github.com/prometheus/client_golang/prometheus"
    33  	log "github.com/sirupsen/logrus"
    34  )
    35  
    36  const (
    37  	acceptHeader = "Accept"
    38  	maxRetries   = 5
    39  )
    40  
    41  var (
    42  	recordsErrorsGauge = prometheus.NewGauge(
    43  		prometheus.GaugeOpts{
    44  			Namespace: "external_dns",
    45  			Subsystem: "webhook_provider",
    46  			Name:      "records_errors_total",
    47  			Help:      "Errors with Records method",
    48  		},
    49  	)
    50  	recordsRequestsGauge = prometheus.NewGauge(
    51  		prometheus.GaugeOpts{
    52  			Namespace: "external_dns",
    53  			Subsystem: "webhook_provider",
    54  			Name:      "records_requests_total",
    55  			Help:      "Requests with Records method",
    56  		},
    57  	)
    58  	applyChangesErrorsGauge = prometheus.NewGauge(
    59  		prometheus.GaugeOpts{
    60  			Namespace: "external_dns",
    61  			Subsystem: "webhook_provider",
    62  			Name:      "applychanges_errors_total",
    63  			Help:      "Errors with ApplyChanges method",
    64  		},
    65  	)
    66  	applyChangesRequestsGauge = prometheus.NewGauge(
    67  		prometheus.GaugeOpts{
    68  			Namespace: "external_dns",
    69  			Subsystem: "webhook_provider",
    70  			Name:      "applychanges_requests_total",
    71  			Help:      "Requests with ApplyChanges method",
    72  		},
    73  	)
    74  	adjustEndpointsErrorsGauge = prometheus.NewGauge(
    75  		prometheus.GaugeOpts{
    76  			Namespace: "external_dns",
    77  			Subsystem: "webhook_provider",
    78  			Name:      "adjustendpoints_errors_total",
    79  			Help:      "Errors with AdjustEndpoints method",
    80  		},
    81  	)
    82  	adjustEndpointsRequestsGauge = prometheus.NewGauge(
    83  		prometheus.GaugeOpts{
    84  			Namespace: "external_dns",
    85  			Subsystem: "webhook_provider",
    86  			Name:      "adjustendpoints_requests_total",
    87  			Help:      "Requests with AdjustEndpoints method",
    88  		},
    89  	)
    90  )
    91  
    92  type WebhookProvider struct {
    93  	client          *http.Client
    94  	remoteServerURL *url.URL
    95  	DomainFilter    endpoint.DomainFilter
    96  }
    97  
    98  func init() {
    99  	prometheus.MustRegister(recordsErrorsGauge)
   100  	prometheus.MustRegister(recordsRequestsGauge)
   101  	prometheus.MustRegister(applyChangesErrorsGauge)
   102  	prometheus.MustRegister(applyChangesRequestsGauge)
   103  	prometheus.MustRegister(adjustEndpointsErrorsGauge)
   104  	prometheus.MustRegister(adjustEndpointsRequestsGauge)
   105  }
   106  
   107  func NewWebhookProvider(u string) (*WebhookProvider, error) {
   108  	parsedURL, err := url.Parse(u)
   109  	if err != nil {
   110  		return nil, err
   111  	}
   112  
   113  	// negotiate API information
   114  	req, err := http.NewRequest("GET", u, nil)
   115  	if err != nil {
   116  		return nil, err
   117  	}
   118  	req.Header.Set(acceptHeader, webhookapi.MediaTypeFormatAndVersion)
   119  
   120  	client := &http.Client{}
   121  	var resp *http.Response
   122  	err = backoff.Retry(func() error {
   123  		resp, err = client.Do(req)
   124  		if err != nil {
   125  			log.Debugf("Failed to connect to plugin api: %v", err)
   126  			return err
   127  		}
   128  		// we currently only use 200 as success, but considering okay all 2XX for future usage
   129  		if resp.StatusCode >= 300 && resp.StatusCode < 500 {
   130  			return backoff.Permanent(fmt.Errorf("status code < 500"))
   131  		}
   132  		return nil
   133  	}, backoff.WithMaxRetries(backoff.NewExponentialBackOff(), maxRetries))
   134  
   135  	if err != nil {
   136  		return nil, fmt.Errorf("failed to connect to plugin api: %v", err)
   137  	}
   138  
   139  	contentType := resp.Header.Get(webhookapi.ContentTypeHeader)
   140  
   141  	// read the serialized DomainFilter from the response body and set it in the webhook provider struct
   142  	defer resp.Body.Close()
   143  
   144  	df := endpoint.DomainFilter{}
   145  	if err := json.NewDecoder(resp.Body).Decode(&df); err != nil {
   146  		return nil, fmt.Errorf("failed to unmarshal response body of DomainFilter: %v", err)
   147  	}
   148  
   149  	if contentType != webhookapi.MediaTypeFormatAndVersion {
   150  		return nil, fmt.Errorf("wrong content type returned from server: %s", contentType)
   151  	}
   152  
   153  	return &WebhookProvider{
   154  		client:          client,
   155  		remoteServerURL: parsedURL,
   156  		DomainFilter:    df,
   157  	}, nil
   158  }
   159  
   160  // Records will make a GET call to remoteServerURL/records and return the results
   161  func (p WebhookProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) {
   162  	recordsRequestsGauge.Inc()
   163  	u := p.remoteServerURL.JoinPath("records").String()
   164  
   165  	req, err := http.NewRequest("GET", u, nil)
   166  	if err != nil {
   167  		recordsErrorsGauge.Inc()
   168  		log.Debugf("Failed to create request: %s", err.Error())
   169  		return nil, err
   170  	}
   171  	req.Header.Set(acceptHeader, webhookapi.MediaTypeFormatAndVersion)
   172  	resp, err := p.client.Do(req)
   173  	if err != nil {
   174  		recordsErrorsGauge.Inc()
   175  		log.Debugf("Failed to perform request: %s", err.Error())
   176  		return nil, err
   177  	}
   178  	defer resp.Body.Close()
   179  
   180  	if resp.StatusCode != http.StatusOK {
   181  		recordsErrorsGauge.Inc()
   182  		log.Debugf("Failed to get records with code %d", resp.StatusCode)
   183  		return nil, fmt.Errorf("failed to get records with code %d", resp.StatusCode)
   184  	}
   185  
   186  	endpoints := []*endpoint.Endpoint{}
   187  	if err := json.NewDecoder(resp.Body).Decode(&endpoints); err != nil {
   188  		recordsErrorsGauge.Inc()
   189  		log.Debugf("Failed to decode response body: %s", err.Error())
   190  		return nil, err
   191  	}
   192  	return endpoints, nil
   193  }
   194  
   195  // ApplyChanges will make a POST to remoteServerURL/records with the changes
   196  func (p WebhookProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error {
   197  	applyChangesRequestsGauge.Inc()
   198  	u := p.remoteServerURL.JoinPath("records").String()
   199  
   200  	b := new(bytes.Buffer)
   201  	if err := json.NewEncoder(b).Encode(changes); err != nil {
   202  		applyChangesErrorsGauge.Inc()
   203  		log.Debugf("Failed to encode changes: %s", err.Error())
   204  		return err
   205  	}
   206  
   207  	req, err := http.NewRequest("POST", u, b)
   208  	if err != nil {
   209  		applyChangesErrorsGauge.Inc()
   210  		log.Debugf("Failed to create request: %s", err.Error())
   211  		return err
   212  	}
   213  
   214  	req.Header.Set(webhookapi.ContentTypeHeader, webhookapi.MediaTypeFormatAndVersion)
   215  
   216  	resp, err := p.client.Do(req)
   217  	if err != nil {
   218  		applyChangesErrorsGauge.Inc()
   219  		log.Debugf("Failed to perform request: %s", err.Error())
   220  		return err
   221  	}
   222  	defer resp.Body.Close()
   223  
   224  	if resp.StatusCode != http.StatusNoContent {
   225  		applyChangesErrorsGauge.Inc()
   226  		log.Debugf("Failed to apply changes with code %d", resp.StatusCode)
   227  		return fmt.Errorf("failed to apply changes with code %d", resp.StatusCode)
   228  	}
   229  	return nil
   230  }
   231  
   232  // AdjustEndpoints will call the provider doing a POST on `/adjustendpoints` which will return a list of modified endpoints
   233  // based on a provider specific requirement.
   234  // This method returns an empty slice in case there is a technical error on the provider's side so that no endpoints will be considered.
   235  func (p WebhookProvider) AdjustEndpoints(e []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) {
   236  	adjustEndpointsRequestsGauge.Inc()
   237  	endpoints := []*endpoint.Endpoint{}
   238  	u, err := url.JoinPath(p.remoteServerURL.String(), "adjustendpoints")
   239  	if err != nil {
   240  		adjustEndpointsErrorsGauge.Inc()
   241  		log.Debugf("Failed to join path, %s", err)
   242  		return nil, err
   243  	}
   244  
   245  	b := new(bytes.Buffer)
   246  	if err := json.NewEncoder(b).Encode(e); err != nil {
   247  		adjustEndpointsErrorsGauge.Inc()
   248  		log.Debugf("Failed to encode endpoints, %s", err)
   249  		return nil, err
   250  	}
   251  
   252  	req, err := http.NewRequest("POST", u, b)
   253  	if err != nil {
   254  		adjustEndpointsErrorsGauge.Inc()
   255  		log.Debugf("Failed to create new HTTP request, %s", err)
   256  		return nil, err
   257  	}
   258  
   259  	req.Header.Set(webhookapi.ContentTypeHeader, webhookapi.MediaTypeFormatAndVersion)
   260  	req.Header.Set(acceptHeader, webhookapi.MediaTypeFormatAndVersion)
   261  
   262  	resp, err := p.client.Do(req)
   263  	if err != nil {
   264  		adjustEndpointsErrorsGauge.Inc()
   265  		log.Debugf("Failed executing http request, %s", err)
   266  		return nil, err
   267  	}
   268  	defer resp.Body.Close()
   269  
   270  	if resp.StatusCode != http.StatusOK {
   271  		adjustEndpointsErrorsGauge.Inc()
   272  		log.Debugf("Failed to AdjustEndpoints with code %d", resp.StatusCode)
   273  		return nil, fmt.Errorf("failed to AdjustEndpoints with code %d", resp.StatusCode)
   274  	}
   275  
   276  	if err := json.NewDecoder(resp.Body).Decode(&endpoints); err != nil {
   277  		recordsErrorsGauge.Inc()
   278  		log.Debugf("Failed to decode response body: %s", err.Error())
   279  		return nil, err
   280  	}
   281  
   282  	return endpoints, nil
   283  }
   284  
   285  // GetDomainFilter make calls to get the serialized version of the domain filter
   286  func (p WebhookProvider) GetDomainFilter() endpoint.DomainFilter {
   287  	return p.DomainFilter
   288  }