mirror of
https://github.com/kubernetes-sigs/external-dns.git
synced 2025-08-05 17:16:59 +02:00
Webhook provider (#3063)
* initial plugin implementation * rename to webhook Signed-off-by: Raffaele Di Fazio <difazio.raffaele@gmail.com> * json encoder changes Signed-off-by: Raffaele Di Fazio <difazio.raffaele@gmail.com> * addressing review comments Signed-off-by: Raffaele Di Fazio <difazio.raffaele@gmail.com> * changes according to ionos review Signed-off-by: Raffaele Di Fazio <difazio.raffaele@gmail.com> * fix to accomodate changes in master Signed-off-by: Raffaele Di Fazio <difazio.raffaele@gmail.com> * fixes to accomodate master changes Signed-off-by: Raffaele Di Fazio <difazio.raffaele@gmail.com> * remove all propertyvaluesequals leftovers Signed-off-by: Raffaele Di Fazio <difazio.raffaele@gmail.com> * readd negotiation to pass the domain filter around Signed-off-by: Raffaele Di Fazio <difazio.raffaele@gmail.com> * fix domain filter passing Signed-off-by: Raffaele Di Fazio <difazio.raffaele@gmail.com> * webhook fixes Signed-off-by: Raffaele Di Fazio <difazio.raffaele@gmail.com> * fix tests Signed-off-by: Raffaele Di Fazio <difazio.raffaele@gmail.com> * fix docs Signed-off-by: Raffaele Di Fazio <difazio.raffaele@gmail.com> * docs fixes Signed-off-by: Raffaele Di Fazio <difazio.raffaele@gmail.com> * code review comments on json unmarshal Signed-off-by: Raffaele Di Fazio <difazio.raffaele@gmail.com> * handle error in adjustendpoints Signed-off-by: Raffaele Di Fazio <difazio.raffaele@gmail.com> * fix a bunch of wrong require Signed-off-by: Raffaele Di Fazio <difazio.raffaele@gmail.com> * tests and docs Signed-off-by: Raffaele Di Fazio <difazio.raffaele@gmail.com> * fix typo Signed-off-by: Raffaele Di Fazio <difazio.raffaele@gmail.com> --------- Signed-off-by: Raffaele Di Fazio <difazio.raffaele@gmail.com>
This commit is contained in:
parent
be2ae3f7e8
commit
8251b6dd85
BIN
docs/img/webhook-provider.png
Normal file
BIN
docs/img/webhook-provider.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 6.6 KiB |
44
docs/tutorials/webhook-provider.md
Normal file
44
docs/tutorials/webhook-provider.md
Normal file
@ -0,0 +1,44 @@
|
||||
# Webhook provider
|
||||
|
||||
The "Webhook" provider allows integrating ExternalDNS with DNS providers through an HTTP interface.
|
||||
The Webhook provider implements the `Provider` interface. Instead of implementing code specific to a provider, it implements an HTTP client that sends requests to an HTTP API.
|
||||
The idea behind it is that providers can be implemented in separate programs: these programs expose an HTTP API that the Webhook provider interacts with. The ideal setup for providers is to run as a sidecar in the same pod of the ExternalDNS container, listening only on localhost. This is not strictly a requirement, but we do not recommend other setups.
|
||||
|
||||
## Architectural diagram
|
||||
|
||||

|
||||
|
||||
## API guarantees
|
||||
|
||||
Providers implementing the HTTP API have to keep in sync with changes to the JSON serialization of Go types `plan.Changes`, `endpoint.Endpoint`, and `endpoint.DomainFilter`. Given the maturity of the project, we do not expect to make significant changes to those types, but can't exclude the possibility that changes will need to happen. We commit to publishing changes to those in the release notes, to ensure that providers implementing the API can keep providers up to date quickly.
|
||||
|
||||
## Implementation requirements
|
||||
|
||||
The following table represents the methods to implement mapped to their HTTP method and route.
|
||||
|
||||
| Provider method | HTTP Method | Route |
|
||||
| --- | --- | --- |
|
||||
| Records | GET | /records |
|
||||
| AdjustEndpoints | POST | /adjustendpoints |
|
||||
| ApplyChanges | POST | /records |
|
||||
|
||||
ExternalDNS will also make requests to the `/` endpoint for negotiation and for deserialization of the `DomainFilter`.
|
||||
|
||||
The server needs to respond to those requests by reading the `Accept` header and responding with a corresponding `Content-Type` header specifying the supported media type format and version.
|
||||
|
||||
**NOTE**: only `5xx` responses will be retried and only `20x` will be considered as successful. All status codes different from those will be considered a failure on ExternalDNS's side.
|
||||
|
||||
## Provider registry
|
||||
|
||||
To simplify the discovery of providers, we will accept pull requests that will add links to providers in the [README](../../README.md) file. This list will only serve the purpose of simplifying finding providers and will not constitute an official endorsement of any of the externally implemented providers unless otherwise stated.
|
||||
|
||||
## Run the AWS provider with the webhook provider.
|
||||
|
||||
To test the Webhook provider and provide a reference implementation, we added the functionality to run the AWS provider as a webhook. To run the AWS provider as a webhook, you need the following flags:
|
||||
|
||||
```yaml
|
||||
- --provider=webhook
|
||||
- --run-aws-provider-as-webhook
|
||||
```
|
||||
|
||||
What will happen behind the scenes is that the AWS provider will be be started as an HTTP server exposed only on localhost and the webhook provider will be configured to talk to it. This is the same setup that we recommend for other providers and a good way to test the Webhook provider.
|
1
go.mod
1
go.mod
@ -18,6 +18,7 @@ require (
|
||||
github.com/ans-group/sdk-go v1.16.6
|
||||
github.com/aws/aws-sdk-go v1.44.311
|
||||
github.com/bodgit/tsig v1.2.2
|
||||
github.com/cenkalti/backoff/v4 v4.2.1
|
||||
github.com/civo/civogo v0.3.42
|
||||
github.com/cloudflare/cloudflare-go v0.73.0
|
||||
github.com/cloudfoundry-community/go-cfclient v0.0.0-20190201205600-f136f9222381
|
||||
|
10
go.sum
10
go.sum
@ -175,7 +175,10 @@ github.com/bugsnag/bugsnag-go v0.0.0-20141110184014-b1d153021fcd/go.mod h1:2oa8n
|
||||
github.com/bugsnag/osext v0.0.0-20130617224835-0dd3f918b21b/go.mod h1:obH5gd0BsqsP2LwDJ9aOkm/6J86V6lyAXCoQWGw3K50=
|
||||
github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE=
|
||||
github.com/casbin/casbin/v2 v2.1.2/go.mod h1:YcPU1XXisHhLzuxH9coDNf2FbKpjGlbCg3n9yuLkIJQ=
|
||||
github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4=
|
||||
github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM=
|
||||
github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
|
||||
github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
|
||||
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
@ -348,6 +351,7 @@ github.com/go-kit/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgO
|
||||
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
|
||||
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
|
||||
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
|
||||
github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
|
||||
github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas=
|
||||
github.com/go-logr/logr v0.2.0/go.mod h1:z6/tIYblkpsD+a4lm/fGIIU9mZ+XfAiaFtq7xTgseGU=
|
||||
github.com/go-logr/logr v1.2.0/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
@ -509,6 +513,7 @@ github.com/golangplus/fmt v0.0.0-20150411045040-2a5d6d7d2995/go.mod h1:lJgMEyOkY
|
||||
github.com/golangplus/testing v0.0.0-20180327235837-af21d9c3145e/go.mod h1:0AA//k/eakGydO4jKRoRL2j92ZKSzTgj9tclaCrvXHk=
|
||||
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/btree v1.0.1/go.mod h1:xXMiIv4Fb/0kKde4SpL7qlzvu5cMJDRkFDxJfI9uaxA=
|
||||
github.com/google/gnostic v0.5.7-v3refs/go.mod h1:73MKFl6jIHelAJNaBGFzt3SPtZULs9dYrGFt8OiIsHQ=
|
||||
github.com/google/gnostic v0.6.9 h1:ZK/5VhkoX835RikCHpSUJV9a+S3e1zLh59YnyWeBW+0=
|
||||
github.com/google/gnostic v0.6.9/go.mod h1:Nm8234We1lq6iB9OmlgNv3nH91XLLVZHCDayfA3xq+E=
|
||||
@ -567,6 +572,7 @@ github.com/googleapis/gax-go/v2 v2.12.0/go.mod h1:y+aIqrI5eb1YGMVJfuV3185Ts/D7qK
|
||||
github.com/googleapis/gnostic v0.0.0-20170729233727-0c5108395e2d/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY=
|
||||
github.com/googleapis/gnostic v0.1.0/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY=
|
||||
github.com/googleapis/gnostic v0.3.1/go.mod h1:on+2t9HRStVgn95RSsFWFz+6Q0Snyqv1awfrALZdbtU=
|
||||
github.com/googleapis/gnostic v0.4.1/go.mod h1:LRhVm6pbyptWbWbuZ38d1eyptfvIytN3ir6b65WBswg=
|
||||
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
|
||||
github.com/gookit/color v1.2.3/go.mod h1:AhIE+pS6D4Ql0SQWbBeXPHw7gY0/sjHoA4s/n1KB7xg=
|
||||
github.com/gophercloud/gophercloud v0.1.0/go.mod h1:vxM41WHh5uqHVBMZHzuwNOHh8XEoIEcSTewFxm1c5g8=
|
||||
@ -686,6 +692,7 @@ github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqx
|
||||
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
|
||||
github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||
github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
@ -700,6 +707,7 @@ github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVY
|
||||
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
|
||||
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
|
||||
github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||
github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
|
||||
@ -758,6 +766,7 @@ github.com/linode/linodego v1.19.0/go.mod h1:XZFR+yJ9mm2kwf6itZ6SCpu+6w3KnIevV0U
|
||||
github.com/lithammer/dedent v1.1.0/go.mod h1:jrXYCQtgg0nJiN+StA2KgR7w6CiQNv9Fd/Z9BP0jIOc=
|
||||
github.com/lyft/protoc-gen-star v0.4.10/go.mod h1:mE8fbna26u7aEA2QCVvvfBU/ZrPgocG1206xAFPcs94=
|
||||
github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ=
|
||||
github.com/magefile/mage v1.10.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
|
||||
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
|
||||
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
@ -836,6 +845,7 @@ github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8m
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
|
||||
github.com/nats-io/jwt v0.3.0/go.mod h1:fRYCDE99xlTsqUzISS1Bi75UBJ6ljOJQOAAu5VglpSg=
|
||||
github.com/nats-io/jwt v0.3.2/go.mod h1:/euKqTS1ZD+zzjYrY7pseZrTtWQSjujC7xjPc8wL6eU=
|
||||
|
25
main.go
25
main.go
@ -80,6 +80,7 @@ import (
|
||||
"sigs.k8s.io/external-dns/provider/ultradns"
|
||||
"sigs.k8s.io/external-dns/provider/vinyldns"
|
||||
"sigs.k8s.io/external-dns/provider/vultr"
|
||||
"sigs.k8s.io/external-dns/provider/webhook"
|
||||
"sigs.k8s.io/external-dns/registry"
|
||||
"sigs.k8s.io/external-dns/source"
|
||||
)
|
||||
@ -192,7 +193,7 @@ func main() {
|
||||
zoneTagFilter := provider.NewZoneTagFilter(cfg.AWSZoneTagFilter)
|
||||
|
||||
var awsSession *session.Session
|
||||
if cfg.Provider == "aws" || cfg.Provider == "aws-sd" || cfg.Registry == "dynamodb" {
|
||||
if cfg.Provider == "aws" || cfg.Provider == "aws-sd" || cfg.Registry == "dynamodb" || cfg.RunAWSProviderAsWebhook {
|
||||
awsSession, err = aws.NewSession(
|
||||
aws.AWSSessionConfig{
|
||||
AssumeRole: cfg.AWSAssumeRole,
|
||||
@ -402,6 +403,28 @@ func main() {
|
||||
p, err = plural.NewPluralProvider(cfg.PluralCluster, cfg.PluralProvider)
|
||||
case "tencentcloud":
|
||||
p, err = tencentcloud.NewTencentCloudProvider(domainFilter, zoneIDFilter, cfg.TencentCloudConfigFile, cfg.TencentCloudZoneType, cfg.DryRun)
|
||||
case "webhook":
|
||||
startedChan := make(chan struct{})
|
||||
if cfg.RunAWSProviderAsWebhook {
|
||||
awsProvider, awsErr := aws.NewAWSProvider(aws.AWSConfig{
|
||||
DomainFilter: domainFilter,
|
||||
ZoneIDFilter: zoneIDFilter,
|
||||
ZoneTypeFilter: zoneTypeFilter,
|
||||
ZoneTagFilter: zoneTagFilter,
|
||||
BatchChangeSize: cfg.AWSBatchChangeSize,
|
||||
BatchChangeInterval: cfg.AWSBatchChangeInterval,
|
||||
EvaluateTargetHealth: cfg.AWSEvaluateTargetHealth,
|
||||
PreferCNAME: cfg.AWSPreferCNAME,
|
||||
DryRun: cfg.DryRun,
|
||||
ZoneCacheDuration: cfg.AWSZoneCacheDuration,
|
||||
}, route53.New(awsSession))
|
||||
if awsErr != nil {
|
||||
log.Fatal(awsErr)
|
||||
}
|
||||
go webhook.StartHTTPApi(awsProvider, startedChan, cfg.WebhookProviderReadTimeout, cfg.WebhookProviderWriteTimeout, "127.0.0.1:8888")
|
||||
<-startedChan
|
||||
}
|
||||
p, err = webhook.NewWebhookProvider(cfg.WebhookProviderURL)
|
||||
default:
|
||||
log.Fatalf("unknown dns provider: %s", cfg.Provider)
|
||||
}
|
||||
|
@ -209,6 +209,10 @@ type Config struct {
|
||||
PiholeTLSInsecureSkipVerify bool
|
||||
PluralCluster string
|
||||
PluralProvider string
|
||||
WebhookProviderURL string
|
||||
RunAWSProviderAsWebhook bool
|
||||
WebhookProviderReadTimeout time.Duration
|
||||
WebhookProviderWriteTimeout time.Duration
|
||||
}
|
||||
|
||||
var defaultConfig = &Config{
|
||||
@ -357,6 +361,9 @@ var defaultConfig = &Config{
|
||||
PiholeTLSInsecureSkipVerify: false,
|
||||
PluralCluster: "",
|
||||
PluralProvider: "",
|
||||
WebhookProviderURL: "http://localhost:8888",
|
||||
WebhookProviderReadTimeout: 5 * time.Second,
|
||||
WebhookProviderWriteTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
// NewConfig returns new Config object
|
||||
@ -446,7 +453,7 @@ func (cfg *Config) ParseFlags(args []string) error {
|
||||
app.Flag("exclude-target-net", "Exclude target nets (optional)").StringsVar(&cfg.ExcludeTargetNets)
|
||||
|
||||
// Flags related to providers
|
||||
providers := []string{"akamai", "alibabacloud", "aws", "aws-sd", "azure", "azure-dns", "azure-private-dns", "bluecat", "civo", "cloudflare", "coredns", "designate", "digitalocean", "dnsimple", "dyn", "exoscale", "gandi", "godaddy", "google", "ibmcloud", "infoblox", "inmemory", "linode", "ns1", "oci", "ovh", "pdns", "pihole", "plural", "rcodezero", "rdns", "rfc2136", "safedns", "scaleway", "skydns", "tencentcloud", "transip", "ultradns", "vinyldns", "vultr"}
|
||||
providers := []string{"akamai", "alibabacloud", "aws", "aws-sd", "azure", "azure-dns", "azure-private-dns", "bluecat", "civo", "cloudflare", "coredns", "designate", "digitalocean", "dnsimple", "dyn", "exoscale", "gandi", "godaddy", "google", "ibmcloud", "infoblox", "inmemory", "linode", "ns1", "oci", "ovh", "pdns", "pihole", "plural", "rcodezero", "rdns", "rfc2136", "safedns", "scaleway", "skydns", "tencentcloud", "transip", "ultradns", "vinyldns", "vultr", "webhook"}
|
||||
app.Flag("provider", "The DNS provider where the DNS records will be created (required, options: "+strings.Join(providers, ", ")+")").Required().PlaceHolder("provider").EnumVar(&cfg.Provider, providers...)
|
||||
app.Flag("domain-filter", "Limit possible target zones by a domain suffix; specify multiple times for multiple domains (optional)").Default("").StringsVar(&cfg.DomainFilter)
|
||||
app.Flag("exclude-domains", "Exclude subdomains (optional)").Default("").StringsVar(&cfg.ExcludeDomains)
|
||||
@ -602,6 +609,12 @@ func (cfg *Config) ParseFlags(args []string) error {
|
||||
app.Flag("metrics-address", "Specify where to serve the metrics and health check endpoint (default: :7979)").Default(defaultConfig.MetricsAddress).StringVar(&cfg.MetricsAddress)
|
||||
app.Flag("log-level", "Set the level of logging. (default: info, options: panic, debug, info, warning, error, fatal").Default(defaultConfig.LogLevel).EnumVar(&cfg.LogLevel, allLogLevelsAsStrings()...)
|
||||
|
||||
// Webhook provider
|
||||
app.Flag("webhook-provider-url", "[EXPERIMENTAL] The URL of the remote endpoint to call for the webhook provider (default: http://localhost:8888)").Default(defaultConfig.WebhookProviderURL).StringVar(&cfg.WebhookProviderURL)
|
||||
app.Flag("run-aws-provider-as-webhook", "[EXPERIMENTAL] When enabled, the AWS provider will be run as a webhook (default: false). To be used together with 'webhook' as provider.").BoolVar(&cfg.RunAWSProviderAsWebhook)
|
||||
app.Flag("webhook-provider-read-timeout", "[EXPERIMENTAL] The read timeout for the webhook provider in duration format (default: 5s)").Default(defaultConfig.WebhookProviderReadTimeout.String()).DurationVar(&cfg.WebhookProviderReadTimeout)
|
||||
app.Flag("webhook-provider-write-timeout", "[EXPERIMENTAL] The write timeout for the webhook provider in duration format (default: 10s)").Default(defaultConfig.WebhookProviderWriteTimeout.String()).DurationVar(&cfg.WebhookProviderWriteTimeout)
|
||||
|
||||
_, err := app.Parse(args)
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -130,6 +130,9 @@ var (
|
||||
IBMCloudConfigFile: "/etc/kubernetes/ibmcloud.json",
|
||||
TencentCloudConfigFile: "/etc/kubernetes/tencent-cloud.json",
|
||||
TencentCloudZoneType: "",
|
||||
WebhookProviderURL: "http://localhost:8888",
|
||||
WebhookProviderReadTimeout: 5 * time.Second,
|
||||
WebhookProviderWriteTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
overriddenConfig = &Config{
|
||||
@ -241,6 +244,9 @@ var (
|
||||
IBMCloudConfigFile: "ibmcloud.json",
|
||||
TencentCloudConfigFile: "tencent-cloud.json",
|
||||
TencentCloudZoneType: "private",
|
||||
WebhookProviderURL: "http://localhost:8888",
|
||||
WebhookProviderReadTimeout: 5 * time.Second,
|
||||
WebhookProviderWriteTimeout: 10 * time.Second,
|
||||
}
|
||||
)
|
||||
|
||||
|
141
provider/webhook/httpapi.go
Normal file
141
provider/webhook/httpapi.go
Normal file
@ -0,0 +1,141 @@
|
||||
/*
|
||||
Copyright 2023 The Kubernetes Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"sigs.k8s.io/external-dns/endpoint"
|
||||
"sigs.k8s.io/external-dns/plan"
|
||||
"sigs.k8s.io/external-dns/provider"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type WebhookServer struct {
|
||||
provider provider.Provider
|
||||
}
|
||||
|
||||
func (p *WebhookServer) recordsHandler(w http.ResponseWriter, req *http.Request) {
|
||||
switch req.Method {
|
||||
case http.MethodGet:
|
||||
records, err := p.provider.Records(context.Background())
|
||||
if err != nil {
|
||||
log.Errorf("Failed to get Records: %v", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set(contentTypeHeader, mediaTypeFormatAndVersion)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if err := json.NewEncoder(w).Encode(records); err != nil {
|
||||
log.Errorf("Failed to encode records: %v", err)
|
||||
}
|
||||
return
|
||||
case http.MethodPost:
|
||||
var changes plan.Changes
|
||||
if err := json.NewDecoder(req.Body).Decode(&changes); err != nil {
|
||||
log.Errorf("Failed to decode changes: %v", err)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
err := p.provider.ApplyChanges(context.Background(), &changes)
|
||||
if err != nil {
|
||||
log.Errorf("Failed to Apply Changes: %v", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
default:
|
||||
log.Errorf("Unsupported method %s", req.Method)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *WebhookServer) adjustEndpointsHandler(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
log.Errorf("Unsupported method %s", req.Method)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
pve := []*endpoint.Endpoint{}
|
||||
if err := json.NewDecoder(req.Body).Decode(&pve); err != nil {
|
||||
log.Errorf("Failed to decode in adjustEndpointsHandler: %v", err)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.Header().Set(contentTypeHeader, mediaTypeFormatAndVersion)
|
||||
pve, err := p.provider.AdjustEndpoints(pve)
|
||||
if err != nil {
|
||||
log.Errorf("Failed to call adjust endpoints: %v", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(&pve); err != nil {
|
||||
log.Errorf("Failed to encode in adjustEndpointsHandler: %v", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (p *WebhookServer) negotiateHandler(w http.ResponseWriter, req *http.Request) {
|
||||
w.Header().Set(contentTypeHeader, mediaTypeFormatAndVersion)
|
||||
json.NewEncoder(w).Encode(p.provider.GetDomainFilter())
|
||||
}
|
||||
|
||||
// StartHTTPApi starts a HTTP server given any provider.
|
||||
// the function takes an optional channel as input which is used to signal that the server has started.
|
||||
// The server will listen on port `providerPort`.
|
||||
// The server will respond to the following endpoints:
|
||||
// - / (GET): initialization, negotiates headers and returns the domain filter
|
||||
// - /records (GET): returns the current records
|
||||
// - /records (POST): applies the changes
|
||||
// - /adjustendpoints (POST): executes the AdjustEndpoints method
|
||||
func StartHTTPApi(provider provider.Provider, startedChan chan struct{}, readTimeout, writeTimeout time.Duration, providerPort string) {
|
||||
p := WebhookServer{
|
||||
provider: provider,
|
||||
}
|
||||
|
||||
m := http.NewServeMux()
|
||||
m.HandleFunc("/", p.negotiateHandler)
|
||||
m.HandleFunc("/records", p.recordsHandler)
|
||||
m.HandleFunc("/adjustendpoints", p.adjustEndpointsHandler)
|
||||
|
||||
s := &http.Server{
|
||||
Addr: providerPort,
|
||||
Handler: m,
|
||||
ReadTimeout: readTimeout,
|
||||
WriteTimeout: writeTimeout,
|
||||
}
|
||||
|
||||
l, err := net.Listen("tcp", providerPort)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if startedChan != nil {
|
||||
startedChan <- struct{}{}
|
||||
}
|
||||
|
||||
if err := s.Serve(l); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
278
provider/webhook/httpapi_test.go
Normal file
278
provider/webhook/httpapi_test.go
Normal file
@ -0,0 +1,278 @@
|
||||
/*
|
||||
Copyright 2023 The Kubernetes Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"sigs.k8s.io/external-dns/endpoint"
|
||||
"sigs.k8s.io/external-dns/plan"
|
||||
)
|
||||
|
||||
var records []*endpoint.Endpoint
|
||||
|
||||
type FakeWebhookProvider struct {
|
||||
err error
|
||||
domainFilter endpoint.DomainFilter
|
||||
}
|
||||
|
||||
func (p FakeWebhookProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) {
|
||||
if p.err != nil {
|
||||
return nil, p.err
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func (p FakeWebhookProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error {
|
||||
if p.err != nil {
|
||||
return p.err
|
||||
}
|
||||
records = append(records, changes.Create...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p FakeWebhookProvider) AdjustEndpoints(endpoints []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) {
|
||||
// for simplicity, we do not adjust endpoints in this test
|
||||
if p.err != nil {
|
||||
return nil, p.err
|
||||
}
|
||||
return endpoints, nil
|
||||
}
|
||||
|
||||
func (p FakeWebhookProvider) GetDomainFilter() endpoint.DomainFilter {
|
||||
return p.domainFilter
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
records = []*endpoint.Endpoint{
|
||||
{
|
||||
DNSName: "foo.bar.com",
|
||||
RecordType: "A",
|
||||
},
|
||||
}
|
||||
m.Run()
|
||||
}
|
||||
|
||||
func TestRecordsHandlerRecords(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/records", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
providerAPIServer := &WebhookServer{
|
||||
provider: &FakeWebhookProvider{
|
||||
domainFilter: endpoint.NewDomainFilter([]string{"foo.bar.com"}),
|
||||
},
|
||||
}
|
||||
providerAPIServer.recordsHandler(w, req)
|
||||
res := w.Result()
|
||||
require.Equal(t, http.StatusOK, res.StatusCode)
|
||||
// require that the res has the same endpoints as the records slice
|
||||
defer res.Body.Close()
|
||||
require.NotNil(t, res.Body)
|
||||
endpoints := []*endpoint.Endpoint{}
|
||||
if err := json.NewDecoder(res.Body).Decode(&endpoints); err != nil {
|
||||
t.Errorf("Failed to decode response body: %s", err.Error())
|
||||
}
|
||||
require.Equal(t, records, endpoints)
|
||||
}
|
||||
|
||||
func TestRecordsHandlerRecordsWithErrors(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/records", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
providerAPIServer := &WebhookServer{
|
||||
provider: &FakeWebhookProvider{
|
||||
err: fmt.Errorf("error"),
|
||||
},
|
||||
}
|
||||
providerAPIServer.recordsHandler(w, req)
|
||||
res := w.Result()
|
||||
require.Equal(t, http.StatusInternalServerError, res.StatusCode)
|
||||
}
|
||||
|
||||
func TestRecordsHandlerApplyChangesWithBadRequest(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/applychanges", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
providerAPIServer := &WebhookServer{
|
||||
provider: &FakeWebhookProvider{},
|
||||
}
|
||||
providerAPIServer.recordsHandler(w, req)
|
||||
res := w.Result()
|
||||
require.Equal(t, http.StatusBadRequest, res.StatusCode)
|
||||
}
|
||||
|
||||
func TestRecordsHandlerApplyChangesWithValidRequest(t *testing.T) {
|
||||
changes := &plan.Changes{
|
||||
Create: []*endpoint.Endpoint{
|
||||
{
|
||||
DNSName: "foo.bar.com",
|
||||
RecordType: "A",
|
||||
Targets: endpoint.Targets{},
|
||||
},
|
||||
},
|
||||
}
|
||||
j, err := json.Marshal(changes)
|
||||
require.NoError(t, err)
|
||||
|
||||
reader := bytes.NewReader(j)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/applychanges", reader)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
providerAPIServer := &WebhookServer{
|
||||
provider: &FakeWebhookProvider{},
|
||||
}
|
||||
providerAPIServer.recordsHandler(w, req)
|
||||
res := w.Result()
|
||||
require.Equal(t, http.StatusNoContent, res.StatusCode)
|
||||
}
|
||||
|
||||
func TestRecordsHandlerApplyChangesWithErrors(t *testing.T) {
|
||||
changes := &plan.Changes{
|
||||
Create: []*endpoint.Endpoint{
|
||||
{
|
||||
DNSName: "foo.bar.com",
|
||||
RecordType: "A",
|
||||
Targets: endpoint.Targets{},
|
||||
},
|
||||
},
|
||||
}
|
||||
j, err := json.Marshal(changes)
|
||||
require.NoError(t, err)
|
||||
|
||||
reader := bytes.NewReader(j)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/applychanges", reader)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
providerAPIServer := &WebhookServer{
|
||||
provider: &FakeWebhookProvider{
|
||||
err: fmt.Errorf("error"),
|
||||
},
|
||||
}
|
||||
providerAPIServer.recordsHandler(w, req)
|
||||
res := w.Result()
|
||||
require.Equal(t, http.StatusInternalServerError, res.StatusCode)
|
||||
}
|
||||
|
||||
func TestRecordsHandlerWithWrongHTTPMethod(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPut, "/records", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
providerAPIServer := &WebhookServer{
|
||||
provider: &FakeWebhookProvider{},
|
||||
}
|
||||
providerAPIServer.recordsHandler(w, req)
|
||||
res := w.Result()
|
||||
require.Equal(t, http.StatusBadRequest, res.StatusCode)
|
||||
}
|
||||
|
||||
func TestAdjustEndpointsHandlerWithInvalidRequest(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/adjustendpoints", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
providerAPIServer := &WebhookServer{
|
||||
provider: &FakeWebhookProvider{},
|
||||
}
|
||||
providerAPIServer.adjustEndpointsHandler(w, req)
|
||||
res := w.Result()
|
||||
require.Equal(t, http.StatusBadRequest, res.StatusCode)
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/adjustendpoints", nil)
|
||||
|
||||
providerAPIServer.adjustEndpointsHandler(w, req)
|
||||
res = w.Result()
|
||||
require.Equal(t, http.StatusBadRequest, res.StatusCode)
|
||||
}
|
||||
|
||||
func TestAdjustEndpointsHandlerWithValidRequest(t *testing.T) {
|
||||
pve := []*endpoint.Endpoint{
|
||||
{
|
||||
DNSName: "foo.bar.com",
|
||||
RecordType: "A",
|
||||
Targets: endpoint.Targets{},
|
||||
RecordTTL: 0,
|
||||
},
|
||||
}
|
||||
|
||||
j, err := json.Marshal(pve)
|
||||
require.NoError(t, err)
|
||||
|
||||
reader := bytes.NewReader(j)
|
||||
req := httptest.NewRequest(http.MethodPost, "/adjustendpoints", reader)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
providerAPIServer := &WebhookServer{
|
||||
provider: &FakeWebhookProvider{},
|
||||
}
|
||||
providerAPIServer.adjustEndpointsHandler(w, req)
|
||||
res := w.Result()
|
||||
require.Equal(t, http.StatusOK, res.StatusCode)
|
||||
require.NotNil(t, res.Body)
|
||||
}
|
||||
|
||||
func TestAdjustEndpointsHandlerWithError(t *testing.T) {
|
||||
pve := []*endpoint.Endpoint{
|
||||
{
|
||||
DNSName: "foo.bar.com",
|
||||
RecordType: "A",
|
||||
Targets: endpoint.Targets{},
|
||||
RecordTTL: 0,
|
||||
},
|
||||
}
|
||||
|
||||
j, err := json.Marshal(pve)
|
||||
require.NoError(t, err)
|
||||
|
||||
reader := bytes.NewReader(j)
|
||||
req := httptest.NewRequest(http.MethodPost, "/adjustendpoints", reader)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
providerAPIServer := &WebhookServer{
|
||||
provider: &FakeWebhookProvider{
|
||||
err: fmt.Errorf("error"),
|
||||
},
|
||||
}
|
||||
providerAPIServer.adjustEndpointsHandler(w, req)
|
||||
res := w.Result()
|
||||
require.Equal(t, http.StatusInternalServerError, res.StatusCode)
|
||||
require.NotNil(t, res.Body)
|
||||
}
|
||||
|
||||
func TestStartHTTPApi(t *testing.T) {
|
||||
startedChan := make(chan struct{})
|
||||
go StartHTTPApi(FakeWebhookProvider{}, startedChan, 5*time.Second, 10*time.Second, "127.0.0.1:8887")
|
||||
<-startedChan
|
||||
resp, err := http.Get("http://127.0.0.1:8887")
|
||||
require.NoError(t, err)
|
||||
// check that resp has a valid domain filter
|
||||
defer resp.Body.Close()
|
||||
|
||||
df := endpoint.DomainFilter{}
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, df.UnmarshalJSON(b))
|
||||
}
|
258
provider/webhook/webhook.go
Normal file
258
provider/webhook/webhook.go
Normal file
@ -0,0 +1,258 @@
|
||||
/*
|
||||
Copyright 2023 The Kubernetes Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"sigs.k8s.io/external-dns/endpoint"
|
||||
"sigs.k8s.io/external-dns/plan"
|
||||
|
||||
backoff "github.com/cenkalti/backoff/v4"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
mediaTypeFormatAndVersion = "application/external.dns.webhook+json;version=1"
|
||||
contentTypeHeader = "Content-Type"
|
||||
acceptHeader = "Accept"
|
||||
maxRetries = 5
|
||||
)
|
||||
|
||||
var (
|
||||
recordsErrorsGauge = prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Namespace: "external_dns",
|
||||
Subsystem: "webhook_provider",
|
||||
Name: "records_errors",
|
||||
Help: "Errors with Records method",
|
||||
},
|
||||
)
|
||||
applyChangesErrorsGauge = prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Namespace: "external_dns",
|
||||
Subsystem: "webhook_provider",
|
||||
Name: "applychanges_errors",
|
||||
Help: "Errors with ApplyChanges method",
|
||||
},
|
||||
)
|
||||
adjustEndpointsErrorsGauge = prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Namespace: "external_dns",
|
||||
Subsystem: "webhook_provider",
|
||||
Name: "adjustendpointsgauge_errors",
|
||||
Help: "Errors with AdjustEndpoints method",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
type WebhookProvider struct {
|
||||
client *http.Client
|
||||
remoteServerURL *url.URL
|
||||
DomainFilter endpoint.DomainFilter
|
||||
}
|
||||
|
||||
func init() {
|
||||
prometheus.MustRegister(recordsErrorsGauge)
|
||||
prometheus.MustRegister(applyChangesErrorsGauge)
|
||||
prometheus.MustRegister(adjustEndpointsErrorsGauge)
|
||||
}
|
||||
|
||||
func NewWebhookProvider(u string) (*WebhookProvider, error) {
|
||||
parsedURL, err := url.Parse(u)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// negotiate API information
|
||||
req, err := http.NewRequest("GET", u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set(acceptHeader, mediaTypeFormatAndVersion)
|
||||
|
||||
client := &http.Client{}
|
||||
var resp *http.Response
|
||||
err = backoff.Retry(func() error {
|
||||
resp, err = client.Do(req)
|
||||
if err != nil {
|
||||
log.Debugf("Failed to connect to plugin api: %v", err)
|
||||
return err
|
||||
}
|
||||
// we currently only use 200 as success, but considering okay all 2XX for future usage
|
||||
if resp.StatusCode >= 300 && resp.StatusCode < 500 {
|
||||
return backoff.Permanent(fmt.Errorf("status code < 500"))
|
||||
}
|
||||
return nil
|
||||
}, backoff.WithMaxRetries(backoff.NewExponentialBackOff(), maxRetries))
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to plugin api: %v", err)
|
||||
}
|
||||
|
||||
contentType := resp.Header.Get(contentTypeHeader)
|
||||
|
||||
// read the serialized DomainFilter from the response body and set it in the webhook provider struct
|
||||
defer resp.Body.Close()
|
||||
|
||||
df := endpoint.DomainFilter{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&df); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal response body of DomainFilter: %v", err)
|
||||
}
|
||||
|
||||
if contentType != mediaTypeFormatAndVersion {
|
||||
return nil, fmt.Errorf("wrong content type returned from server: %s", contentType)
|
||||
}
|
||||
|
||||
return &WebhookProvider{
|
||||
client: client,
|
||||
remoteServerURL: parsedURL,
|
||||
DomainFilter: df,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Records will make a GET call to remoteServerURL/records and return the results
|
||||
func (p WebhookProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) {
|
||||
u := p.remoteServerURL.JoinPath("records").String()
|
||||
req, err := http.NewRequest("GET", u, nil)
|
||||
if err != nil {
|
||||
recordsErrorsGauge.Inc()
|
||||
log.Debugf("Failed to create request: %s", err.Error())
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set(acceptHeader, mediaTypeFormatAndVersion)
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
recordsErrorsGauge.Inc()
|
||||
log.Debugf("Failed to perform request: %s", err.Error())
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
recordsErrorsGauge.Inc()
|
||||
log.Debugf("Failed to get records with code %d", resp.StatusCode)
|
||||
return nil, fmt.Errorf("failed to get records with code %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
endpoints := []*endpoint.Endpoint{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&endpoints); err != nil {
|
||||
recordsErrorsGauge.Inc()
|
||||
log.Debugf("Failed to decode response body: %s", err.Error())
|
||||
return nil, err
|
||||
}
|
||||
return endpoints, nil
|
||||
}
|
||||
|
||||
// ApplyChanges will make a POST to remoteServerURL/records with the changes
|
||||
func (p WebhookProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error {
|
||||
u := p.remoteServerURL.JoinPath("records").String()
|
||||
|
||||
b := new(bytes.Buffer)
|
||||
if err := json.NewEncoder(b).Encode(changes); err != nil {
|
||||
applyChangesErrorsGauge.Inc()
|
||||
log.Debugf("Failed to encode changes: %s", err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", u, b)
|
||||
if err != nil {
|
||||
applyChangesErrorsGauge.Inc()
|
||||
log.Debugf("Failed to create request: %s", err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set(contentTypeHeader, mediaTypeFormatAndVersion)
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
applyChangesErrorsGauge.Inc()
|
||||
log.Debugf("Failed to perform request: %s", err.Error())
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
applyChangesErrorsGauge.Inc()
|
||||
log.Debugf("Failed to apply changes with code %d", resp.StatusCode)
|
||||
return fmt.Errorf("failed to apply changes with code %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AdjustEndpoints will call the provider doing a POST on `/adjustendpoints` which will return a list of modified endpoints
|
||||
// based on a provider specific requirement.
|
||||
// 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.
|
||||
func (p WebhookProvider) AdjustEndpoints(e []*endpoint.Endpoint) ([]*endpoint.Endpoint, error) {
|
||||
endpoints := []*endpoint.Endpoint{}
|
||||
u, err := url.JoinPath(p.remoteServerURL.String(), "adjustendpoints")
|
||||
if err != nil {
|
||||
adjustEndpointsErrorsGauge.Inc()
|
||||
log.Debugf("Failed to join path, %s", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
b := new(bytes.Buffer)
|
||||
if err := json.NewEncoder(b).Encode(e); err != nil {
|
||||
adjustEndpointsErrorsGauge.Inc()
|
||||
log.Debugf("Failed to encode endpoints, %s", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", u, b)
|
||||
if err != nil {
|
||||
adjustEndpointsErrorsGauge.Inc()
|
||||
log.Debugf("Failed to create new HTTP request, %s", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set(contentTypeHeader, mediaTypeFormatAndVersion)
|
||||
req.Header.Set(acceptHeader, mediaTypeFormatAndVersion)
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
adjustEndpointsErrorsGauge.Inc()
|
||||
log.Debugf("Failed executing http request, %s", err)
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
adjustEndpointsErrorsGauge.Inc()
|
||||
log.Debugf("Failed to AdjustEndpoints with code %d", resp.StatusCode)
|
||||
return nil, fmt.Errorf("failed to AdjustEndpoints with code %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&endpoints); err != nil {
|
||||
recordsErrorsGauge.Inc()
|
||||
log.Debugf("Failed to decode response body: %s", err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return endpoints, nil
|
||||
}
|
||||
|
||||
// GetDomainFilter make calls to get the serialized version of the domain filter
|
||||
func (p WebhookProvider) GetDomainFilter() endpoint.DomainFilter {
|
||||
return p.DomainFilter
|
||||
}
|
214
provider/webhook/webhook_test.go
Normal file
214
provider/webhook/webhook_test.go
Normal file
@ -0,0 +1,214 @@
|
||||
/*
|
||||
Copyright 2023 The Kubernetes Authors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"sigs.k8s.io/external-dns/endpoint"
|
||||
)
|
||||
|
||||
func TestInvalidDomainFilter(t *testing.T) {
|
||||
svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/" {
|
||||
w.Header().Set(contentTypeHeader, mediaTypeFormatAndVersion)
|
||||
w.WriteHeader(200)
|
||||
return
|
||||
}
|
||||
w.Write([]byte(`[{
|
||||
"dnsName" : "test.example.com"
|
||||
}]`))
|
||||
}))
|
||||
defer svr.Close()
|
||||
|
||||
_, err := NewWebhookProvider(svr.URL)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestValidDomainfilter(t *testing.T) {
|
||||
// initialize domain filter
|
||||
domainFilter := endpoint.NewDomainFilter([]string{"example.com"})
|
||||
svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/" {
|
||||
w.Header().Set(contentTypeHeader, mediaTypeFormatAndVersion)
|
||||
json.NewEncoder(w).Encode(domainFilter)
|
||||
return
|
||||
}
|
||||
}))
|
||||
defer svr.Close()
|
||||
|
||||
p, err := NewWebhookProvider(svr.URL)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, p.GetDomainFilter(), endpoint.NewDomainFilter([]string{"example.com"}))
|
||||
}
|
||||
|
||||
func TestRecords(t *testing.T) {
|
||||
svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/" {
|
||||
w.Header().Set(contentTypeHeader, mediaTypeFormatAndVersion)
|
||||
w.Write([]byte(`{}`))
|
||||
return
|
||||
}
|
||||
require.Equal(t, "/records", r.URL.Path)
|
||||
w.Write([]byte(`[{
|
||||
"dnsName" : "test.example.com"
|
||||
}]`))
|
||||
}))
|
||||
defer svr.Close()
|
||||
|
||||
provider, err := NewWebhookProvider(svr.URL)
|
||||
require.NoError(t, err)
|
||||
endpoints, err := provider.Records(context.TODO())
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, endpoints)
|
||||
require.Equal(t, []*endpoint.Endpoint{{
|
||||
DNSName: "test.example.com",
|
||||
}}, endpoints)
|
||||
}
|
||||
|
||||
func TestRecordsWithErrors(t *testing.T) {
|
||||
svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/" {
|
||||
w.Header().Set(contentTypeHeader, mediaTypeFormatAndVersion)
|
||||
w.Write([]byte(`{}`))
|
||||
return
|
||||
}
|
||||
require.Equal(t, "/records", r.URL.Path)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer svr.Close()
|
||||
|
||||
provider, err := NewWebhookProvider(svr.URL)
|
||||
require.NoError(t, err)
|
||||
_, err = provider.Records(context.Background())
|
||||
require.NotNil(t, err)
|
||||
}
|
||||
|
||||
func TestApplyChanges(t *testing.T) {
|
||||
successfulApplyChanges := true
|
||||
svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/" {
|
||||
w.Header().Set(contentTypeHeader, mediaTypeFormatAndVersion)
|
||||
w.Write([]byte(`{}`))
|
||||
return
|
||||
}
|
||||
require.Equal(t, "/records", r.URL.Path)
|
||||
if successfulApplyChanges {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
}))
|
||||
defer svr.Close()
|
||||
|
||||
provider, err := NewWebhookProvider(svr.URL)
|
||||
require.NoError(t, err)
|
||||
err = provider.ApplyChanges(context.TODO(), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
successfulApplyChanges = false
|
||||
|
||||
err = provider.ApplyChanges(context.TODO(), nil)
|
||||
require.NotNil(t, err)
|
||||
}
|
||||
|
||||
func TestAdjustEndpoints(t *testing.T) {
|
||||
svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/" {
|
||||
w.Header().Set(contentTypeHeader, mediaTypeFormatAndVersion)
|
||||
w.Write([]byte(`{}`))
|
||||
return
|
||||
}
|
||||
require.Equal(t, "/adjustendpoints", r.URL.Path)
|
||||
|
||||
var endpoints []*endpoint.Endpoint
|
||||
defer r.Body.Close()
|
||||
b, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = json.Unmarshal(b, &endpoints)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, e := range endpoints {
|
||||
e.RecordTTL = 0
|
||||
}
|
||||
j, _ := json.Marshal(endpoints)
|
||||
w.Write(j)
|
||||
|
||||
}))
|
||||
defer svr.Close()
|
||||
|
||||
provider, err := NewWebhookProvider(svr.URL)
|
||||
require.NoError(t, err)
|
||||
endpoints := []*endpoint.Endpoint{
|
||||
{
|
||||
DNSName: "test.example.com",
|
||||
RecordTTL: 10,
|
||||
RecordType: "A",
|
||||
Targets: endpoint.Targets{
|
||||
"",
|
||||
},
|
||||
},
|
||||
}
|
||||
adjustedEndpoints, err := provider.AdjustEndpoints(endpoints)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []*endpoint.Endpoint{{
|
||||
DNSName: "test.example.com",
|
||||
RecordTTL: 0,
|
||||
RecordType: "A",
|
||||
Targets: endpoint.Targets{
|
||||
"",
|
||||
},
|
||||
}}, adjustedEndpoints)
|
||||
}
|
||||
|
||||
func TestAdjustendpointsWithError(t *testing.T) {
|
||||
svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/" {
|
||||
w.Header().Set(contentTypeHeader, mediaTypeFormatAndVersion)
|
||||
w.Write([]byte(`{}`))
|
||||
return
|
||||
}
|
||||
require.Equal(t, "/adjustendpoints", r.URL.Path)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer svr.Close()
|
||||
|
||||
provider, err := NewWebhookProvider(svr.URL)
|
||||
require.NoError(t, err)
|
||||
endpoints := []*endpoint.Endpoint{
|
||||
{
|
||||
DNSName: "test.example.com",
|
||||
RecordTTL: 10,
|
||||
RecordType: "A",
|
||||
Targets: endpoint.Targets{
|
||||
"",
|
||||
},
|
||||
},
|
||||
}
|
||||
_, err = provider.AdjustEndpoints(endpoints)
|
||||
require.Error(t, err)
|
||||
}
|
Loading…
Reference in New Issue
Block a user