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 }