From e20aea4d5fb88f0761ca7a6f56c437567e33b52b Mon Sep 17 00:00:00 2001 From: Tim Curless Date: Thu, 2 Jan 2020 13:55:59 -0600 Subject: [PATCH] Add Initial BlueCat Provider Support The new BlueCat provider uses the BlueCat API Gateway(REST API). Not the legacy XML based BlueCat API. https://github.com/bluecatlabs/gateway-workflows --- README.md | 2 + docs/tutorials/bluecat.md | 64 ++ go.sum | 20 +- main.go | 3 + pkg/apis/externaldns/types.go | 5 +- pkg/apis/externaldns/types_test.go | 4 + provider/bluecat/OWNERS | 6 + provider/bluecat/bluecat.go | 969 +++++++++++++++++++++++++++++ provider/bluecat/bluecat_test.go | 390 ++++++++++++ 9 files changed, 1446 insertions(+), 17 deletions(-) create mode 100644 docs/tutorials/bluecat.md create mode 100644 provider/bluecat/OWNERS create mode 100644 provider/bluecat/bluecat.go create mode 100644 provider/bluecat/bluecat_test.go diff --git a/README.md b/README.md index b56a395ba3..0155aa3352 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ ExternalDNS' current release is `v0.7`. This version allows you to keep selected * [AWS Route 53](https://aws.amazon.com/route53/) * [AWS Cloud Map](https://docs.aws.amazon.com/cloud-map/) * [AzureDNS](https://azure.microsoft.com/en-us/services/dns) +* [BlueCat](https://bluecatnetworks.com) * [CloudFlare](https://www.cloudflare.com/dns) * [RcodeZero](https://www.rcodezero.at/) * [DigitalOcean](https://www.digitalocean.com/products/networking) @@ -82,6 +83,7 @@ The following table clarifies the current status of the providers according to t | AWS Cloud Map | Beta | | | Akamai Edge DNS | Beta | | | AzureDNS | Beta | | +| BlueCat | Alpha | @seanmalloy @vinny-sabatini | | CloudFlare | Beta | | | RcodeZero | Alpha | | | DigitalOcean | Alpha | | diff --git a/docs/tutorials/bluecat.md b/docs/tutorials/bluecat.md new file mode 100644 index 0000000000..b2150b9b7a --- /dev/null +++ b/docs/tutorials/bluecat.md @@ -0,0 +1,64 @@ +# Setting up external-dns for BlueCat + +## Prerequisites +Install the BlueCat Gateway product and deploy the [community gateway workflows](https://github.com/bluecatlabs/gateway-workflows). + +## Deploy +Setup configuration file as k8s `Secret`. +``` +cat << EOF > ~/bluecat.json +{ + "gatewayHost": "https://bluecatgw.example.com", + "gatewayUsername": "user", + "GatewayPassword": "pass", + "dnsConfiguration": "Example", + "dnsView": "Internal", + "rootZone": "example.com" +} +EOF +kubectl create secret generic bluecatconfig --from-file ~/bluecat.json -n bluecat-example +``` + +Setup up deployment/service account: +``` +apiVersion: v1 +kind: ServiceAccount +metadata: + name: external-dns + namespace: bluecat-example +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: external-dns + namespace: bluecat-example +spec: + selector: + matchLabels: + app: external-dns + strategy: + type: Recreate + template: + metadata: + labels: + app: external-dns + spec: + serviceAccountName: external-dns + volumes: + - name: bluecatconfig + secret: + secretName: bluecatconfig + containers: + - name: external-dns + image: k8s.gcr.io/external-dns/external-dns:$TAG # no released versions include the bluecat provider yet + volumeMounts: + - name: bluecatconfig + mountPath: "/etc/external-dns/" + readOnly: true + args: + - --log-level=debug + - --source=service + - --provider=bluecat + - --txt-owner-id=bluecat-example + - --bluecat-config-file=/etc/external-dns/bluecat.json +``` diff --git a/go.sum b/go.sum index 23127e8a73..8a5a6e67b1 100644 --- a/go.sum +++ b/go.sum @@ -92,6 +92,7 @@ github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuy github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4 h1:Hs82Z41s6SdL1CELW+XaDYmOH4hkBN4/N9og/AsOv7E= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alexbrainman/sspi v0.0.0-20180613141037-e580b900e9f5 h1:P5U+E4x5OkVEKQDklVPmzs71WM56RTTRqV4OrDC//Y4= github.com/alexbrainman/sspi v0.0.0-20180613141037-e580b900e9f5/go.mod h1:976q2ETgjT2snVCf2ZaBnyBbVoPERGjUz+0sofzEfro= github.com/aliyun/alibaba-cloud-sdk-go v1.61.357 h1:3ynCSeUh9OtJLd/OzLapM1DLDv2g+0yyDdkLqSfZCaQ= github.com/aliyun/alibaba-cloud-sdk-go v1.61.357/go.mod h1:pUKYbK5JQ+1Dfxk80P0qxGqe5dkxDoabbZS7zOcouyA= @@ -127,8 +128,6 @@ github.com/blang/semver v3.5.0+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnweb github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= github.com/bodgit/tsig v0.0.2 h1:seNt23SrPW8dkWoyRYzdeuqFEzr+lDc0dAJvo94xB8U= github.com/bodgit/tsig v0.0.2/go.mod h1:0mYe0t9it36SOvDQyeFekc7bLtvljFz7H9vHS/nYbgc= -github.com/bodgit/tsig v1.1.1 h1:SViReRa8KyaweqdJ3ojdYqIE3xDyJlR3G+6wAsSbLCo= -github.com/bodgit/tsig v1.1.1/go.mod h1:8LZ3Mn7AVZHH8GN2ArvzB7msHfLjoptWsdPEJRSw/uo= github.com/bshuster-repo/logrus-logstash-hook v0.4.1/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk= github.com/bugsnag/bugsnag-go v0.0.0-20141110184014-b1d153021fcd/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8= github.com/bugsnag/osext v0.0.0-20130617224835-0dd3f918b21b/go.mod h1:obH5gd0BsqsP2LwDJ9aOkm/6J86V6lyAXCoQWGw3K50= @@ -427,7 +426,9 @@ github.com/gorilla/mux v1.7.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2z github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.7.4 h1:VuZ8uybHlWmqV03+zRzdwKL4tUnIp1MAQtp1mIFE1bc= github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.2.0 h1:S7P+1Hm5V/AT9cjEcUD5uDaQSX0OE577aCXgoaKpYbQ= github.com/gorilla/sessions v1.2.0/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= @@ -510,8 +511,6 @@ github.com/jcmturner/gokrb5/v8 v8.4.1/go.mod h1:T1hnNppQsBtxW0tCHMHTkAt8n/sABdzZ github.com/jcmturner/rpc/v2 v2.0.2 h1:gMB4IwRXYsWw4Bc6o/az2HJgFUA1ffSh90i26ZJ6Xl0= github.com/jcmturner/rpc/v2 v2.0.2/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= -github.com/jinzhu/copier v0.1.0 h1:Vh8xALtH3rrKGB/XIRe5d0yCTHPZFauWPLvdpDAbi88= -github.com/jinzhu/copier v0.1.0/go.mod h1:24xnZezI2Yqac9J61UC6/dG/k76ttpq0DdJI3QmUvro= github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= github.com/jmespath/go-jmespath v0.0.0-20160803190731-bd40a432e4c7/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= @@ -599,8 +598,6 @@ github.com/maxatome/go-testdeep v1.4.0/go.mod h1:011SgQ6efzZYAen6fDn4BqQ+lUR72ys github.com/mholt/archiver/v3 v3.3.0/go.mod h1:YnQtqsp+94Rwd0D/rk5cnLrxusUBUXg+08Ebtr1Mqao= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/miekg/dns v1.1.6/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= -github.com/miekg/dns v1.1.30 h1:Qww6FseFn8PRfw07jueqIXqodm0JKiiKuK0DeXSqfyo= -github.com/miekg/dns v1.1.30/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM= github.com/miekg/dns v1.1.31/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM= github.com/miekg/dns v1.1.36-0.20210109083720-731b191cabd1 h1:kZZmnTeY2r+88mDNCVV/uCXL2gG3rkVPTN9jcYfGQcI= github.com/miekg/dns v1.1.36-0.20210109083720-731b191cabd1/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM= @@ -690,6 +687,7 @@ github.com/openshift/api v0.0.0-20200605231317-fb2a6ca106ae/go.mod h1:l6TGeqJ92D github.com/openshift/build-machinery-go v0.0.0-20200424080330-082bf86082cc/go.mod h1:1CkcsT3aVebzRBzVTSbiKSkJMsC/CASqxesfqEMfJEc= github.com/openshift/client-go v0.0.0-20200608144219-584632b8fc73 h1:JePLt9EpNLF/30KsSsArrzxGWPaUIvYUt8Fwnw9wlgM= github.com/openshift/client-go v0.0.0-20200608144219-584632b8fc73/go.mod h1:+66gk3dEqw9e+WoiXjJFzWlS1KGhj9ZRHi/RI/YG/ZM= +github.com/openshift/gssapi v0.0.0-20161010215902-5fb4217df13b h1:it0YPE/evO6/m8t8wxis9KFI2F/aleOKsI6d9uz0cEk= github.com/openshift/gssapi v0.0.0-20161010215902-5fb4217df13b/go.mod h1:tNrEB5k8SI+g5kOlsCmL2ELASfpqEofI0+FLBgBdN08= github.com/opentracing-contrib/go-observer v0.0.0-20170622124052-a52f23424492/go.mod h1:Ngi6UdF0k5OKD5t5wlmGhe/EDKPoUM3BXZSSfIuJbis= github.com/opentracing/basictracer-go v1.0.0/go.mod h1:QfBfYuafItcjQuMwinw9GhYKwFXS9KnPs5lxoYwgW74= @@ -948,8 +946,6 @@ golang.org/x/crypto v0.0.0-20200414173820-0848c9571904/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0 h1:hb9wdF1z5waM+dSIICn1l0DkLVDT3hqhhQsDNUmHPRE= golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad h1:DN0cp81fZ3njFcrLCytUHRSUkqBjfTo4Tx9RJTWs0EY= -golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -1062,7 +1058,6 @@ golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191022100944-742c48ecaeb7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191105231009-c1f44814a5cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191220142924-d4481acd189f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -1073,9 +1068,6 @@ golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1 h1:ogLJMz+qpzav7lGMh10LMvAkM golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210113181707-4bcb84eeeb78 h1:nVuTkr9L6Bq62qpUqKo/RnZCFfzDBL0bYo6w9OJUqZY= -golang.org/x/sys v0.0.0-20210113181707-4bcb84eeeb78/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -1225,12 +1217,8 @@ honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2020.1.4 h1:UoveltGrhghAA7ePc+e+QYDHXrBps2PqFZiHkGR/xK8= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -istio.io/api v0.0.0-20200529165953-72dad51d4ffc h1:cR9GmbIBAz3FnY3tgs1SRn/uiznhtvG+mZBfD1p2vIA= -istio.io/api v0.0.0-20200529165953-72dad51d4ffc/go.mod h1:kyq3g5w42zl/AKlbzDGppYpGMQYMYMyZKeq0/eexML8= istio.io/api v0.0.0-20210128181506-0c4b8e54850f h1:zUFsawgPj5oI9p5cf91YCExRlxLIVsEkIunN9ODUSJs= istio.io/api v0.0.0-20210128181506-0c4b8e54850f/go.mod h1:88HN3o1fSD1jo+Z1WTLlJfMm9biopur6Ct9BFKjiB64= -istio.io/client-go v0.0.0-20200529172309-31c16ea3f751 h1:yH62fTmV+5l1XVTWcomsc1jjH/oH9u/tTgn5NVmdIac= -istio.io/client-go v0.0.0-20200529172309-31c16ea3f751/go.mod h1:4SGvmmus5HNFdqQsIL+uQO1PbAhjQKtSjMTqwsvYHlg= istio.io/client-go v0.0.0-20210128182905-ee2edd059e02 h1:ZA8Y2gKkKtEeYuKfqlEzIBDfU4IE5uIAdsXDeD41T9w= istio.io/client-go v0.0.0-20210128182905-ee2edd059e02/go.mod h1:oXMjFUWhxlReUSbg4i3GjKgOhSX1WgD68ZNlHQEcmQg= istio.io/gogo-genproto v0.0.0-20190904133402-ee07f2785480/go.mod h1:uKtbae4K9k2rjjX4ToV0l6etglbc1i7gqQ94XdkshzY= diff --git a/main.go b/main.go index 09860c08d2..9e0837a468 100644 --- a/main.go +++ b/main.go @@ -39,6 +39,7 @@ import ( "sigs.k8s.io/external-dns/provider/aws" "sigs.k8s.io/external-dns/provider/awssd" "sigs.k8s.io/external-dns/provider/azure" + "sigs.k8s.io/external-dns/provider/bluecat" "sigs.k8s.io/external-dns/provider/cloudflare" "sigs.k8s.io/external-dns/provider/coredns" "sigs.k8s.io/external-dns/provider/designate" @@ -194,6 +195,8 @@ func main() { p, err = azure.NewAzureProvider(cfg.AzureConfigFile, domainFilter, zoneNameFilter, zoneIDFilter, cfg.AzureResourceGroup, cfg.AzureUserAssignedIdentityClientID, cfg.DryRun) case "azure-private-dns": p, err = azure.NewAzurePrivateDNSProvider(cfg.AzureConfigFile, domainFilter, zoneIDFilter, cfg.AzureResourceGroup, cfg.AzureUserAssignedIdentityClientID, cfg.DryRun) + case "bluecat": + p, err = bluecat.NewBluecatProvider(cfg.BluecatConfigFile, domainFilter, zoneIDFilter, cfg.DryRun) case "vinyldns": p, err = vinyldns.NewVinylDNSProvider(domainFilter, zoneIDFilter, cfg.DryRun) case "vultr": diff --git a/pkg/apis/externaldns/types.go b/pkg/apis/externaldns/types.go index b8d43f4620..dfda5bd913 100644 --- a/pkg/apis/externaldns/types.go +++ b/pkg/apis/externaldns/types.go @@ -82,6 +82,7 @@ type Config struct { AzureResourceGroup string AzureSubscriptionID string AzureUserAssignedIdentityClientID string + BluecatConfigFile string CloudflareProxied bool CloudflareZonesPerPage int CoreDNSPrefix string @@ -199,6 +200,7 @@ var defaultConfig = &Config{ AzureConfigFile: "/etc/kubernetes/azure.json", AzureResourceGroup: "", AzureSubscriptionID: "", + BluecatConfigFile: "/etc/kubernetes/bluecat.json", CloudflareProxied: false, CloudflareZonesPerPage: 50, CoreDNSPrefix: "/skydns/", @@ -352,7 +354,7 @@ func (cfg *Config) ParseFlags(args []string) error { app.Flag("managed-record-types", "Comma separated list of record types to manage (default: A, CNAME) (supported records: CNAME, A, NS").Default("A", "CNAME").StringsVar(&cfg.ManagedDNSRecordTypes) // Flags related to providers - app.Flag("provider", "The DNS provider where the DNS records will be created (required, options: aws, aws-sd, godaddy, google, azure, azure-dns, azure-private-dns, cloudflare, rcodezero, digitalocean, hetzner, dnsimple, akamai, infoblox, dyn, designate, coredns, skydns, inmemory, ovh, pdns, oci, exoscale, linode, rfc2136, ns1, transip, vinyldns, rdns, scaleway, vultr, ultradns)").Required().PlaceHolder("provider").EnumVar(&cfg.Provider, "aws", "aws-sd", "google", "azure", "azure-dns", "hetzner", "azure-private-dns", "alibabacloud", "cloudflare", "rcodezero", "digitalocean", "dnsimple", "akamai", "infoblox", "dyn", "designate", "coredns", "skydns", "inmemory", "ovh", "pdns", "oci", "exoscale", "linode", "rfc2136", "ns1", "transip", "vinyldns", "rdns", "scaleway", "vultr", "ultradns", "godaddy") + app.Flag("provider", "The DNS provider where the DNS records will be created (required, options: aws, aws-sd, godaddy, google, azure, azure-dns, azure-private-dns, bluecat, cloudflare, rcodezero, digitalocean, hetzner, dnsimple, akamai, infoblox, dyn, designate, coredns, skydns, inmemory, ovh, pdns, oci, exoscale, linode, rfc2136, ns1, transip, vinyldns, rdns, scaleway, vultr, ultradns)").Required().PlaceHolder("provider").EnumVar(&cfg.Provider, "aws", "aws-sd", "google", "azure", "azure-dns", "hetzner", "azure-private-dns", "alibabacloud", "cloudflare", "rcodezero", "digitalocean", "dnsimple", "akamai", "infoblox", "dyn", "designate", "coredns", "skydns", "inmemory", "ovh", "pdns", "oci", "exoscale", "linode", "rfc2136", "ns1", "transip", "vinyldns", "rdns", "scaleway", "vultr", "ultradns", "godaddy") 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) app.Flag("zone-name-filter", "Filter target zones by zone domain (For now, only AzureDNS provider is using this flag); specify multiple times for multiple zones (optional)").Default("").StringsVar(&cfg.ZoneNameFilter) @@ -375,6 +377,7 @@ func (cfg *Config) ParseFlags(args []string) error { app.Flag("azure-resource-group", "When using the Azure provider, override the Azure resource group to use (required when --provider=azure-private-dns)").Default(defaultConfig.AzureResourceGroup).StringVar(&cfg.AzureResourceGroup) app.Flag("azure-subscription-id", "When using the Azure provider, specify the Azure configuration file (required when --provider=azure-private-dns)").Default(defaultConfig.AzureSubscriptionID).StringVar(&cfg.AzureSubscriptionID) app.Flag("azure-user-assigned-identity-client-id", "When using the Azure provider, override the client id of user assigned identity in config file (optional)").Default("").StringVar(&cfg.AzureUserAssignedIdentityClientID) + app.Flag("bluecat-config-file", "When using the Bluecat provider, specify the Bluecat configuration file (required when --provider=bluecat").Default(defaultConfig.BluecatConfigFile).StringVar(&cfg.BluecatConfigFile) app.Flag("cloudflare-proxied", "When using the Cloudflare provider, specify if the proxy mode must be enabled (default: disabled)").BoolVar(&cfg.CloudflareProxied) app.Flag("cloudflare-zones-per-page", "When using the Cloudflare provider, specify how many zones per page listed, max. possible 50 (default: 50)").Default(strconv.Itoa(defaultConfig.CloudflareZonesPerPage)).IntVar(&cfg.CloudflareZonesPerPage) app.Flag("coredns-prefix", "When using the CoreDNS provider, specify the prefix name").Default(defaultConfig.CoreDNSPrefix).StringVar(&cfg.CoreDNSPrefix) diff --git a/pkg/apis/externaldns/types_test.go b/pkg/apis/externaldns/types_test.go index df37180d40..6c2b1f553e 100644 --- a/pkg/apis/externaldns/types_test.go +++ b/pkg/apis/externaldns/types_test.go @@ -61,6 +61,7 @@ var ( AzureConfigFile: "/etc/kubernetes/azure.json", AzureResourceGroup: "", AzureSubscriptionID: "", + BluecatConfigFile: "/etc/kubernetes/bluecat.json", CloudflareProxied: false, CloudflareZonesPerPage: 50, CoreDNSPrefix: "/skydns/", @@ -142,6 +143,7 @@ var ( AzureConfigFile: "azure.json", AzureResourceGroup: "arg", AzureSubscriptionID: "arg", + BluecatConfigFile: "bluecat.json", CloudflareProxied: true, CloudflareZonesPerPage: 20, CoreDNSPrefix: "/coredns/", @@ -236,6 +238,7 @@ func TestParseFlags(t *testing.T) { "--azure-config-file=azure.json", "--azure-resource-group=arg", "--azure-subscription-id=arg", + "--bluecat-config-file=bluecat.json", "--cloudflare-proxied", "--cloudflare-zones-per-page=20", "--coredns-prefix=/coredns/", @@ -331,6 +334,7 @@ func TestParseFlags(t *testing.T) { "EXTERNAL_DNS_AZURE_CONFIG_FILE": "azure.json", "EXTERNAL_DNS_AZURE_RESOURCE_GROUP": "arg", "EXTERNAL_DNS_AZURE_SUBSCRIPTION_ID": "arg", + "EXTERNAL_DNS_BLUECAT_CONFIG_FILE": "bluecat.json", "EXTERNAL_DNS_CLOUDFLARE_PROXIED": "1", "EXTERNAL_DNS_CLOUDFLARE_ZONES_PER_PAGE": "20", "EXTERNAL_DNS_COREDNS_PREFIX": "/coredns/", diff --git a/provider/bluecat/OWNERS b/provider/bluecat/OWNERS new file mode 100644 index 0000000000..58a6b3a179 --- /dev/null +++ b/provider/bluecat/OWNERS @@ -0,0 +1,6 @@ +approvers: +- seanmalloy +- vinny-sabatini +reviewers: +- seanmalloy +- vinny-sabatini diff --git a/provider/bluecat/bluecat.go b/provider/bluecat/bluecat.go new file mode 100644 index 0000000000..2205dcc0c5 --- /dev/null +++ b/provider/bluecat/bluecat.go @@ -0,0 +1,969 @@ +/* +Copyright 2020 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. +*/ + +// TODO: Ensure we have proper error handling/logging for API calls to Bluecat. getBluecatGatewayToken has a good example of this + +package bluecat + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "io" + "io/ioutil" + "net/http" + "strconv" + "strings" + + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + + "sigs.k8s.io/external-dns/endpoint" + "sigs.k8s.io/external-dns/plan" + "sigs.k8s.io/external-dns/provider" +) + +type bluecatConfig struct { + GatewayHost string `json:"gatewayHost"` + GatewayUsername string `json:"gatewayUsername"` + GatewayPassword string `json:"gatewayPassword"` + DNSConfiguration string `json:"dnsConfiguration"` + View string `json:"dnsView"` + RootZone string `json:"rootZone"` +} + +// BluecatProvider implements the DNS provider for Bluecat DNS +type BluecatProvider struct { + provider.BaseProvider + domainFilter endpoint.DomainFilter + zoneIDFilter provider.ZoneIDFilter + dryRun bool + RootZone string + DNSConfiguration string + View string + gatewayClient GatewayClient +} + +type GatewayClient interface { + getBluecatZones(zoneName string) ([]BluecatZone, error) + getHostRecords(zone string, records *[]BluecatHostRecord) error + getCNAMERecords(zone string, records *[]BluecatCNAMERecord) error + getHostRecord(name string, record *BluecatHostRecord) error + getCNAMERecord(name string, record *BluecatCNAMERecord) error + createHostRecord(zone string, req *bluecatCreateHostRecordRequest) (res interface{}, err error) + createCNAMERecord(zone string, req *bluecatCreateCNAMERecordRequest) (res interface{}, err error) + deleteHostRecord(name string) (err error) + deleteCNAMERecord(name string) (err error) + buildHTTPRequest(method, url string, body io.Reader) (*http.Request, error) + getTXTRecords(zone string, records *[]BluecatTXTRecord) error + getTXTRecord(name string, record *BluecatTXTRecord) error + createTXTRecord(zone string, req *bluecatCreateTXTRecordRequest) (res interface{}, err error) + deleteTXTRecord(name string) error +} + +// GatewayClientConfig defines new client on bluecat gateway +type GatewayClientConfig struct { + Cookie http.Cookie + Token string + Host string + DNSConfiguration string + View string + RootZone string +} + +// BluecatZone defines a zone to hold records +type BluecatZone struct { + ID int `json:"id"` + Name string `json:"name"` + Properties string `json:"properties"` + Type string `json:"type"` +} + +// BluecatHostRecord defines dns Host record +type BluecatHostRecord struct { + ID int `json:"id"` + Name string `json:"name"` + Properties string `json:"properties"` + Type string `json:"type"` +} + +// BluecatCNAMERecord defines dns CNAME record +type BluecatCNAMERecord struct { + ID int `json:"id"` + Name string `json:"name"` + Properties string `json:"properties"` + Type string `json:"type"` +} + +// BluecatTXTRecord defines dns TXT record +type BluecatTXTRecord struct { + ID int `json:"id"` + Name string `json:"name"` + Text string `json:"text"` +} + +type bluecatRecordSet struct { + obj interface{} + res interface{} +} + +type bluecatCreateHostRecordRequest struct { + AbsoluteName string `json:"absolute_name"` + IP4Address string `json:"ip4_address"` + TTL int `json:"ttl"` + Properties string `json:"properties"` +} + +type bluecatCreateCNAMERecordRequest struct { + AbsoluteName string `json:"absolute_name"` + LinkedRecord string `json:"linked_record"` + TTL int `json:"ttl"` + Properties string `json:"properties"` +} + +type bluecatCreateTXTRecordRequest struct { + AbsoluteName string `json:"absolute_name"` + Text string `json:"txt"` +} + +// NewBluecatProvider creates a new Bluecat provider. +// +// Returns a pointer to the provider or an error if a provider could not be created. +func NewBluecatProvider(configFile string, domainFilter endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, dryRun bool) (*BluecatProvider, error) { + contents, err := ioutil.ReadFile(configFile) + if err != nil { + return nil, errors.Wrapf(err, "failed to read Bluecat config file %v", configFile) + } + + cfg := bluecatConfig{} + err = json.Unmarshal(contents, &cfg) + if err != nil { + return nil, errors.Wrapf(err, "failed to read Bluecat config file %v", configFile) + } + + token, cookie, err := getBluecatGatewayToken(cfg) + if err != nil { + return nil, errors.Wrap(err, "failed to get API token from Bluecat Gateway") + } + gatewayClient := NewGatewayClient(cookie, token, cfg.GatewayHost, cfg.DNSConfiguration, cfg.View, cfg.RootZone) + + provider := &BluecatProvider{ + domainFilter: domainFilter, + zoneIDFilter: zoneIDFilter, + dryRun: dryRun, + gatewayClient: gatewayClient, + DNSConfiguration: cfg.DNSConfiguration, + View: cfg.View, + RootZone: cfg.RootZone, + } + return provider, nil +} + +// NewGatewayClient creates and returns a new Bluecat gateway client +func NewGatewayClient(cookie http.Cookie, token, gatewayHost, dnsConfiguration, view, rootZone string) GatewayClientConfig { + // Right now the Bluecat gateway doesn't seem to have a way to get the root zone from the API. If the user + // doesn't provide one via the config file we'll assume it's 'com' + if rootZone == "" { + rootZone = "com" + } + return GatewayClientConfig{ + Cookie: cookie, + Token: token, + Host: gatewayHost, + DNSConfiguration: dnsConfiguration, + View: view, + RootZone: rootZone, + } +} + +// Records fetches Host, CNAME, and TXT records from bluecat gateway +func (p *BluecatProvider) Records(ctx context.Context) (endpoints []*endpoint.Endpoint, err error) { + zones, err := p.zones() + if err != nil { + return nil, errors.Wrap(err, "could not fetch zones") + } + + for _, zone := range zones { + log.Debugf("fetching records from zone '%s'", zone) + var resH []BluecatHostRecord + err = p.gatewayClient.getHostRecords(zone, &resH) + if err != nil { + return nil, errors.Wrapf(err, "could not fetch host records for zone: %v", zone) + } + for _, rec := range resH { + propMap := splitProperties(rec.Properties) + ips := strings.Split(propMap["addresses"], ",") + for _, ip := range ips { + ep := endpoint.NewEndpoint(propMap["absoluteName"], endpoint.RecordTypeA, ip) + endpoints = append(endpoints, ep) + } + } + + var resC []BluecatCNAMERecord + err = p.gatewayClient.getCNAMERecords(zone, &resC) + if err != nil { + return nil, errors.Wrapf(err, "could not fetch CNAME records for zone: %v", zone) + } + for _, rec := range resC { + propMap := splitProperties(rec.Properties) + endpoints = append(endpoints, endpoint.NewEndpoint(propMap["absoluteName"], endpoint.RecordTypeCNAME, propMap["linkedRecordName"])) + } + + var resT []BluecatTXTRecord + err = p.gatewayClient.getTXTRecords(zone, &resT) + if err != nil { + return nil, errors.Wrapf(err, "could not fetch TXT records for zone: %v", zone) + } + for _, rec := range resT { + endpoints = append(endpoints, endpoint.NewEndpoint(rec.Name, endpoint.RecordTypeTXT, rec.Text)) + } + } + + log.Debugf("fetched %d records from Bluecat", len(endpoints)) + return endpoints, nil +} + +// ApplyChanges updates necessary zones and replaces old records with new ones +// +// Returns nil upon success and err is there is an error +func (p *BluecatProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { + zones, err := p.zones() + if err != nil { + return err + } + log.Infof("zones is: %+v\n", zones) + log.Infof("changes: %+v\n", changes) + created, deleted := p.mapChanges(zones, changes) + log.Infof("created: %+v\n", created) + log.Infof("deleted: %+v\n", deleted) + p.deleteRecords(deleted) + p.createRecords(created) + + // TODO: add bluecat deploy API call here + + return nil +} + +type bluecatChangeMap map[string][]*endpoint.Endpoint + +func (p *BluecatProvider) mapChanges(zones []string, changes *plan.Changes) (bluecatChangeMap, bluecatChangeMap) { + created := bluecatChangeMap{} + deleted := bluecatChangeMap{} + + mapChange := func(changeMap bluecatChangeMap, change *endpoint.Endpoint) { + zone := p.findZone(zones, change.DNSName) + if zone == "" { + log.Debugf("ignoring changes to '%s' because a suitable Bluecat DNS zone was not found", change.DNSName) + return + } + changeMap[zone] = append(changeMap[zone], change) + } + + for _, change := range changes.Delete { + mapChange(deleted, change) + } + for _, change := range changes.UpdateOld { + mapChange(deleted, change) + } + for _, change := range changes.Create { + mapChange(created, change) + } + for _, change := range changes.UpdateNew { + mapChange(created, change) + } + + return created, deleted +} + +// findZone finds the most specific matching zone for a given record 'name' from a list of all zones +func (p *BluecatProvider) findZone(zones []string, name string) string { + var result string + + for _, zone := range zones { + if strings.HasSuffix(name, "."+zone) { + if result == "" || len(zone) > len(result) { + result = zone + } + } else if strings.EqualFold(name, zone) { + if result == "" || len(zone) > len(result) { + result = zone + } + } + } + + return result +} + +func (p *BluecatProvider) zones() ([]string, error) { + log.Debugf("retrieving Bluecat zones for configuration: %s, view: %s", p.DNSConfiguration, p.View) + var zones []string + + zonelist, err := p.gatewayClient.getBluecatZones(p.RootZone) + if err != nil { + return nil, err + } + + for _, zone := range zonelist { + if !p.domainFilter.Match(zone.Name) { + continue + } + + // TODO: match to absoluteName(string) not Id(int) + if !p.zoneIDFilter.Match(strconv.Itoa(zone.ID)) { + continue + } + + zoneProps := splitProperties(zone.Properties) + + zones = append(zones, zoneProps["absoluteName"]) + } + log.Debugf("found %d zones", len(zones)) + return zones, nil +} + +func (p *BluecatProvider) createRecords(created bluecatChangeMap) { + for zone, endpoints := range created { + for _, ep := range endpoints { + if p.dryRun { + log.Infof("would create %s record named '%s' to '%s' for Bluecat DNS zone '%s'.", + ep.RecordType, + ep.DNSName, + ep.Targets, + zone, + ) + continue + } + + log.Infof("creating %s record named '%s' to '%s' for Bluecat DNS zone '%s'.", + ep.RecordType, + ep.DNSName, + ep.Targets, + zone, + ) + + recordSet, err := p.recordSet(ep, false) + if err != nil { + log.Errorf( + "Failed to retrieve %s record named '%s' to '%s' for Bluecat DNS zone '%s': %v", + ep.RecordType, + ep.DNSName, + ep.Targets, + zone, + err, + ) + continue + } + var response interface{} + switch ep.RecordType { + case endpoint.RecordTypeA: + response, err = p.gatewayClient.createHostRecord(zone, recordSet.obj.(*bluecatCreateHostRecordRequest)) + case endpoint.RecordTypeCNAME: + response, err = p.gatewayClient.createCNAMERecord(zone, recordSet.obj.(*bluecatCreateCNAMERecordRequest)) + case endpoint.RecordTypeTXT: + response, err = p.gatewayClient.createTXTRecord(zone, recordSet.obj.(*bluecatCreateTXTRecordRequest)) + } + log.Debugf("Response from create: %v", response) + if err != nil { + log.Errorf( + "Failed to create %s record named '%s' to '%s' for Bluecat DNS zone '%s': %v", + ep.RecordType, + ep.DNSName, + ep.Targets, + zone, + err, + ) + } + } + } +} + +func (p *BluecatProvider) deleteRecords(deleted bluecatChangeMap) { + // run deletions first + for zone, endpoints := range deleted { + for _, ep := range endpoints { + if p.dryRun { + log.Infof("would delete %s record named '%s' for Bluecat DNS zone '%s'.", + ep.RecordType, + ep.DNSName, + zone, + ) + continue + } else { + log.Infof("deleting %s record named '%s' for Bluecat DNS zone '%s'.", + ep.RecordType, + ep.DNSName, + zone, + ) + + recordSet, err := p.recordSet(ep, true) + if err != nil { + log.Errorf( + "Failed to retrieve %s record named '%s' to '%s' for Bluecat DNS zone '%s': %v", + ep.RecordType, + ep.DNSName, + ep.Targets, + zone, + err, + ) + continue + } + + switch ep.RecordType { + case endpoint.RecordTypeA: + for _, record := range *recordSet.res.(*[]BluecatHostRecord) { + err = p.gatewayClient.deleteHostRecord(record.Name) + } + case endpoint.RecordTypeCNAME: + for _, record := range *recordSet.res.(*[]BluecatCNAMERecord) { + err = p.gatewayClient.deleteCNAMERecord(record.Name) + } + case endpoint.RecordTypeTXT: + for _, record := range *recordSet.res.(*[]BluecatTXTRecord) { + err = p.gatewayClient.deleteTXTRecord(record.Name) + } + } + if err != nil { + log.Errorf("Failed to delete %s record named '%s' for Bluecat DNS zone '%s': %v", + ep.RecordType, + ep.DNSName, + zone, + err) + } + } + } + } +} + +func (p *BluecatProvider) recordSet(ep *endpoint.Endpoint, getObject bool) (recordSet bluecatRecordSet, err error) { + switch ep.RecordType { + case endpoint.RecordTypeA: + var res []BluecatHostRecord + // TODO Allow configurable properties/ttl + obj := bluecatCreateHostRecordRequest{ + AbsoluteName: ep.DNSName, + IP4Address: ep.Targets[0], + TTL: 0, + Properties: "", + } + if getObject { + var record BluecatHostRecord + err = p.gatewayClient.getHostRecord(ep.DNSName, &record) + if err != nil { + return + } + res = append(res, record) + } + recordSet = bluecatRecordSet{ + obj: &obj, + res: &res, + } + case endpoint.RecordTypeCNAME: + var res []BluecatCNAMERecord + obj := bluecatCreateCNAMERecordRequest{ + AbsoluteName: ep.DNSName, + LinkedRecord: ep.Targets[0], + TTL: 0, + Properties: "", + } + if getObject { + var record BluecatCNAMERecord + err = p.gatewayClient.getCNAMERecord(ep.DNSName, &record) + if err != nil { + return + } + res = append(res, record) + } + recordSet = bluecatRecordSet{ + obj: &obj, + res: &res, + } + case endpoint.RecordTypeTXT: + var res []BluecatTXTRecord + obj := bluecatCreateTXTRecordRequest{ + AbsoluteName: ep.DNSName, + Text: ep.Targets[0], + } + if getObject { + var record BluecatTXTRecord + err = p.gatewayClient.getTXTRecord(ep.DNSName, &record) + if err != nil { + return + } + res = append(res, record) + } + recordSet = bluecatRecordSet{ + obj: &obj, + res: &res, + } + } + return +} + +// getBluecatGatewayToken retrieves a Bluecat Gateway API token. +func getBluecatGatewayToken(cfg bluecatConfig) (string, http.Cookie, error) { + body, err := json.Marshal(map[string]string{ + "username": cfg.GatewayUsername, + "password": cfg.GatewayPassword, + }) + if err != nil { + return "", http.Cookie{}, errors.Wrap(err, "could not unmarshal credentials for bluecat gateway config") + } + + c := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // ignore self-signed SSL cert check + }} + + resp, err := c.Post(cfg.GatewayHost+"/rest_login", "application/json", bytes.NewBuffer(body)) + if err != nil { + return "", http.Cookie{}, errors.Wrap(err, "error obtaining API token from bluecat gateway") + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + details, _ := ioutil.ReadAll(resp.Body) + return "", http.Cookie{}, errors.Errorf("got HTTP response code %v, detailed message: %v", resp.StatusCode, string(details)) + } + + res, err := ioutil.ReadAll(resp.Body) + if err != nil { + return "", http.Cookie{}, errors.Wrap(err, "error reading get_token response from bluecat gateway") + } + + resJSON := map[string]string{} + err = json.Unmarshal(res, &resJSON) + if err != nil { + return "", http.Cookie{}, errors.Wrap(err, "error unmarshaling json response (auth) from bluecat gateway") + } + + // Example response: {"access_token": "BAMAuthToken: abc123"} + // We only care about the actual token string - i.e. abc123 + // The gateway also creates a cookie as part of the response. This seems to be the actual auth mechanism, at least + // for now. + return strings.Split(resJSON["access_token"], " ")[1], *resp.Cookies()[0], nil +} + +func (c GatewayClientConfig) getBluecatZones(zoneName string) ([]BluecatZone, error) { + transportCfg := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //ignore self-signed SSL cert check + } + client := &http.Client{ + Transport: transportCfg, + } + zonePath := expandZone(zoneName) + url := c.Host + "/api/v1/configurations/" + c.DNSConfiguration + "/views/" + c.View + "/" + zonePath + req, err := c.buildHTTPRequest("GET", url, nil) + if err != nil { + return nil, errors.Wrap(err, "error building http request") + } + + resp, err := client.Do(req) + if err != nil { + return nil, errors.Wrapf(err, "error retrieving zone(s) from gateway: %v, %v", url, zoneName) + } + + defer resp.Body.Close() + + zones := []BluecatZone{} + json.NewDecoder(resp.Body).Decode(&zones) + + // Bluecat Gateway only returns subzones one level deeper than the provided zone + // so this recursion is needed to traverse subzones until none are returned + for _, zone := range zones { + zoneProps := splitProperties(zone.Properties) + subZones, err := c.getBluecatZones(zoneProps["absoluteName"]) + if err != nil { + return nil, errors.Wrapf(err, "error retrieving subzones from gateway: %v", zoneName) + } + zones = append(zones, subZones...) + } + + return zones, nil +} + +func (c GatewayClientConfig) getHostRecords(zone string, records *[]BluecatHostRecord) error { + transportCfg := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //ignore self-signed SSL cert check + } + client := &http.Client{ + Transport: transportCfg, + } + + zonePath := expandZone(zone) + + // Remove the trailing 'zones/' + zonePath = strings.TrimSuffix(zonePath, "zones/") + + url := c.Host + "/api/v1/configurations/" + c.DNSConfiguration + "/views/" + c.View + "/" + zonePath + "host_records/" + req, err := c.buildHTTPRequest("GET", url, nil) + if err != nil { + return errors.Wrap(err, "error building http request") + } + + resp, err := client.Do(req) + if err != nil { + return errors.Wrapf(err, "error retrieving record(s) from gateway: %v", zone) + } + + defer resp.Body.Close() + + json.NewDecoder(resp.Body).Decode(records) + log.Debugf("Get Host Records Response: %v", records) + + return nil +} + +func (c GatewayClientConfig) getCNAMERecords(zone string, records *[]BluecatCNAMERecord) error { + transportCfg := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //ignore self-signed SSL cert check + } + client := &http.Client{ + Transport: transportCfg, + } + + zonePath := expandZone(zone) + + // Remove the trailing 'zones/' + zonePath = strings.TrimSuffix(zonePath, "zones/") + + url := c.Host + "/api/v1/configurations/" + c.DNSConfiguration + "/views/" + c.View + "/" + zonePath + "cname_records/" + req, err := c.buildHTTPRequest("GET", url, nil) + if err != nil { + return errors.Wrap(err, "error building http request") + } + + resp, err := client.Do(req) + if err != nil { + return errors.Wrapf(err, "error retrieving record(s) from gateway: %v", zone) + } + + defer resp.Body.Close() + + json.NewDecoder(resp.Body).Decode(records) + log.Debugf("Get CName Records Response: %v", records) + + return nil +} + +func (c GatewayClientConfig) getTXTRecords(zone string, records *[]BluecatTXTRecord) error { + transportCfg := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //ignore self-signed SSL cert check + } + client := &http.Client{ + Transport: transportCfg, + } + + zonePath := expandZone(zone) + + // Remove the trailing 'zones/' + zonePath = strings.TrimSuffix(zonePath, "zones/") + + url := c.Host + "/api/v1/configurations/" + c.DNSConfiguration + "/views/" + c.View + "/" + zonePath + "text_records/" + req, err := c.buildHTTPRequest("GET", url, nil) + if err != nil { + return errors.Wrap(err, "error building http request") + } + log.Debugf("Request: %v", req) + + resp, err := client.Do(req) + if err != nil { + return errors.Wrapf(err, "error retrieving record(s) from gateway: %v", zone) + } + log.Debugf("Get Txt Records response: %v", resp) + + defer resp.Body.Close() + + json.NewDecoder(resp.Body).Decode(records) + log.Debugf("Get TXT Records Body: %v", records) + + return nil +} + +func (c GatewayClientConfig) getHostRecord(name string, record *BluecatHostRecord) error { + transportCfg := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //ignore self-signed SSL cert check + } + client := &http.Client{ + Transport: transportCfg, + } + + url := c.Host + "/api/v1/configurations/" + c.DNSConfiguration + + "/views/" + c.View + "/" + + "host_records/" + name + "/" + req, err := c.buildHTTPRequest("GET", url, nil) + if err != nil { + return errors.Wrapf(err, "error building http request: %v", name) + } + + resp, err := client.Do(req) + if err != nil { + return errors.Wrapf(err, "error retrieving record(s) from gateway: %v", name) + } + + defer resp.Body.Close() + + json.NewDecoder(resp.Body).Decode(record) + log.Debugf("Get Host Record Response: %v", record) + return nil +} + +func (c GatewayClientConfig) getCNAMERecord(name string, record *BluecatCNAMERecord) error { + transportCfg := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //ignore self-signed SSL cert check + } + client := &http.Client{ + Transport: transportCfg, + } + + url := c.Host + "/api/v1/configurations/" + c.DNSConfiguration + + "/views/" + c.View + "/" + + "cname_records/" + name + "/" + req, err := c.buildHTTPRequest("GET", url, nil) + if err != nil { + return errors.Wrapf(err, "error building http request: %v", name) + } + + resp, err := client.Do(req) + if err != nil { + return errors.Wrapf(err, "error retrieving record(s) from gateway: %v", name) + } + + defer resp.Body.Close() + + json.NewDecoder(resp.Body).Decode(record) + log.Debugf("Get CName Record Response: %v", record) + return nil +} + +func (c GatewayClientConfig) getTXTRecord(name string, record *BluecatTXTRecord) error { + transportCfg := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //ignore self-signed SSL cert check + } + client := &http.Client{ + Transport: transportCfg, + } + + url := c.Host + "/api/v1/configurations/" + c.DNSConfiguration + + "/views/" + c.View + "/" + + "text_records/" + name + "/" + req, err := c.buildHTTPRequest("GET", url, nil) + if err != nil { + return errors.Wrap(err, "error building http request") + } + + resp, err := client.Do(req) + if err != nil { + return errors.Wrapf(err, "error retrieving record(s) from gateway: %v", name) + } + + defer resp.Body.Close() + + json.NewDecoder(resp.Body).Decode(record) + log.Debugf("Get TXT Record Response: %v", record) + + return nil +} + +func (c GatewayClientConfig) createHostRecord(zone string, req *bluecatCreateHostRecordRequest) (res interface{}, err error) { + transportCfg := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //ignore self-signed SSL cert check + } + client := &http.Client{ + Transport: transportCfg, + } + + zonePath := expandZone(zone) + // Remove the trailing 'zones/' + zonePath = strings.TrimSuffix(zonePath, "zones/") + + url := c.Host + "/api/v1/configurations/" + c.DNSConfiguration + "/views/" + c.View + "/" + zonePath + "host_records/" + body, _ := json.Marshal(req) + hreq, err := c.buildHTTPRequest("POST", url, bytes.NewBuffer(body)) + if err != nil { + return nil, errors.Wrap(err, "error building http request") + } + hreq.Header.Add("Content-Type", "application/json") + res, err = client.Do(hreq) + + return +} + +func (c GatewayClientConfig) createCNAMERecord(zone string, req *bluecatCreateCNAMERecordRequest) (res interface{}, err error) { + transportCfg := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //ignore self-signed SSL cert check + } + client := &http.Client{ + Transport: transportCfg, + } + + zonePath := expandZone(zone) + // Remove the trailing 'zones/' + zonePath = strings.TrimSuffix(zonePath, "zones/") + + url := c.Host + "/api/v1/configurations/" + c.DNSConfiguration + "/views/" + c.View + "/" + zonePath + "cname_records/" + body, _ := json.Marshal(req) + + hreq, err := c.buildHTTPRequest("POST", url, bytes.NewBuffer(body)) + if err != nil { + return nil, errors.Wrap(err, "error building http request") + } + + hreq.Header.Add("Content-Type", "application/json") + res, err = client.Do(hreq) + + return +} + +func (c GatewayClientConfig) createTXTRecord(zone string, req *bluecatCreateTXTRecordRequest) (interface{}, error) { + transportCfg := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //ignore self-signed SSL cert check + } + client := &http.Client{ + Transport: transportCfg, + } + + zonePath := expandZone(zone) + // Remove the trailing 'zones/' + zonePath = strings.TrimSuffix(zonePath, "zones/") + + url := c.Host + "/api/v1/configurations/" + c.DNSConfiguration + "/views/" + c.View + "/" + zonePath + "text_records/" + body, _ := json.Marshal(req) + hreq, err := c.buildHTTPRequest("POST", url, bytes.NewBuffer(body)) + if err != nil { + return nil, err + } + + hreq.Header.Add("Content-Type", "application/json") + res, err := client.Do(hreq) + + return res, err +} + +func (c GatewayClientConfig) deleteHostRecord(name string) (err error) { + transportCfg := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //ignore self-signed SSL cert check + } + client := &http.Client{ + Transport: transportCfg, + } + + url := c.Host + "/api/v1/configurations/" + c.DNSConfiguration + + "/views/" + c.View + "/" + + "host_records/" + name + "/" + req, err := c.buildHTTPRequest("DELETE", url, nil) + if err != nil { + return errors.Wrapf(err, "error building http request: %v", name) + } + + _, err = client.Do(req) + if err != nil { + return errors.Wrapf(err, "error deleting record(s) from gateway: %v", name) + } + + return nil +} + +func (c GatewayClientConfig) deleteCNAMERecord(name string) (err error) { + transportCfg := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //ignore self-signed SSL cert check + } + client := &http.Client{ + Transport: transportCfg, + } + + url := c.Host + "/api/v1/configurations/" + c.DNSConfiguration + + "/views/" + c.View + "/" + + "cname_records/" + name + "/" + req, err := c.buildHTTPRequest("DELETE", url, nil) + if err != nil { + return errors.Wrapf(err, "error building http request: %v", name) + } + + _, err = client.Do(req) + if err != nil { + return errors.Wrapf(err, "error deleting record(s) from gateway: %v", name) + } + + return nil +} + +func (c GatewayClientConfig) deleteTXTRecord(name string) error { + transportCfg := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //ignore self-signed SSL cert check + } + client := &http.Client{ + Transport: transportCfg, + } + + url := c.Host + "/api/v1/configurations/" + c.DNSConfiguration + + "/views/" + c.View + "/" + + "text_records/" + name + "/" + + req, err := c.buildHTTPRequest("DELETE", url, nil) + if err != nil { + return errors.Wrap(err, "error building http request") + } + + _, err = client.Do(req) + if err != nil { + return errors.Wrapf(err, "error deleting record(s) from gateway: %v", name) + } + + return nil +} + +//buildHTTPRequest builds a standard http Request and adds authentication headers required by Bluecat Gateway +func (c GatewayClientConfig) buildHTTPRequest(method, url string, body io.Reader) (*http.Request, error) { + req, err := http.NewRequest(method, url, body) + req.Header.Add("Accept", "application/json") + req.Header.Add("Authorization", "Basic "+c.Token) + req.AddCookie(&c.Cookie) + return req, err +} + +//splitProperties is a helper function to break a '|' separated string into key/value pairs +// i.e. "foo=bar|baz=mop" +func splitProperties(props string) map[string]string { + propMap := make(map[string]string) + + // remove trailing | character before we split + props = strings.TrimSuffix(props, "|") + + splits := strings.Split(props, "|") + for _, pair := range splits { + items := strings.Split(pair, "=") + propMap[items[0]] = items[1] + } + + return propMap +} + +//expandZone takes an absolute domain name such as 'example.com' and returns a zone hierarchy used by Bluecat Gateway, +//such as '/zones/com/zones/example/zones/' +func expandZone(zone string) string { + ze := "zones/" + parts := strings.Split(zone, ".") + if len(parts) > 1 { + last := len(parts) - 1 + for i := range parts { + ze = ze + parts[last-i] + "/zones/" + } + } else { + ze = ze + zone + "/zones/" + } + return ze +} diff --git a/provider/bluecat/bluecat_test.go b/provider/bluecat/bluecat_test.go new file mode 100644 index 0000000000..1ec751d701 --- /dev/null +++ b/provider/bluecat/bluecat_test.go @@ -0,0 +1,390 @@ +/* +Copyright 2020 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 bluecat + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "sigs.k8s.io/external-dns/endpoint" + "sigs.k8s.io/external-dns/internal/testutils" + "sigs.k8s.io/external-dns/plan" + "sigs.k8s.io/external-dns/provider" +) + +type mockGatewayClient struct { + mockBluecatZones *[]BluecatZone + mockBluecatHosts *[]BluecatHostRecord + mockBluecatCNAMEs *[]BluecatCNAMERecord + mockBluecatTXTs *[]BluecatTXTRecord +} + +type Changes struct { + // Records that need to be created + Create []*endpoint.Endpoint + // Records that need to be updated (current data) + UpdateOld []*endpoint.Endpoint + // Records that need to be updated (desired data) + UpdateNew []*endpoint.Endpoint + // Records that need to be deleted + Delete []*endpoint.Endpoint +} + +func (g mockGatewayClient) getBluecatZones(zoneName string) ([]BluecatZone, error) { + return *g.mockBluecatZones, nil +} +func (g mockGatewayClient) getHostRecords(zone string, records *[]BluecatHostRecord) error { + *records = *g.mockBluecatHosts + return nil +} +func (g mockGatewayClient) getCNAMERecords(zone string, records *[]BluecatCNAMERecord) error { + *records = *g.mockBluecatCNAMEs + return nil +} +func (g mockGatewayClient) getHostRecord(name string, record *BluecatHostRecord) error { + for _, currentRecord := range *g.mockBluecatHosts { + if currentRecord.Name == strings.Split(name, ".")[0] { + *record = currentRecord + return nil + } + } + return nil +} +func (g mockGatewayClient) getCNAMERecord(name string, record *BluecatCNAMERecord) error { + for _, currentRecord := range *g.mockBluecatCNAMEs { + if currentRecord.Name == strings.Split(name, ".")[0] { + *record = currentRecord + return nil + } + } + return nil +} +func (g mockGatewayClient) createHostRecord(zone string, req *bluecatCreateHostRecordRequest) (res interface{}, err error) { + return nil, nil +} +func (g mockGatewayClient) createCNAMERecord(zone string, req *bluecatCreateCNAMERecordRequest) (res interface{}, err error) { + return nil, nil +} +func (g mockGatewayClient) deleteHostRecord(name string) (err error) { + *g.mockBluecatHosts = nil + return nil +} +func (g mockGatewayClient) deleteCNAMERecord(name string) (err error) { + *g.mockBluecatCNAMEs = nil + return nil +} +func (g mockGatewayClient) getTXTRecords(zone string, records *[]BluecatTXTRecord) error { + *records = *g.mockBluecatTXTs + return nil +} +func (g mockGatewayClient) getTXTRecord(name string, record *BluecatTXTRecord) error { + for _, currentRecord := range *g.mockBluecatTXTs { + if currentRecord.Name == name { + *record = currentRecord + return nil + } + } + return nil +} +func (g mockGatewayClient) createTXTRecord(zone string, req *bluecatCreateTXTRecordRequest) (res interface{}, err error) { + return nil, nil +} +func (g mockGatewayClient) deleteTXTRecord(name string) error { + *g.mockBluecatTXTs = nil + return nil +} + +func (g mockGatewayClient) buildHTTPRequest(method, url string, body io.Reader) (*http.Request, error) { + request, _ := http.NewRequest("GET", fmt.Sprintf("%s/users", "http://some.com/api/v1"), nil) + return request, nil +} + +func createMockBluecatZone(fqdn string) BluecatZone { + props := "absoluteName=" + fqdn + return BluecatZone{ + Properties: props, + Name: fqdn, + ID: 3, + } +} + +func createMockBluecatHostRecord(fqdn, target string) BluecatHostRecord { + props := "absoluteName=" + fqdn + "|addresses=" + target + "|" + nameParts := strings.Split(fqdn, ".") + return BluecatHostRecord{ + Name: nameParts[0], + Properties: props, + ID: 3, + } +} + +func createMockBluecatCNAME(alias, target string) BluecatCNAMERecord { + props := "absoluteName=" + alias + "|linkedRecordName=" + target + "|" + nameParts := strings.Split(alias, ".") + return BluecatCNAMERecord{ + Name: nameParts[0], + Properties: props, + } +} + +func createMockBluecatTXT(fqdn, txt string) BluecatTXTRecord { + return BluecatTXTRecord{ + Name: fqdn, + Text: txt, + } +} + +func newBluecatProvider(domainFilter endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, dryRun bool, client GatewayClient) *BluecatProvider { + return &BluecatProvider{ + domainFilter: domainFilter, + zoneIDFilter: zoneIDFilter, + dryRun: dryRun, + gatewayClient: client, + } +} + +type bluecatTestData []struct { + TestDescription string + Endpoints []*endpoint.Endpoint +} + +var tests = bluecatTestData{ + { + "first test case", // TODO: better test description + []*endpoint.Endpoint{ + { + DNSName: "example.com", + RecordType: endpoint.RecordTypeA, + Targets: endpoint.Targets{"123.123.123.122"}, + }, + { + DNSName: "nginx.example.com", + RecordType: endpoint.RecordTypeA, + Targets: endpoint.Targets{"123.123.123.123"}, + }, + { + DNSName: "whitespace.example.com", + RecordType: endpoint.RecordTypeA, + Targets: endpoint.Targets{"123.123.123.124"}, + }, + { + DNSName: "hack.example.com", + RecordType: endpoint.RecordTypeCNAME, + Targets: endpoint.Targets{"bluecatnetworks.com"}, + }, + { + DNSName: "abc.example.com", + RecordType: endpoint.RecordTypeTXT, + Targets: endpoint.Targets{"hello"}, + }, + }, + }, +} + +func TestBluecatRecords(t *testing.T) { + client := mockGatewayClient{ + mockBluecatZones: &[]BluecatZone{ + createMockBluecatZone("example.com"), + }, + mockBluecatHosts: &[]BluecatHostRecord{ + createMockBluecatHostRecord("example.com", "123.123.123.122"), + createMockBluecatHostRecord("nginx.example.com", "123.123.123.123"), + createMockBluecatHostRecord("whitespace.example.com", "123.123.123.124"), + }, + mockBluecatCNAMEs: &[]BluecatCNAMERecord{ + createMockBluecatCNAME("hack.example.com", "bluecatnetworks.com"), + }, + mockBluecatTXTs: &[]BluecatTXTRecord{ + createMockBluecatTXT("abc.example.com", "hello"), + }, + } + + provider := newBluecatProvider( + endpoint.NewDomainFilter([]string{"example.com"}), + provider.NewZoneIDFilter([]string{""}), false, client) + + for _, ti := range tests { + actual, err := provider.Records(context.Background()) + if err != nil { + t.Fatal(err) + } + validateEndpoints(t, actual, ti.Endpoints) + } +} + +func TestBluecatApplyChangesCreate(t *testing.T) { + client := mockGatewayClient{ + mockBluecatZones: &[]BluecatZone{ + createMockBluecatZone("example.com"), + }, + mockBluecatHosts: &[]BluecatHostRecord{}, + mockBluecatCNAMEs: &[]BluecatCNAMERecord{}, + mockBluecatTXTs: &[]BluecatTXTRecord{}, + } + + provider := newBluecatProvider( + endpoint.NewDomainFilter([]string{"example.com"}), + provider.NewZoneIDFilter([]string{""}), false, client) + + for _, ti := range tests { + err := provider.ApplyChanges(context.Background(), &plan.Changes{Create: ti.Endpoints}) + if err != nil { + t.Fatal(err) + } + + actual, err := provider.Records(context.Background()) + if err != nil { + t.Fatal(err) + } + validateEndpoints(t, actual, []*endpoint.Endpoint{}) + } +} +func TestBluecatApplyChangesDelete(t *testing.T) { + client := mockGatewayClient{ + mockBluecatZones: &[]BluecatZone{ + createMockBluecatZone("example.com"), + }, + mockBluecatHosts: &[]BluecatHostRecord{ + createMockBluecatHostRecord("example.com", "123.123.123.122"), + createMockBluecatHostRecord("nginx.example.com", "123.123.123.123"), + createMockBluecatHostRecord("whitespace.example.com", "123.123.123.124"), + }, + mockBluecatCNAMEs: &[]BluecatCNAMERecord{ + createMockBluecatCNAME("hack.example.com", "bluecatnetworks.com"), + }, + mockBluecatTXTs: &[]BluecatTXTRecord{ + createMockBluecatTXT("abc.example.com", "hello"), + }, + } + + provider := newBluecatProvider( + endpoint.NewDomainFilter([]string{"example.com"}), + provider.NewZoneIDFilter([]string{""}), false, client) + + for _, ti := range tests { + err := provider.ApplyChanges(context.Background(), &plan.Changes{Delete: ti.Endpoints}) + if err != nil { + t.Fatal(err) + } + + actual, err := provider.Records(context.Background()) + if err != nil { + t.Fatal(err) + } + validateEndpoints(t, actual, []*endpoint.Endpoint{}) + } +} + +// TODO: ensure mapChanges method is tested +// TODO: ensure findZone method is tested +// TODO: ensure zones method is tested +// TODO: ensure createRecords method is tested +// TODO: ensure deleteRecords method is tested +// TODO: ensure recordSet method is tested + +// TODO: Figure out why recordSet.res is not being set properly +func TestBluecatRecordset(t *testing.T) { + client := mockGatewayClient{ + mockBluecatZones: &[]BluecatZone{ + createMockBluecatZone("example.com"), + }, + mockBluecatHosts: &[]BluecatHostRecord{ + createMockBluecatHostRecord("example.com", "123.123.123.122"), + createMockBluecatHostRecord("nginx.example.com", "123.123.123.123"), + createMockBluecatHostRecord("whitespace.example.com", "123.123.123.124"), + }, + mockBluecatCNAMEs: &[]BluecatCNAMERecord{ + createMockBluecatCNAME("hack.example.com", "bluecatnetworks.com"), + }, + mockBluecatTXTs: &[]BluecatTXTRecord{ + createMockBluecatTXT("abc.example.com", "hello"), + }, + } + + provider := newBluecatProvider( + endpoint.NewDomainFilter([]string{"example.com"}), + provider.NewZoneIDFilter([]string{""}), false, client) + + // Test txt records for recordSet function + testTxtEndpoint := endpoint.NewEndpoint("abc.example.com", endpoint.RecordTypeTXT, "hello") + txtObj := bluecatCreateTXTRecordRequest{ + AbsoluteName: testTxtEndpoint.DNSName, + Text: testTxtEndpoint.Targets[0], + } + txtRecords := []BluecatTXTRecord{ + createMockBluecatTXT("abc.example.com", "hello"), + } + expected := bluecatRecordSet{ + obj: &txtObj, + res: &txtRecords, + } + actual, err := provider.recordSet(testTxtEndpoint, true) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, actual.obj, expected.obj) + assert.Equal(t, actual.res, expected.res) + + // Test a records for recordSet function + testHostEndpoint := endpoint.NewEndpoint("whitespace.example.com", endpoint.RecordTypeA, "123.123.123.124") + hostObj := bluecatCreateHostRecordRequest{ + AbsoluteName: testHostEndpoint.DNSName, + IP4Address: testHostEndpoint.Targets[0], + } + hostRecords := []BluecatHostRecord{ + createMockBluecatHostRecord("whitespace.example.com", "123.123.123.124"), + } + hostExpected := bluecatRecordSet{ + obj: &hostObj, + res: &hostRecords, + } + hostActual, err := provider.recordSet(testHostEndpoint, true) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, hostActual.obj, hostExpected.obj) + assert.Equal(t, hostActual.res, hostExpected.res) + + // Test CName records for recordSet function + testCnameEndpoint := endpoint.NewEndpoint("hack.example.com", endpoint.RecordTypeCNAME, "bluecatnetworks.com") + cnameObj := bluecatCreateCNAMERecordRequest{ + AbsoluteName: testCnameEndpoint.DNSName, + LinkedRecord: testCnameEndpoint.Targets[0], + } + cnameRecords := []BluecatCNAMERecord{ + createMockBluecatCNAME("hack.example.com", "bluecatnetworks.com"), + } + cnameExpected := bluecatRecordSet{ + obj: &cnameObj, + res: &cnameRecords, + } + cnameActual, err := provider.recordSet(testCnameEndpoint, true) + if err != nil { + t.Fatal(err) + } + assert.Equal(t, cnameActual.obj, cnameExpected.obj) + assert.Equal(t, cnameActual.res, cnameExpected.res) +} + +func validateEndpoints(t *testing.T, actual, expected []*endpoint.Endpoint) { + assert.True(t, testutils.SameEndpoints(actual, expected), "actual and expected endpoints don't match. %s:%s", actual, expected) +}