diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 000000000..df6c42ad3 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,19 @@ +run: + concurrency: 4 + + modules-download-mode: readonly + +linters-settings: + golint: + min-confidence: 0.9 + + gocyclo: + min-complexity: 15 + +linters: + disable-all: true + enable: + - govet + - ineffassign + - golint + - goimports diff --git a/.travis.yml b/.travis.yml index c22f9041c..5b2f6c836 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,4 +1,4 @@ -dist: trusty +dist: xenial os: - linux @@ -6,27 +6,23 @@ os: language: go go: -- 1.x +- "1.12.x" - tip matrix: allow_failures: - go: tip +env: +- GO111MODULE=on GOLANGCI_RELEASE="v1.16.0" + before_install: -- make dep - go get github.com/mattn/goveralls - go get github.com/lawrencewoodman/roveralls -- go get github.com/alecthomas/gometalinter - -install: -- gometalinter --install - -env: - - GOMETALINTER_DEADLINE="600s" +- curl -sfL https://install.goreleaser.com/github.com/golangci/golangci-lint.sh | sh -s -- -b $(go env GOPATH)/bin ${GOLANGCI_RELEASE} script: -- vendor/github.com/kubernetes/repo-infra/verify/verify-boilerplate.sh --rootdir=$(pwd) -- vendor/github.com/kubernetes/repo-infra/verify/verify-go-src.sh -v --rootdir $(pwd) +- make test +- make lint - travis_wait 20 roveralls - goveralls -coverprofile=roveralls.coverprofile -service=travis-ci diff --git a/CHANGELOG.md b/CHANGELOG.md index 5789b6b13..32d36d8c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,83 @@ +## v0.5.14 - 2019-04-23 + + - Core: Supress Kubernetes logs (#991) @njuettner + +## v0.5.13 - 2019-04-18 + + - Azure: Support multiple A targets (#987) @michaelfig + - Core: Fixing what seems an obvious omission of /github.com/ dir in Dockerfile (#985) @llamahunter + - Docs: GKE tutorial remove disable-addon argument (#978) @ggordan + - Docs: Alibaba Cloud config file missing by enable sts token (#977) @xianlubird + - Docs: Alibaba Cloud fix wrong arg in manifest (#976) @iamzhout + - AWS: Set a default TTL for Alias records (#975) @fraenkel + - Cloudflare: Add support for multiple target addresses (#970) @nta + - AWS: Adding China ELB endpoints and hosted zone id's (#968) @jfillo + - AWS: Streamline ApplyChanges (#966) @fraenkel + - Core: Switch to go modules (#960) @njuettner + - Docs: AWS how to check if your cluster has a RBAC (#959) @confiq + - Docs: AWS remove superfluous trailing period from hostname (#952) @hobti01 + - Core: Add generic logic to remove secrets from logs (#951) @dsbrng25b + - RFC2136: Remove unnecessary parameter (#948) @ChristianMoesl + - Infoblox: Reduce verbosity of logs (#945) @dsbrng25b + +## v0.5.12 - 2019-03-26 + + - Bumping istio to 1.1.0 (#942) @venezia + - Docs: Added stability matrix and minor improvements to README (#938) @Raffo + - Docs: Added a reference to a blogpost which uses ExternalDNS in a CI/CD setup (#928) @vanhumbeecka + - Use k8s informer cache instead of making active API GET requests (#917) @jlamillan + - Docs: Tiny clarification about two available deployment methods (#935) @przemolb + - Add support for multiple Istio IngressGateway LoadBalancer Services (#907) @LorbusChris + - Set log level to debug when axfr is disabled (#932) @arief-hidayat + - Infoblox provider support for DNS view (#895) @dsbrng25b + - Add RcodeZero Anycast DNS provider (#874) @dklesev + - Docs: Dropping owners (#929) @njuettner + - Docs: Added description for multiple dns name (#911) @st1t + - Docs: Clarify that hosted zone identifier is to be used (#915) @dirkgomez + - Docs: Make dep step which may be needed to run make build (#913) @dirkgomez + - PowerDNS: Fixed Domain Filter Bug (#827) @anandsinghkunwar + - Allow hostname annotations to be ignored (#745) @anandkumarpatel + - RFC2136: Fixed typo in debug output (#899) @hpandeycodeit + +## v0.5.11 - 2019-02-11 + + - Fix constant updating issue introduced with v0.5.10 (#886) @jhohertz + - Ignore evaluate target health for calculating changes for AWS (#880) @linki + - Pagination for cloudflare zones (#873) @njuettner + +## v0.5.10 - 2019-01-28 + + - Docs: Improve documentation regarding Alias (#868) @alexnederlof + - Adds a new flag `--aws-api-retries` which allows overriding the number of retries (#858) @viafoura + - Docs: Make awscli commands use JSON output (#849) @ifosch + - Docs: Add missing apiVersion to Ingress resource (#847) @shlao + - Fix for AWS private DNS zone (#844) @xianlubird + - Add support for AWS ELBs in eu-north-1 (#843) @argoyle + - Create a SECURITY_CONTACTS file (#842) @njuettner + - Use correct product name for Google Cloud DNS (#841) @seils + - Change default AWSBatchChangeSize to 1000 (#839) @medzin + - Fix dry-run mode in rfc2136 provider (#838) @lachlancooper + - Fix typos in rfc2136 provider (#837) @lachlancooper + - rfc2136 provider: one IP Target per RRSET (#836) @ivanfilippov + - Normalize DNS names during planning (#833) @justinsb + - Implement Stringer for planTableRow (#832) @justinsb + - Docs: Better security granularity concerning external dns service principal for Azure (#829) @DenisBiondic + - Docs: Update links in Cloudflare docs (#824) @PascalKu + - Docs: Add metrics info to FAQ (#822) @zachyam + - Docs: Update nameserver IPs in coredns.md (#820) @mozhuli + - Docs: Fix commands to cleanup Cloudflare (#818) @acrogenesis + - Avoid unnecessary updating for CRD resource (#810) @xunpan + - Fix issues with CoreDNS provider and more than 1 targets (#807) @xunpan + - AWS: Add zone tag filter (#804) @csrwng + - Docs: Update CoreDNS tutorial with RBAC manifest (#803) @Lujeni + - Use SOAP API to improve DYN's provider's performance (#799) @sanyu + - Expose managed resources and records as metrics (#793) @linki + - Docs: Updating Azure tutorial (#788) @pelithne + - Improve errors in Records() of Infoblox provider (#785) @dsbrng25b + - Change default apiVersion of CRD Source (#774) @dsbrng25b + - Allow setting Cloudflare proxying on a per-Ingress basis (#650) @eswets + - Support A record for multiple IPs for headless services (#645) @toshipp + ## v0.5.9 - 2018-11-22 - Core: Update delivery.yaml to new format (#782) @linki diff --git a/Dockerfile b/Dockerfile index 95dc63f1d..f987cf7d4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,11 +13,11 @@ # limitations under the License. # builder image -FROM golang as builder +FROM golang:1.12.4 as builder -WORKDIR /go/src/github.com/kubernetes-incubator/external-dns +WORKDIR /github.com/kubernetes-incubator/external-dns COPY . . -RUN make dep +RUN go mod vendor RUN make test RUN make build @@ -25,7 +25,7 @@ RUN make build FROM registry.opensource.zalan.do/stups/alpine:latest LABEL maintainer="Team Teapot @ Zalando SE " -COPY --from=builder /go/src/github.com/kubernetes-incubator/external-dns/build/external-dns /bin/external-dns +COPY --from=builder /github.com/kubernetes-incubator/external-dns/build/external-dns /bin/external-dns USER nobody diff --git a/Dockerfile.mini b/Dockerfile.mini new file mode 100644 index 000000000..6e0303d99 --- /dev/null +++ b/Dockerfile.mini @@ -0,0 +1,8 @@ +FROM golang:1.12.4 as builder +WORKDIR /external-dns +COPY . . +RUN make build + +FROM gcr.io/distroless/static +COPY --from=builder /external-dns/build/external-dns /external-dns +ENTRYPOINT ["./external-dns"] diff --git a/Gopkg.lock b/Gopkg.lock deleted file mode 100644 index a692aa987..000000000 --- a/Gopkg.lock +++ /dev/null @@ -1,1182 +0,0 @@ -# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. - - -[[projects]] - digest = "1:ae9d0182a5cf7dbb025a8fc5821234cc1f26ca342fc41d951a99f71b9adc1b87" - name = "cloud.google.com/go" - packages = [ - "compute/metadata", - "internal", - ] - pruneopts = "" - revision = "3b1ae45394a234c385be014e9a488f2bb6eef821" - -[[projects]] - digest = "1:fd38e3b8c27cab6561a7d2e8557205c3ca5c57cbb6d3a79e10f22e73e84fd776" - name = "github.com/Azure/azure-sdk-for-go" - packages = ["arm/dns"] - pruneopts = "" - revision = "2629e2dfcfeab50896230140542c3b9d89b35ae1" - version = "v10.0.4-beta" - -[[projects]] - digest = "1:f719ef698feb8da2923ebda9a8d553b977320b02182f3797e185202e588a94b1" - name = "github.com/Azure/go-autorest" - packages = [ - "autorest", - "autorest/adal", - "autorest/azure", - "autorest/date", - "autorest/to", - ] - pruneopts = "" - revision = "aa2a4534ab680e938d933870f58f23f77e0e208e" - version = "v10.9.0" - -[[projects]] - digest = "1:7dc69d1597e4773ec5f64e5c078d55f0f011bb05ec0435346d0649ad978a23fd" - name = "github.com/alecthomas/kingpin" - packages = ["."] - pruneopts = "" - revision = "1087e65c9441605df944fb12c33f0fe7072d18ca" - version = "v2.2.5" - -[[projects]] - branch = "master" - digest = "1:a74730e052a45a3fab1d310fdef2ec17ae3d6af16228421e238320846f2aaec8" - name = "github.com/alecthomas/template" - packages = [ - ".", - "parse", - ] - pruneopts = "" - revision = "a0175ee3bccc567396460bf5acd36800cb10c49c" - -[[projects]] - branch = "master" - digest = "1:8483994d21404c8a1d489f6be756e25bfccd3b45d65821f25695577791a08e68" - name = "github.com/alecthomas/units" - packages = ["."] - pruneopts = "" - revision = "2efee857e7cfd4f3d0138cc3cbb1b4966962b93a" - -[[projects]] - digest = "1:d2dc5d0ccc137594ea6fb3870964967b96b43daac19b8093930c7b02873fd5ca" - name = "github.com/aliyun/alibaba-cloud-sdk-go" - packages = [ - "sdk", - "sdk/auth", - "sdk/auth/credentials", - "sdk/auth/signers", - "sdk/endpoints", - "sdk/errors", - "sdk/requests", - "sdk/responses", - "sdk/utils", - "services/alidns", - "services/pvtz", - ] - pruneopts = "" - revision = "cad214d7d71fba7883fcf3b7e550ba782c15b400" - version = "1.27.7" - -[[projects]] - digest = "1:1c82dd6a02941a3c4f23a32eca182717ab79691939e97d6b971466b780f06eea" - name = "github.com/aws/aws-sdk-go" - packages = [ - "aws", - "aws/awserr", - "aws/awsutil", - "aws/client", - "aws/client/metadata", - "aws/corehandlers", - "aws/credentials", - "aws/credentials/ec2rolecreds", - "aws/credentials/endpointcreds", - "aws/credentials/stscreds", - "aws/defaults", - "aws/ec2metadata", - "aws/endpoints", - "aws/request", - "aws/session", - "aws/signer/v4", - "internal/sdkio", - "internal/sdkrand", - "internal/shareddefaults", - "private/protocol", - "private/protocol/json/jsonutil", - "private/protocol/jsonrpc", - "private/protocol/query", - "private/protocol/query/queryutil", - "private/protocol/rest", - "private/protocol/restxml", - "private/protocol/xml/xmlutil", - "service/route53", - "service/servicediscovery", - "service/sts", - ] - pruneopts = "" - revision = "9b0098a71f6d4d473a26ec8ad3c2feaac6eb1da6" - version = "v1.13.32" - -[[projects]] - branch = "master" - digest = "1:0c5485088ce274fac2e931c1b979f2619345097b39d91af3239977114adf0320" - name = "github.com/beorn7/perks" - packages = ["quantile"] - pruneopts = "" - revision = "4c0e84591b9aa9e6dcfdf3e020114cd81f89d5f9" - -[[projects]] - digest = "1:85fd00554a6ed5b33687684b76635d532c74141508b5bce2843d85e8a3c9dc91" - name = "github.com/cloudflare/cloudflare-go" - packages = ["."] - pruneopts = "" - revision = "4c6994ac3877fbb627766edadc67f4e816e8c890" - version = "v0.7.4" - -[[projects]] - digest = "1:eaeede87b418b97f9dee473f8940fd9b65ca5cdac0503350c7c8f8965ea3cf4d" - name = "github.com/coreos/etcd" - packages = [ - "auth/authpb", - "clientv3", - "etcdserver/api/v3rpc/rpctypes", - "etcdserver/etcdserverpb", - "mvcc/mvccpb", - "pkg/types", - ] - pruneopts = "" - revision = "1b3ac99e8a431b381e633802cc42fe70e663baf5" - version = "v3.2.15" - -[[projects]] - digest = "1:56c130d885a4aacae1dd9c7b71cfe39912c7ebc1ff7d2b46083c8812996dc43b" - name = "github.com/davecgh/go-spew" - packages = ["spew"] - pruneopts = "" - revision = "346938d642f2ec3594ed81d874461961cd0faa76" - version = "v1.1.0" - -[[projects]] - branch = "master" - digest = "1:dc166ce7345c060c2153561130d6d49ac580c804226ac675e368d533b36eb169" - name = "github.com/denverdino/aliyungo" - packages = [ - "metadata", - "util", - ] - pruneopts = "" - revision = "69560d9530f5265ba00ffad2520d7ef01c2cddd4" - -[[projects]] - digest = "1:6098222470fe0172157ce9bbef5d2200df4edde17ee649c5d6e48330e4afa4c6" - name = "github.com/dgrijalva/jwt-go" - packages = ["."] - pruneopts = "" - revision = "06ea1031745cb8b3dab3f6a236daf2b0aa468b7e" - version = "v3.2.0" - -[[projects]] - digest = "1:32d1941b093bb945de75b0276348494be318d34f3df39c4413d61e002c800bc6" - name = "github.com/digitalocean/godo" - packages = [ - ".", - "context", - ] - pruneopts = "" - revision = "77ea48de76a7b31b234d854f15d003c68bb2fb90" - version = "v1.1.1" - -[[projects]] - digest = "1:5ffd39844bdd1259a6227d544f582c6686ce43c8c44399a46052fe3bd2bed93c" - name = "github.com/dnsimple/dnsimple-go" - packages = ["dnsimple"] - pruneopts = "" - revision = "d1105abc03b313d7b8d9b04364f6bd053b346e59" - version = "v0.14.0" - -[[projects]] - digest = "1:e17d18b233f506404061c27ac4a08624dad38dcd0d49f9cfdae67a7772a4fb8c" - name = "github.com/exoscale/egoscale" - packages = ["."] - pruneopts = "" - revision = "c6d915cb993f1a54f604acefc0fc15cf6578a87a" - version = "v0.11.0" - -[[projects]] - branch = "master" - digest = "1:ae7fb2062735e966ab69d14d2a091f3778b0d676dc8d1f01d092bcb0fb8ed45b" - name = "github.com/ffledgling/pdns-go" - packages = ["."] - pruneopts = "" - revision = "524e7daccd99651cdb56426eb15b7d61f9597a5c" - -[[projects]] - digest = "1:b13707423743d41665fd23f0c36b2f37bb49c30e94adb813319c44188a51ba22" - name = "github.com/ghodss/yaml" - packages = ["."] - pruneopts = "" - revision = "0ca9ea5df5451ffdf184b4428c902747c2c11cd7" - version = "v1.0.0" - -[[projects]] - digest = "1:a00483fe4106b86fb1187a92b5cf6915c85f294ed4c129ccbe7cb1f1a06abd46" - name = "github.com/go-ini/ini" - packages = ["."] - pruneopts = "" - revision = "32e4c1e6bc4e7d0d8451aa6b75200d19e37a536a" - version = "v1.32.0" - -[[projects]] - digest = "1:8e67153fc0a9fb0d6c9707e36cf80e217a012364307b222eb4ba6828f7e881e6" - name = "github.com/go-resty/resty" - packages = ["."] - pruneopts = "" - revision = "97a15579492cd5f35632499f315d7a8df94160a1" - version = "v1.8.0" - -[[projects]] - digest = "1:54d5c6a784a9de9c836fc070d51d0689c3e99ee6d24dba8a36f0762039dae830" - name = "github.com/gogo/googleapis" - packages = ["google/rpc"] - pruneopts = "" - revision = "8558fb44d2f1fc223118afc694129d2c2d2924d1" - version = "v1.1.0" - -[[projects]] - digest = "1:6e73003ecd35f4487a5e88270d3ca0a81bc80dc88053ac7e4dcfec5fba30d918" - name = "github.com/gogo/protobuf" - packages = [ - "gogoproto", - "jsonpb", - "proto", - "protoc-gen-gogo/descriptor", - "sortkeys", - "types", - ] - pruneopts = "" - revision = "636bf0302bc95575d69441b25a2603156ffdddf1" - version = "v1.1.1" - -[[projects]] - branch = "master" - digest = "1:b12aff239810a9fa71e901a712a52f9da4c6e536852e943be693dec1d4519dfd" - name = "github.com/golang/glog" - packages = ["."] - pruneopts = "" - revision = "3fa5b9870d1d29f6d7907b29f1ae8c6eeb403829" - source = "github.com/kubermatic/glog-logrus" - -[[projects]] - digest = "1:3dd078fda7500c341bc26cfbc6c6a34614f295a2457149fc1045cab767cbcf18" - name = "github.com/golang/protobuf" - packages = [ - "jsonpb", - "proto", - "protoc-gen-go/descriptor", - "ptypes", - "ptypes/any", - "ptypes/duration", - "ptypes/struct", - "ptypes/timestamp", - ] - pruneopts = "" - revision = "aa810b61a9c79d51363740d207bb46cf8e620ed5" - version = "v1.2.0" - -[[projects]] - branch = "master" - digest = "1:be28c0531a755f2178acf1e327e6f5a8a3968feb5f2567cdc968064253141751" - name = "github.com/google/btree" - packages = ["."] - pruneopts = "" - revision = "e89373fe6b4a7413d7acd6da1725b83ef713e6e4" - -[[projects]] - branch = "master" - digest = "1:9abc49f39e3e23e262594bb4fb70abf74c0c99e94f99153f43b143805e850719" - name = "github.com/google/go-querystring" - packages = ["query"] - pruneopts = "" - revision = "53e6ce116135b80d037921a7fdd5138cf32d7a8a" - -[[projects]] - digest = "1:a2823c34933d4a2b36284f617f483d51fe156a443923284b3660f183dcfa3338" - name = "github.com/google/gofuzz" - packages = ["."] - pruneopts = "" - revision = "44d81051d367757e1c7c6a5a86423ece9afcf63c" - -[[projects]] - digest = "1:16b2837c8b3cf045fa2cdc82af0cf78b19582701394484ae76b2c3bc3c99ad73" - name = "github.com/googleapis/gnostic" - packages = [ - "OpenAPIv2", - "compiler", - "extensions", - ] - pruneopts = "" - revision = "7c663266750e7d82587642f65e60bc4083f1f84e" - version = "v0.2.0" - -[[projects]] - branch = "master" - digest = "1:54a44d48a24a104e078ef5f94d82f025a6be757e7c42b4370c621a3928d6ab7c" - name = "github.com/gophercloud/gophercloud" - packages = [ - ".", - "openstack", - "openstack/dns/v2/recordsets", - "openstack/dns/v2/zones", - "openstack/identity/v2/tenants", - "openstack/identity/v2/tokens", - "openstack/identity/v3/tokens", - "openstack/utils", - "pagination", - ] - pruneopts = "" - revision = "bfc4756e1a693a850d7d459f4b28b21f35a24b5a" - -[[projects]] - digest = "1:dbbeb8ddb0be949954c8157ee8439c2adfd8dc1c9510eb44a6e58cb68c3dce28" - name = "github.com/gorilla/context" - packages = ["."] - pruneopts = "" - revision = "08b5f424b9271eedf6f9f0ce86cb9396ed337a42" - version = "v1.1.1" - -[[projects]] - digest = "1:c2c8666b4836c81a1d247bdf21c6a6fc1ab586538ab56f74437c2e0df5c375e1" - name = "github.com/gorilla/mux" - packages = ["."] - pruneopts = "" - revision = "e3702bed27f0d39777b0b37b664b6280e8ef8fbf" - version = "v1.6.2" - -[[projects]] - branch = "master" - digest = "1:009a1928b8c096338b68b5822d838a72b4d8520715c1463614476359f3282ec8" - name = "github.com/gregjones/httpcache" - packages = [ - ".", - "diskcache", - ] - pruneopts = "" - revision = "9cad4c3443a7200dd6400aef47183728de563a38" - -[[projects]] - digest = "1:8e3bd93036b4a925fe2250d3e4f38f21cadb8ef623561cd80c3c50c114b13201" - name = "github.com/hashicorp/errwrap" - packages = ["."] - pruneopts = "" - revision = "8a6fb523712970c966eefc6b39ed2c5e74880354" - version = "v1.0.0" - -[[projects]] - branch = "master" - digest = "1:72308fdd6d5ef61106a95be7ca72349a5565809042b6426a3cfb61d99483b824" - name = "github.com/hashicorp/go-multierror" - packages = ["."] - pruneopts = "" - revision = "886a7fbe3eb1c874d46f623bfa70af45f425b3d1" - -[[projects]] - digest = "1:3313a63031ae281e5f6fd7b0bbca733dfa04d2429df86519e3b4d4c016ccb836" - name = "github.com/hashicorp/golang-lru" - packages = [ - ".", - "simplelru", - ] - pruneopts = "" - revision = "20f1fb78b0740ba8c3cb143a61e86ba5c8669768" - version = "v0.5.0" - -[[projects]] - digest = "1:af7e132906cb360f4d7c34a9e1434825467f21c4ff5c521ad4cc5b55352876a8" - name = "github.com/imdario/mergo" - packages = ["."] - pruneopts = "" - revision = "6633656539c1639d9d78127b7d47c622b5d7b6dc" - -[[projects]] - digest = "1:870d441fe217b8e689d7949fef6e43efbc787e50f200cb1e70dbca9204a1d6be" - name = "github.com/inconshreveable/mousetrap" - packages = ["."] - pruneopts = "" - revision = "76626ae9c91c4f2a10f34cad8ce83ea42c93bb75" - version = "v1.0" - -[[projects]] - branch = "master" - digest = "1:e0a13d0a368c028716e78448db972657b5292c7238d61405e8289f47c05c8706" - name = "github.com/infobloxopen/infoblox-go-client" - packages = ["."] - pruneopts = "" - revision = "61dc5f9b0a655ebf43026f0d8a837ad1e28e4b96" - -[[projects]] - digest = "1:6f49eae0c1e5dab1dafafee34b207aeb7a42303105960944828c2079b92fc88e" - name = "github.com/jmespath/go-jmespath" - packages = ["."] - pruneopts = "" - revision = "0b12d6b5" - -[[projects]] - digest = "1:53ac4e911e12dde0ab68655e2006449d207a5a681f084974da2b06e5dbeaca72" - name = "github.com/json-iterator/go" - packages = ["."] - pruneopts = "" - revision = "ab8a2e0c74be9d3be70b3184d9acc634935ded82" - version = "1.1.4" - -[[projects]] - branch = "master" - digest = "1:fd50e2c52f29bb81f9a172f0d5aee1438b201ca0502ff3a20ebbe9629e274875" - name = "github.com/kubernetes/repo-infra" - packages = ["verify/boilerplate/test"] - pruneopts = "" - revision = "1bcb110c8726cee477939f507f4760a95e155347" - -[[projects]] - digest = "1:7c23a751ce2f84663fa411acb87eae0da2d09c39a8e99b08bd8f65fae75d8928" - name = "github.com/linki/instrumented_http" - packages = ["."] - pruneopts = "" - revision = "508103cfb3b315fa9752b5bcd4fb2d97bbc26d89" - version = "v0.2.0" - -[[projects]] - digest = "1:1c41354ef11c9dbae2fe1ceee8369fcb2634977ba07e701e19ea53e8742c5420" - name = "github.com/linode/linodego" - packages = ["."] - pruneopts = "" - revision = "7edbc87f0140b7561dbc20458877a56bdded5eb8" - version = "v0.3.0" - -[[projects]] - branch = "master" - digest = "1:63722a4b1e1717be7b98fc686e0b30d5e7f734b9e93d7dee86293b6deab7ea28" - name = "github.com/matttproud/golang_protobuf_extensions" - packages = ["pbutil"] - pruneopts = "" - revision = "c12348ce28de40eed0136aa2b644d0ee0650e56c" - -[[projects]] - digest = "1:4c8d8358c45ba11ab7bb15df749d4df8664ff1582daead28bae58cf8cbe49890" - name = "github.com/miekg/dns" - packages = ["."] - pruneopts = "" - revision = "5a2b9fab83ff0f8bfc99684bd5f43a37abe560f1" - version = "v1.0.8" - -[[projects]] - digest = "1:0c0ff2a89c1bb0d01887e1dac043ad7efbf3ec77482ef058ac423d13497e16fd" - name = "github.com/modern-go/concurrent" - packages = ["."] - pruneopts = "" - revision = "bacd9c7ef1dd9b15be4a9909b8ac7a4e313eec94" - version = "1.0.3" - -[[projects]] - digest = "1:e32bdbdb7c377a07a9a46378290059822efdce5c8d96fe71940d87cb4f918855" - name = "github.com/modern-go/reflect2" - packages = ["."] - pruneopts = "" - revision = "4b7aa43c6742a2c18fdef89dd197aaae7dac7ccd" - version = "1.0.1" - -[[projects]] - digest = "1:11c58e19ff7ce22740423bb933f1ddca3bf575def40d5ac3437ec12871b1648b" - name = "github.com/natefinch/lumberjack" - packages = ["."] - pruneopts = "" - revision = "a96e63847dc3c67d17befa69c303767e2f84e54f" - version = "v2.1" - -[[projects]] - digest = "1:d8b5d0ecca348c835914a1ed8589f17a6a7f309befab7327b0470324531f7ac4" - name = "github.com/nesv/go-dynect" - packages = ["dynect"] - pruneopts = "" - revision = "cdd946344b54bdf7dbeac406c2f1fe93150f08ea" - version = "v0.6.0" - -[[projects]] - digest = "1:70df8e71a953626770223d4982801fa73e7e99cbfcca068b95127f72af9b9edd" - name = "github.com/oracle/oci-go-sdk" - packages = [ - "common", - "dns", - ] - pruneopts = "" - revision = "a2ded717dc4bb4916c0416ec79f81718b576dbc4" - version = "v1.8.0" - -[[projects]] - branch = "master" - digest = "1:c24598ffeadd2762552269271b3b1510df2d83ee6696c1e543a0ff653af494bc" - name = "github.com/petar/GoLLRB" - packages = ["llrb"] - pruneopts = "" - revision = "53be0d36a84c2a886ca057d34b6aa4468df9ccb4" - -[[projects]] - digest = "1:b46305723171710475f2dd37547edd57b67b9de9f2a6267cafdd98331fd6897f" - name = "github.com/peterbourgon/diskv" - packages = ["."] - pruneopts = "" - revision = "5f041e8faa004a95c88a202771f4cc3e991971e6" - version = "v2.0.1" - -[[projects]] - digest = "1:cf172c58bb2a13ed39ea1c9e79525567c63bcc2c4afbb6cf023e87b31780f249" - name = "github.com/pkg/errors" - packages = ["."] - pruneopts = "" - revision = "f15c970de5b76fac0b59abb32d62c17cc7bed265" - -[[projects]] - digest = "1:256484dbbcd271f9ecebc6795b2df8cad4c458dd0f5fd82a8c2fa0c29f233411" - name = "github.com/pmezard/go-difflib" - packages = ["difflib"] - pruneopts = "" - revision = "792786c7400a136282c1664665ae0a8db921c6c2" - version = "v1.0.0" - -[[projects]] - digest = "1:2f69dc6b2685b31a1a410ef697410aa8a669704fb201d45dbd8c1911728afa75" - name = "github.com/prometheus/client_golang" - packages = [ - "prometheus", - "prometheus/promhttp", - ] - pruneopts = "" - revision = "967789050ba94deca04a5e84cce8ad472ce313c1" - version = "v0.9.0-pre1" - -[[projects]] - branch = "master" - digest = "1:60aca47f4eeeb972f1b9da7e7db51dee15ff6c59f7b401c1588b8e6771ba15ef" - name = "github.com/prometheus/client_model" - packages = ["go"] - pruneopts = "" - revision = "99fa1f4be8e564e8a6b613da7fa6f46c9edafc6c" - -[[projects]] - branch = "master" - digest = "1:e3aa5178be4fc4ae8cdb37d11c02f7490c00450a9f419e6aa84d02d3b47e90d2" - name = "github.com/prometheus/common" - packages = [ - "expfmt", - "internal/bitbucket.org/ww/goautoneg", - "model", - ] - pruneopts = "" - revision = "2e54d0b93cba2fd133edc32211dcc32c06ef72ca" - -[[projects]] - digest = "1:a6a85fc81f2a06ccac3d45005523afbeee45138d781d4f3cb7ad9889d5c65aab" - name = "github.com/prometheus/procfs" - packages = [ - ".", - "xfs", - ] - pruneopts = "" - revision = "a6e9df898b1336106c743392c48ee0b71f5c4efa" - -[[projects]] - digest = "1:7f569d906bdd20d906b606415b7d794f798f91a62fcfb6a4daa6d50690fb7a3f" - name = "github.com/satori/go.uuid" - packages = ["."] - pruneopts = "" - revision = "f58768cc1a7a7e77a3bd49e98cdd21419399b6a3" - version = "v1.2.0" - -[[projects]] - digest = "1:3ac248add5bb40a3c631c5334adcd09aa72d15af2768a5bc0274084ea7b2e5ba" - name = "github.com/sirupsen/logrus" - packages = ["."] - pruneopts = "" - revision = "f006c2ac4710855cf0f916dd6b77acf6b048dc6e" - version = "v1.0.3" - -[[projects]] - digest = "1:a1403cc8a94b8d7956ee5e9694badef0e7b051af289caad1cf668331e3ffa4f6" - name = "github.com/spf13/cobra" - packages = ["."] - pruneopts = "" - revision = "ef82de70bb3f60c65fb8eebacbb2d122ef517385" - version = "v0.0.3" - -[[projects]] - digest = "1:0a52bcb568386d98f4894575d53ce3e456f56471de6897bb8b9de13c33d9340e" - name = "github.com/spf13/pflag" - packages = ["."] - pruneopts = "" - revision = "9a97c102cda95a86cec2345a6f09f55a939babf5" - version = "v1.0.2" - -[[projects]] - digest = "1:306417ea2f31ea733df356a2b895de63776b6a5107085b33458e5cd6eb1d584d" - name = "github.com/stretchr/objx" - packages = ["."] - pruneopts = "" - revision = "facf9a85c22f48d2f52f2380e4efce1768749a89" - version = "v0.1" - -[[projects]] - digest = "1:a30066593578732a356dc7e5d7f78d69184ca65aeeff5939241a3ab10559bb06" - name = "github.com/stretchr/testify" - packages = [ - "assert", - "mock", - "require", - "suite", - ] - pruneopts = "" - revision = "12b6f73e6084dad08a7c6e575284b177ecafbc71" - version = "v1.2.1" - -[[projects]] - branch = "master" - digest = "1:81f435c83e3523a7ee3f277769727f73ca66218ca8188d96a0935a4841b47a76" - name = "github.com/tent/http-link-go" - packages = ["."] - pruneopts = "" - revision = "ac974c61c2f990f4115b119354b5e0b47550e888" - -[[projects]] - digest = "1:74f86c458e82e1c4efbab95233e0cf51b7cc02dc03193be9f62cd81224e10401" - name = "go.uber.org/atomic" - packages = ["."] - pruneopts = "" - revision = "1ea20fb1cbb1cc08cbd0d913a96dead89aa18289" - version = "v1.3.2" - -[[projects]] - digest = "1:22c7effcb4da0eacb2bb1940ee173fac010e9ef3c691f5de4b524d538bd980f5" - name = "go.uber.org/multierr" - packages = ["."] - pruneopts = "" - revision = "3c4937480c32f4c13a875a1829af76c98ca3d40a" - version = "v1.1.0" - -[[projects]] - digest = "1:246f378f80fba6fcf0f191c486b6613265abd2bc0f2fa55a36b928c67352021e" - name = "go.uber.org/zap" - packages = [ - ".", - "buffer", - "internal/bufferpool", - "internal/color", - "internal/exit", - "zapcore", - "zapgrpc", - ] - pruneopts = "" - revision = "ff33455a0e382e8a81d14dd7c922020b6b5e7982" - version = "v1.9.1" - -[[projects]] - branch = "master" - digest = "1:b2d8b39397ca07929a3de3a3fd2b6ca4c8d48e9cadaa7cf2b083e27fd9e78107" - name = "golang.org/x/crypto" - packages = [ - "ed25519", - "ed25519/internal/edwards25519", - "ssh/terminal", - ] - pruneopts = "" - revision = "0709b304e793a5edb4a2c0145f281ecdc20838a4" - -[[projects]] - digest = "1:782723d6fc27d202f1943219d68d58b3f6bcab6212c85294b1ddd8b586b1d356" - name = "golang.org/x/net" - packages = [ - "bpf", - "context", - "context/ctxhttp", - "http/httpguts", - "http2", - "http2/hpack", - "idna", - "internal/iana", - "internal/socket", - "internal/timeseries", - "ipv4", - "ipv6", - "publicsuffix", - "trace", - ] - pruneopts = "" - revision = "161cd47e91fd58ac17490ef4d742dc98bb4cf60e" - -[[projects]] - digest = "1:dad5a319c4710358be1f2bf68f9fb7f90a71d7c641221b79801d5667b28f19e3" - name = "golang.org/x/oauth2" - packages = [ - ".", - "google", - "internal", - "jws", - "jwt", - ] - pruneopts = "" - revision = "3c3a985cb79f52a3190fbc056984415ca6763d01" - -[[projects]] - digest = "1:39d88a855976e160babdd254802e1c2ae75ed380328c39742b57928852da6207" - name = "golang.org/x/sys" - packages = [ - "unix", - "windows", - ] - pruneopts = "" - revision = "13d03a9a82fba647c21a0ef8fba44a795d0f0835" - -[[projects]] - digest = "1:5acd3512b047305d49e8763eef7ba423901e85d5dd2fd1e71778a0ea8de10bd4" - name = "golang.org/x/text" - packages = [ - "collate", - "collate/build", - "internal/colltab", - "internal/gen", - "internal/tag", - "internal/triegen", - "internal/ucd", - "language", - "secure/bidirule", - "transform", - "unicode/bidi", - "unicode/cldr", - "unicode/norm", - "unicode/rangetable", - ] - pruneopts = "" - revision = "f21a4dfb5e38f5895301dc265a8def02365cc3d0" - version = "v0.3.0" - -[[projects]] - branch = "master" - digest = "1:55a681cb66f28755765fa5fa5104cbd8dc85c55c02d206f9f89566451e3fe1aa" - name = "golang.org/x/time" - packages = ["rate"] - pruneopts = "" - revision = "fbb02b2291d28baffd63558aa44b4b56f178d650" - -[[projects]] - digest = "1:2ad38d79865e33dde6157b7048debd6e7d47e0709df7b5e11bb888168e316908" - name = "google.golang.org/api" - packages = [ - "dns/v1", - "gensupport", - "googleapi", - "googleapi/internal/uritemplates", - ] - pruneopts = "" - revision = "a0ff90edab763c86aa88f2b1eb4f3301b82f6336" - -[[projects]] - digest = "1:41e2b7e287117f6136f75286d48072ecf781ba54823ffeb2098e152e7dc45ef6" - name = "google.golang.org/appengine" - packages = [ - ".", - "internal", - "internal/app_identity", - "internal/base", - "internal/datastore", - "internal/log", - "internal/modules", - "internal/remote_api", - "internal/urlfetch", - "urlfetch", - ] - pruneopts = "" - revision = "4f7eeb5305a4ba1966344836ba4af9996b7b4e05" - -[[projects]] - branch = "master" - digest = "1:e43f1cb3f488a0c2be85939c2a594636f60b442a12a196c778bd2d6c9aca3df7" - name = "google.golang.org/genproto" - packages = [ - "googleapis/api/annotations", - "googleapis/rpc/status", - ] - pruneopts = "" - revision = "11092d34479b07829b72e10713b159248caf5dad" - -[[projects]] - digest = "1:ca75b3775a5d4e5d1fb48f57ef0865b4aaa8b3f00e6b52be68db991c4594e0a7" - name = "google.golang.org/grpc" - packages = [ - ".", - "balancer", - "balancer/base", - "balancer/roundrobin", - "codes", - "connectivity", - "credentials", - "encoding", - "encoding/proto", - "grpclog", - "health/grpc_health_v1", - "internal", - "internal/backoff", - "internal/channelz", - "internal/envconfig", - "internal/grpcrand", - "internal/transport", - "keepalive", - "metadata", - "naming", - "peer", - "resolver", - "resolver/dns", - "resolver/passthrough", - "stats", - "status", - "tap", - ] - pruneopts = "" - revision = "32fb0ac620c32ba40a4626ddf94d90d12cce3455" - version = "v1.14.0" - -[[projects]] - digest = "1:e5d1fb981765b6f7513f793a3fcaac7158408cca77f75f7311ac82cc88e9c445" - name = "gopkg.in/inf.v0" - packages = ["."] - pruneopts = "" - revision = "3887ee99ecf07df5b447e9b00d9c0b2adaa9f3e4" - version = "v0.9.0" - -[[projects]] - digest = "1:f0620375dd1f6251d9973b5f2596228cc8042e887cd7f827e4220bc1ce8c30e2" - name = "gopkg.in/yaml.v2" - packages = ["."] - pruneopts = "" - revision = "5420a8b6744d3b0345ab293f6fcba19c978f1183" - version = "v2.2.1" - -[[projects]] - branch = "release-1.0" - digest = "1:bc43af6616d8ca12a7b8e806874387f0f1e181c08f547e9cd77f6cbac8cefd86" - name = "istio.io/api" - packages = [ - "authentication/v1alpha1", - "mesh/v1alpha1", - "mixer/v1", - "mixer/v1/config/client", - "networking/v1alpha3", - "rbac/v1alpha1", - ] - pruneopts = "" - revision = "76349c53b87f03f1e610b3aa3843dba3c38138d7" - -[[projects]] - digest = "1:7eb25280e1f610470bb0c43ab6c91573cfc78672a58542106b9b71705581429a" - name = "istio.io/istio" - packages = [ - "pilot/pkg/config/kube/crd", - "pilot/pkg/model", - "pilot/pkg/model/test", - "pilot/pkg/serviceregistry/kube", - "pkg/cache", - "pkg/kube", - "pkg/log", - ] - pruneopts = "" - revision = "42773aacced474d97159902d20579a25b1f98106" - version = "1.0.1" - -[[projects]] - digest = "1:f420c8548c93242d8e5dcfa5b34e0243883b4e660f65076e869daafac877144d" - name = "k8s.io/api" - packages = [ - "admissionregistration/v1alpha1", - "admissionregistration/v1beta1", - "apps/v1", - "apps/v1beta1", - "apps/v1beta2", - "authentication/v1", - "authentication/v1beta1", - "authorization/v1", - "authorization/v1beta1", - "autoscaling/v1", - "autoscaling/v2beta1", - "batch/v1", - "batch/v1beta1", - "batch/v2alpha1", - "certificates/v1beta1", - "core/v1", - "events/v1beta1", - "extensions/v1beta1", - "networking/v1", - "policy/v1beta1", - "rbac/v1", - "rbac/v1alpha1", - "rbac/v1beta1", - "scheduling/v1alpha1", - "scheduling/v1beta1", - "settings/v1alpha1", - "storage/v1", - "storage/v1alpha1", - "storage/v1beta1", - ] - pruneopts = "" - revision = "072894a440bdee3a891dea811fe42902311cd2a3" - version = "kubernetes-1.11.0" - -[[projects]] - digest = "1:66d1421ecff35bc48ee0b11a3f891f3af6f775ed6bb1d3e0deeaba221bf42490" - name = "k8s.io/apiextensions-apiserver" - packages = [ - "pkg/apis/apiextensions", - "pkg/apis/apiextensions/v1beta1", - "pkg/client/clientset/clientset", - "pkg/client/clientset/clientset/scheme", - "pkg/client/clientset/clientset/typed/apiextensions/v1beta1", - ] - pruneopts = "" - revision = "8e7f43002fec5394a8d96ebca781aa9d4b37aaef" - version = "kubernetes-1.10.4" - -[[projects]] - digest = "1:b6b2fb7b4da1ac973b64534ace2299a02504f16bc7820cb48edb8ca4077183e1" - name = "k8s.io/apimachinery" - packages = [ - "pkg/api/errors", - "pkg/api/meta", - "pkg/api/resource", - "pkg/apis/meta/internalversion", - "pkg/apis/meta/v1", - "pkg/apis/meta/v1/unstructured", - "pkg/apis/meta/v1beta1", - "pkg/conversion", - "pkg/conversion/queryparams", - "pkg/fields", - "pkg/labels", - "pkg/runtime", - "pkg/runtime/schema", - "pkg/runtime/serializer", - "pkg/runtime/serializer/json", - "pkg/runtime/serializer/protobuf", - "pkg/runtime/serializer/recognizer", - "pkg/runtime/serializer/streaming", - "pkg/runtime/serializer/versioning", - "pkg/selection", - "pkg/types", - "pkg/util/cache", - "pkg/util/clock", - "pkg/util/diff", - "pkg/util/errors", - "pkg/util/framer", - "pkg/util/intstr", - "pkg/util/json", - "pkg/util/mergepatch", - "pkg/util/net", - "pkg/util/runtime", - "pkg/util/sets", - "pkg/util/strategicpatch", - "pkg/util/validation", - "pkg/util/validation/field", - "pkg/util/wait", - "pkg/util/yaml", - "pkg/version", - "pkg/watch", - "third_party/forked/golang/json", - "third_party/forked/golang/reflect", - ] - pruneopts = "" - revision = "103fd098999dc9c0c88536f5c9ad2e5da39373ae" - version = "kubernetes-1.11.0" - -[[projects]] - digest = "1:d04779a8de7d5465e0463bd986506348de5e89677c74777f695d3145a7a8d15e" - name = "k8s.io/client-go" - packages = [ - "discovery", - "discovery/fake", - "kubernetes", - "kubernetes/fake", - "kubernetes/scheme", - "kubernetes/typed/admissionregistration/v1alpha1", - "kubernetes/typed/admissionregistration/v1alpha1/fake", - "kubernetes/typed/admissionregistration/v1beta1", - "kubernetes/typed/admissionregistration/v1beta1/fake", - "kubernetes/typed/apps/v1", - "kubernetes/typed/apps/v1/fake", - "kubernetes/typed/apps/v1beta1", - "kubernetes/typed/apps/v1beta1/fake", - "kubernetes/typed/apps/v1beta2", - "kubernetes/typed/apps/v1beta2/fake", - "kubernetes/typed/authentication/v1", - "kubernetes/typed/authentication/v1/fake", - "kubernetes/typed/authentication/v1beta1", - "kubernetes/typed/authentication/v1beta1/fake", - "kubernetes/typed/authorization/v1", - "kubernetes/typed/authorization/v1/fake", - "kubernetes/typed/authorization/v1beta1", - "kubernetes/typed/authorization/v1beta1/fake", - "kubernetes/typed/autoscaling/v1", - "kubernetes/typed/autoscaling/v1/fake", - "kubernetes/typed/autoscaling/v2beta1", - "kubernetes/typed/autoscaling/v2beta1/fake", - "kubernetes/typed/batch/v1", - "kubernetes/typed/batch/v1/fake", - "kubernetes/typed/batch/v1beta1", - "kubernetes/typed/batch/v1beta1/fake", - "kubernetes/typed/batch/v2alpha1", - "kubernetes/typed/batch/v2alpha1/fake", - "kubernetes/typed/certificates/v1beta1", - "kubernetes/typed/certificates/v1beta1/fake", - "kubernetes/typed/core/v1", - "kubernetes/typed/core/v1/fake", - "kubernetes/typed/events/v1beta1", - "kubernetes/typed/events/v1beta1/fake", - "kubernetes/typed/extensions/v1beta1", - "kubernetes/typed/extensions/v1beta1/fake", - "kubernetes/typed/networking/v1", - "kubernetes/typed/networking/v1/fake", - "kubernetes/typed/policy/v1beta1", - "kubernetes/typed/policy/v1beta1/fake", - "kubernetes/typed/rbac/v1", - "kubernetes/typed/rbac/v1/fake", - "kubernetes/typed/rbac/v1alpha1", - "kubernetes/typed/rbac/v1alpha1/fake", - "kubernetes/typed/rbac/v1beta1", - "kubernetes/typed/rbac/v1beta1/fake", - "kubernetes/typed/scheduling/v1alpha1", - "kubernetes/typed/scheduling/v1alpha1/fake", - "kubernetes/typed/scheduling/v1beta1", - "kubernetes/typed/scheduling/v1beta1/fake", - "kubernetes/typed/settings/v1alpha1", - "kubernetes/typed/settings/v1alpha1/fake", - "kubernetes/typed/storage/v1", - "kubernetes/typed/storage/v1/fake", - "kubernetes/typed/storage/v1alpha1", - "kubernetes/typed/storage/v1alpha1/fake", - "kubernetes/typed/storage/v1beta1", - "kubernetes/typed/storage/v1beta1/fake", - "pkg/apis/clientauthentication", - "pkg/apis/clientauthentication/v1alpha1", - "pkg/apis/clientauthentication/v1beta1", - "pkg/version", - "plugin/pkg/client/auth", - "plugin/pkg/client/auth/azure", - "plugin/pkg/client/auth/exec", - "plugin/pkg/client/auth/gcp", - "plugin/pkg/client/auth/oidc", - "plugin/pkg/client/auth/openstack", - "rest", - "rest/fake", - "rest/watch", - "testing", - "third_party/forked/golang/template", - "tools/auth", - "tools/cache", - "tools/clientcmd", - "tools/clientcmd/api", - "tools/clientcmd/api/latest", - "tools/clientcmd/api/v1", - "tools/metrics", - "tools/pager", - "tools/reference", - "transport", - "util/buffer", - "util/cert", - "util/connrotation", - "util/flowcontrol", - "util/homedir", - "util/integer", - "util/jsonpath", - "util/retry", - ] - pruneopts = "" - revision = "7d04d0e2a0a1a4d4a1cd6baa432a2301492e4e65" - version = "v8.0.0" - -[[projects]] - branch = "master" - digest = "1:526095379da1098c3f191a0008cc59c9bf9927492e63da7689e5de424219c162" - name = "k8s.io/kube-openapi" - packages = ["pkg/util/proto"] - pruneopts = "" - revision = "d8ea2fe547a448256204cfc68dfee7b26c720acb" - -[solve-meta] - analyzer-name = "dep" - analyzer-version = 1 - input-imports = [ - "cloud.google.com/go/compute/metadata", - "github.com/Azure/azure-sdk-for-go/arm/dns", - "github.com/Azure/go-autorest/autorest", - "github.com/Azure/go-autorest/autorest/adal", - "github.com/Azure/go-autorest/autorest/azure", - "github.com/Azure/go-autorest/autorest/to", - "github.com/alecthomas/kingpin", - "github.com/aliyun/alibaba-cloud-sdk-go/sdk/requests", - "github.com/aliyun/alibaba-cloud-sdk-go/services/alidns", - "github.com/aliyun/alibaba-cloud-sdk-go/services/pvtz", - "github.com/aws/aws-sdk-go/aws", - "github.com/aws/aws-sdk-go/aws/credentials/stscreds", - "github.com/aws/aws-sdk-go/aws/request", - "github.com/aws/aws-sdk-go/aws/session", - "github.com/aws/aws-sdk-go/service/route53", - "github.com/aws/aws-sdk-go/service/servicediscovery", - "github.com/cloudflare/cloudflare-go", - "github.com/coreos/etcd/clientv3", - "github.com/denverdino/aliyungo/metadata", - "github.com/digitalocean/godo", - "github.com/digitalocean/godo/context", - "github.com/dnsimple/dnsimple-go/dnsimple", - "github.com/exoscale/egoscale", - "github.com/ffledgling/pdns-go", - "github.com/gophercloud/gophercloud", - "github.com/gophercloud/gophercloud/openstack", - "github.com/gophercloud/gophercloud/openstack/dns/v2/recordsets", - "github.com/gophercloud/gophercloud/openstack/dns/v2/zones", - "github.com/gophercloud/gophercloud/pagination", - "github.com/infobloxopen/infoblox-go-client", - "github.com/kubernetes/repo-infra/verify/boilerplate/test", - "github.com/linki/instrumented_http", - "github.com/linode/linodego", - "github.com/miekg/dns", - "github.com/nesv/go-dynect/dynect", - "github.com/oracle/oci-go-sdk/common", - "github.com/oracle/oci-go-sdk/dns", - "github.com/pkg/errors", - "github.com/prometheus/client_golang/prometheus", - "github.com/prometheus/client_golang/prometheus/promhttp", - "github.com/sirupsen/logrus", - "github.com/stretchr/testify/assert", - "github.com/stretchr/testify/mock", - "github.com/stretchr/testify/require", - "github.com/stretchr/testify/suite", - "golang.org/x/net/context", - "golang.org/x/oauth2", - "golang.org/x/oauth2/google", - "google.golang.org/api/dns/v1", - "google.golang.org/api/googleapi", - "gopkg.in/yaml.v2", - "istio.io/api/networking/v1alpha3", - "istio.io/istio/pilot/pkg/config/kube/crd", - "istio.io/istio/pilot/pkg/model", - "k8s.io/api/core/v1", - "k8s.io/api/extensions/v1beta1", - "k8s.io/apimachinery/pkg/api/errors", - "k8s.io/apimachinery/pkg/apis/meta/v1", - "k8s.io/apimachinery/pkg/labels", - "k8s.io/apimachinery/pkg/runtime", - "k8s.io/apimachinery/pkg/runtime/schema", - "k8s.io/apimachinery/pkg/runtime/serializer", - "k8s.io/client-go/kubernetes", - "k8s.io/client-go/kubernetes/fake", - "k8s.io/client-go/plugin/pkg/client/auth", - "k8s.io/client-go/rest", - "k8s.io/client-go/rest/fake", - "k8s.io/client-go/tools/clientcmd", - ] - solver-name = "gps-cdcl" - solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml deleted file mode 100644 index 2887deaab..000000000 --- a/Gopkg.toml +++ /dev/null @@ -1,117 +0,0 @@ -# Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md -# for detailed Gopkg.toml documentation. - -required = ["github.com/kubernetes/repo-infra/verify/boilerplate/test"] -ignored = ["github.com/kubernetes/repo-infra/kazel"] - -[[constraint]] - name = "github.com/Azure/azure-sdk-for-go" - version = "~10.0.4-beta" - -[[constraint]] - name = "github.com/Azure/go-autorest" - version = "~10.9.0" - -[[constraint]] - name = "github.com/alecthomas/kingpin" - version = "~2.2.4" - -[[constraint]] - name = "github.com/aws/aws-sdk-go" - version = "~1.13.7" - -[[constraint]] - name = "github.com/cloudflare/cloudflare-go" - version = "0.7.3" - -[[constraint]] - name = "github.com/digitalocean/godo" - version = "~1.1.0" - -[[constraint]] - name = "github.com/dnsimple/dnsimple-go" - version = "0.14.0" - -[[constraint]] - branch = "master" - name = "github.com/infobloxopen/infoblox-go-client" - -[[constraint]] - name = "github.com/linki/instrumented_http" - version = "0.2.0" - -[[constraint]] - name = "github.com/prometheus/client_golang" - version = "0.9.0-pre1" - -[[constraint]] - name = "github.com/sirupsen/logrus" - version = "~1.0.3" - -[[constraint]] - name = "github.com/stretchr/testify" - version = "~1.2.1" - -[[override]] - name = "github.com/kubernetes/repo-infra" - branch = "master" - -[[constraint]] - name = "github.com/nesv/go-dynect" - version = "0.6.0" - -[[constraint]] - name = "github.com/exoscale/egoscale" - version = "~0.11.0" - -[[constraint]] - name = "github.com/oracle/oci-go-sdk" - version = "1.8.0" - -[[constraint]] - name = "github.com/linode/linodego" - version = "0.3.0" - -[[constraint]] - name = "github.com/aliyun/alibaba-cloud-sdk-go" - version = "1.27.7" - -[[constraint]] - name = "istio.io/istio" - version = "1.0.0" - -[[override]] - name = "github.com/golang/glog" - source = "github.com/kubermatic/glog-logrus" - -[[override]] - name = "github.com/golang/protobuf" - version = "1.1.0" - -[[constraint]] - name = "k8s.io/client-go" - version = "8.0.0" - -[[override]] - name = "k8s.io/apimachinery" - version = "kubernetes-1.11.0" - -[[override]] - name = "k8s.io/api" - version = "kubernetes-1.11.0" - -[[override]] - name = "golang.org/x/sys" - revision = "13d03a9a82fba647c21a0ef8fba44a795d0f0835" - -[[override]] - name = "github.com/spf13/pflag" - version = "1.0.2" - -[[override]] - name = "golang.org/x/net" - revision = "161cd47e91fd58ac17490ef4d742dc98bb4cf60e" - -[[constraint]] - name = "github.com/miekg/dns" - version = "1.0.8" diff --git a/Makefile b/Makefile index 9771ab8f2..765fecb9f 100644 --- a/Makefile +++ b/Makefile @@ -27,9 +27,12 @@ cover: cover-html: cover go tool cover -html cover.out -dep: - curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh - dep ensure -vendor-only +.PHONY: lint + +# Run all the linters +lint: + golangci-lint run ./... + # The verify target runs tasks similar to the CI tasks, but without code coverage .PHONY: verify test @@ -37,12 +40,8 @@ dep: test: go test -v -race $(shell go list ./... | grep -v /vendor/) -verify: test - vendor/github.com/kubernetes/repo-infra/verify/verify-boilerplate.sh --rootdir=${CURDIR} - vendor/github.com/kubernetes/repo-infra/verify/verify-go-src.sh -v --rootdir ${CURDIR} - # The build targets allow to build the binary and docker image -.PHONY: build build.docker +.PHONY: build build.docker build.mini BINARY ?= external-dns SOURCES = $(shell find . -name '*.go') @@ -62,5 +61,8 @@ build.push: build.docker build.docker: docker build --rm --tag "$(IMAGE):$(VERSION)" . +build.mini: + docker build --rm --tag "$(IMAGE):$(VERSION)" -f Dockerfile.mini . + clean: @rm -rf build diff --git a/OWNERS b/OWNERS index 5c33fb7d6..f82ca6dfe 100644 --- a/OWNERS +++ b/OWNERS @@ -2,12 +2,7 @@ # https://github.com/kubernetes/kubernetes/blob/master/docs/devel/owners.md approvers: - - justinsb - hjacobs - raffo - linki - - ideahitme - - chrislovecnm - - kris-nova - - iterion - njuettner diff --git a/README.md b/README.md index 25c2b5536..83706d3f3 100644 --- a/README.md +++ b/README.md @@ -13,22 +13,23 @@ ExternalDNS synchronizes exposed Kubernetes Services and Ingresses with DNS prov ## What It Does -Inspired by [Kubernetes DNS](https://github.com/kubernetes/dns), Kubernetes' cluster-internal DNS server, ExternalDNS makes Kubernetes resources discoverable via public DNS servers. Like KubeDNS, it retrieves a list of resources (Services, Ingresses, etc.) from the [Kubernetes API](https://kubernetes.io/docs/api/) to determine a desired list of DNS records. *Unlike* KubeDNS, however, it's not a DNS server itself, but merely configures other DNS providers accordingly—e.g. [AWS Route 53](https://aws.amazon.com/route53/) or [Google CloudDNS](https://cloud.google.com/dns/docs/). +Inspired by [Kubernetes DNS](https://github.com/kubernetes/dns), Kubernetes' cluster-internal DNS server, ExternalDNS makes Kubernetes resources discoverable via public DNS servers. Like KubeDNS, it retrieves a list of resources (Services, Ingresses, etc.) from the [Kubernetes API](https://kubernetes.io/docs/api/) to determine a desired list of DNS records. *Unlike* KubeDNS, however, it's not a DNS server itself, but merely configures other DNS providers accordingly—e.g. [AWS Route 53](https://aws.amazon.com/route53/) or [Google Cloud DNS](https://cloud.google.com/dns/docs/). In a broader sense, ExternalDNS allows you to control DNS records dynamically via Kubernetes resources in a DNS provider-agnostic way. The [FAQ](docs/faq.md) contains additional information and addresses several questions about key concepts of ExternalDNS. -To see ExternalDNS in action, have a look at this [video](https://www.youtube.com/watch?v=9HQ2XgL9YVI). +To see ExternalDNS in action, have a look at this [video](https://www.youtube.com/watch?v=9HQ2XgL9YVI) or read this [blogpost](https://medium.com/wearetheledger/deploying-test-environments-with-azure-devops-eks-and-externaldns-67abe647e4e). ## The Latest Release: v0.5 ExternalDNS' current release is `v0.5`. This version allows you to keep selected zones (via `--domain-filter`) synchronized with Ingresses and Services of `type=LoadBalancer` in various cloud providers: -* [Google CloudDNS](https://cloud.google.com/dns/docs/) +* [Google Cloud DNS](https://cloud.google.com/dns/docs/) * [AWS Route 53](https://aws.amazon.com/route53/) * [AWS Service Discovery](https://docs.aws.amazon.com/Route53/latest/APIReference/overview-service-discovery.html) * [AzureDNS](https://azure.microsoft.com/en-us/services/dns) * [CloudFlare](https://www.cloudflare.com/dns) +* [RcodeZero](https://www.rcodezero.at/) * [DigitalOcean](https://www.digitalocean.com/products/networking) * [DNSimple](https://dnsimple.com/) * [Infoblox](https://www.infoblox.com/products/dns/) @@ -39,7 +40,10 @@ ExternalDNS' current release is `v0.5`. This version allows you to keep selected * [Exoscale](https://www.exoscale.com/dns/) * [Oracle Cloud Infrastructure DNS](https://docs.cloud.oracle.com/iaas/Content/DNS/Concepts/dnszonemanagement.htm) * [Linode DNS](https://www.linode.com/docs/networking/dns/) -* [RFC2136](https://tools.ietf.org/html/rfc2136) +* [RFC2136](https://tools.ietf.org/html/rfc2136) +* [NS1](https://ns1.com/) +* [TransIP](https://www.transip.eu/domain-name/) + From this release, ExternalDNS can become aware of the records it is managing (enabled via `--registry=txt`), therefore ExternalDNS can safely manage non-empty hosted zones. We strongly encourage you to use `v0.5` (or greater) with `--registry=txt` enabled and `--txt-owner-id` set to a unique value that doesn't change for the lifetime of your cluster. You might also want to run ExternalDNS in a dry run mode (`--dry-run` flag) to see the changes to be submitted to your DNS Provider API. @@ -47,7 +51,48 @@ Note that all flags can be replaced with environment variables; for instance, `--dry-run` could be replaced with `EXTERNAL_DNS_DRY_RUN=1`, or `--registry txt` could be replaced with `EXTERNAL_DNS_REGISTRY=txt`. -## Deploying to a Cluster +## Status of providers + +ExternalDNS supports multiple DNS providers which have been implemented by the [ExternalDNS contributors](https://github.com/kubernetes-incubator/external-dns/graphs/contributors). Maintaining all of those in a central repository is a challenge and we have limited resources to test changes. This means that it is very hard to test all providers for possible regressions and, as written in the [Contributing](## Contributing) section, we encourage contributors to step in as maintainers for the individual providers and help by testing the integrations. +We define the following stability levels for providers: + +- **Stable**: Used for smoke tests before a release, used in production and maintainers are active. +- **Beta**: Community supported, well tested, but maintainers have no access to resources to execute integration tests on the real platform and/or are not using it in production. +- **Alpha**: Community provided with no support from the maintainers apart from reviewing PRs. + +The following table clarifies the current status of the providers according to the aforementioned stability levels: + +| Provider | Status | +| -------- | ------ | +| Google Cloud DNS | Stable | +| AWS Route 53 | Stable | +| AWS Service Discovery | Beta | +| AzureDNS | Beta | +| CloudFlare | Beta +| RcodeZero | Alpha | +| DigitalOcean | Alpha | +| DNSimple | Alpha | +| Infoblox | Alpha | +| Dyn | Alpha | +| OpenStack Designate | Alpha | +| PowerDNS | Alpha | +| CoreDNS | Alpha | +| Exoscale | Alpha | +| Oracle Cloud Infrastructure DNS | Alpha | +| Linode DNS | Alpha | +| RFC2136 | Alpha | +| NS1 | Alpha | +| TransIP | Alpha | + + +## Running ExternalDNS: + +The are two ways of running ExternalDNS: + +* Deploying to a Cluster +* Running Locally + +### Deploying to a Cluster The following tutorials are provided: @@ -57,6 +102,7 @@ The following tutorials are provided: * [Azure](docs/tutorials/azure.md) * [CoreDNS](docs/tutorials/coredns.md) * [Cloudflare](docs/tutorials/cloudflare.md) +* [RcodeZero](docs/tutorials/rcodezero.md) * [DigitalOcean](docs/tutorials/digitalocean.md) * [Infoblox](docs/tutorials/infoblox.md) * [Dyn](docs/tutorials/dyn.md) @@ -67,25 +113,35 @@ The following tutorials are provided: * [Oracle Cloud Infrastructure (OCI) DNS](docs/tutorials/oracle.md) * [Linode](docs/tutorials/linode.md) * [RFC2136](docs/tutorials/rfc2136.md) +* [NS1](docs/tutorials/ns1.md) +* [TransIP](docs/tutorials/transip.md) -## Running Locally +### Running Locally -### Technical Requirements +#### Technical Requirements Make sure you have the following prerequisites: * A local Go 1.7+ development environment. * Access to a Google/AWS account with the DNS API enabled. * Access to a Kubernetes cluster that supports exposing Services, e.g. GKE. -### Setup Steps +#### Setup Steps First, get ExternalDNS: -**To install all dependencies, make sure to install [dep](https://github.com/golang/dep) first.** - ```console $ git clone https://github.com/kubernetes-incubator/external-dns.git && cd external-dns -$ dep ensure -vendor-only +``` + +**This project uses [Go modules](https://github.com/golang/go/wiki/Modules) as +introduced in Go 1.11 therefore you need Go >=1.11 installed in order to build.** +If using Go 1.11 you also need to [activate Module +support](https://github.com/golang/go/wiki/Modules#installing-and-activating-module-support). + +Assuming Go has been setup with module support it can be built simply by running: + +```console +$ export GO111MODULE=on # needed if the project is checked out in your $GOPATH. $ make ``` @@ -180,6 +236,9 @@ Here's a rough outline on what is to come (subject to change): - [x] Support for OpenStack Designate - [x] Support for PowerDNS - [x] Support for Linode +- [x] Support for RcodeZero +- [x] Support for NS1 +- [x] Support for TransIP ### v0.6 diff --git a/SECURITY_CONTACTS b/SECURITY_CONTACTS new file mode 100644 index 000000000..0811c743f --- /dev/null +++ b/SECURITY_CONTACTS @@ -0,0 +1,15 @@ +# Defined below are the security contacts for this repo. +# +# They are the contact point for the Product Security Team to reach out +# to for triaging and handling of incoming issues. +# +# The below names agree to abide by the +# [Embargo Policy](https://github.com/kubernetes/sig-release/blob/master/security-release-process-documentation/security-release-process.md#embargo-policy) +# and will be removed and replaced if they violate that agreement. +# +# DO NOT REPORT SECURITY VULNERABILITIES DIRECTLY TO THESE NAMES, FOLLOW THE +# INSTRUCTIONS AT https://kubernetes.io/security/ + +njuettner +hjacobs +raffo diff --git a/controller/controller.go b/controller/controller.go index 75c44cf5c..653481d25 100644 --- a/controller/controller.go +++ b/controller/controller.go @@ -17,12 +17,14 @@ limitations under the License. package controller import ( + "context" "time" "github.com/prometheus/client_golang/prometheus" log "github.com/sirupsen/logrus" "github.com/kubernetes-incubator/external-dns/plan" + "github.com/kubernetes-incubator/external-dns/provider" "github.com/kubernetes-incubator/external-dns/registry" "github.com/kubernetes-incubator/external-dns/source" ) @@ -44,11 +46,29 @@ var ( Help: "Number of Source errors.", }, ) + sourceEndpointsTotal = prometheus.NewGauge( + prometheus.GaugeOpts{ + Namespace: "external_dns", + Subsystem: "source", + Name: "endpoints_total", + Help: "Number of Endpoints in all sources", + }, + ) + registryEndpointsTotal = prometheus.NewGauge( + prometheus.GaugeOpts{ + Namespace: "external_dns", + Subsystem: "registry", + Name: "endpoints_total", + Help: "Number of Endpoints in the registry", + }, + ) ) func init() { prometheus.MustRegister(registryErrorsTotal) prometheus.MustRegister(sourceErrorsTotal) + prometheus.MustRegister(sourceEndpointsTotal) + prometheus.MustRegister(registryEndpointsTotal) } // Controller is responsible for orchestrating the different components. @@ -73,12 +93,16 @@ func (c *Controller) RunOnce() error { registryErrorsTotal.Inc() return err } + registryEndpointsTotal.Set(float64(len(records))) + + ctx := context.WithValue(context.Background(), provider.RecordsContextKey, records) endpoints, err := c.Source.Endpoints() if err != nil { sourceErrorsTotal.Inc() return err } + sourceEndpointsTotal.Set(float64(len(endpoints))) plan := &plan.Plan{ Policies: []plan.Policy{c.Policy}, @@ -88,7 +112,7 @@ func (c *Controller) RunOnce() error { plan = plan.Calculate() - err = c.Registry.ApplyChanges(plan.Changes) + err = c.Registry.ApplyChanges(ctx, plan.Changes) if err != nil { registryErrorsTotal.Inc() return err diff --git a/controller/controller_test.go b/controller/controller_test.go index 909c33a78..1cf68bdfd 100644 --- a/controller/controller_test.go +++ b/controller/controller_test.go @@ -17,7 +17,9 @@ limitations under the License. package controller import ( + "context" "errors" + "reflect" "testing" "github.com/kubernetes-incubator/external-dns/endpoint" @@ -42,7 +44,7 @@ func (p *mockProvider) Records() ([]*endpoint.Endpoint, error) { } // ApplyChanges validates that the passed in changes satisfy the assumtions. -func (p *mockProvider) ApplyChanges(changes *plan.Changes) error { +func (p *mockProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { if len(changes.Create) != len(p.ExpectChanges.Create) { return errors.New("number of created records is wrong") } @@ -71,6 +73,9 @@ func (p *mockProvider) ApplyChanges(changes *plan.Changes) error { } } + if !reflect.DeepEqual(ctx.Value(provider.RecordsContextKey), p.RecordsStore) { + return errors.New("context is wrong") + } return nil } diff --git a/delivery.yaml b/delivery.yaml index 222ff01a5..6df6938cd 100644 --- a/delivery.yaml +++ b/delivery.yaml @@ -1,15 +1,23 @@ version: "2017-09-20" pipeline: - id: build + overlay: ci/golang + cache: + paths: + - /go/pkg/mod # pkg cache for Go modules + - ~/.cache/go-build # Go build cache type: script commands: - - desc: Build and push Docker image + - desc: build + cmd: | + make build.docker + - desc: push cmd: | if [[ $CDP_TARGET_BRANCH == master && ! $CDP_PULL_REQUEST_NUMBER ]]; then - RELEASE_VERSION=$(git describe --tags --always --dirty) - IMAGE=registry-write.opensource.zalan.do/teapot/external-dns:${RELEASE_VERSION} + IMAGE=registry-write.opensource.zalan.do/teapot/external-dns + VERSION=$(git describe --tags --always --dirty) else - IMAGE=registry-write.opensource.zalan.do/teapot/external-dns-test:${CDP_BUILD_VERSION} + IMAGE=registry-write.opensource.zalan.do/teapot/external-dns-test + VERSION=$CDP_BUILD_VERSION fi - docker build --squash --tag "$IMAGE" . - docker push "$IMAGE" + IMAGE=$IMAGE VERSION=$VERSION make build.push diff --git a/docs/contributing/crd-source.md b/docs/contributing/crd-source.md index 06fbea80a..ca43503d3 100644 --- a/docs/contributing/crd-source.md +++ b/docs/contributing/crd-source.md @@ -14,7 +14,11 @@ Here is typical example of [CRD API type](https://github.com/kubernetes-incubato ```go type TTL int64 type Targets []string -type ProviderSpecific map[string]string +type ProviderSpecificProperty struct { + Name string + Value string +} +type ProviderSpecific []ProviderSpecificProperty type Endpoint struct { // The hostname of the DNS record diff --git a/docs/contributing/crd-source/crd-manifest.yaml b/docs/contributing/crd-source/crd-manifest.yaml index 258404575..00b52f34c 100644 --- a/docs/contributing/crd-source/crd-manifest.yaml +++ b/docs/contributing/crd-source/crd-manifest.yaml @@ -33,7 +33,14 @@ spec: labels: type: object providerSpecific: - type: object + items: + properties: + name: + type: string + value: + type: string + type: object + type: array recordTTL: format: int64 type: integer diff --git a/docs/contributing/getting-started.md b/docs/contributing/getting-started.md index 8ce2325c6..3ca84b425 100644 --- a/docs/contributing/getting-started.md +++ b/docs/contributing/getting-started.md @@ -2,7 +2,7 @@ ### Building -You can build ExternalDNS for your platform with `make build`. The binary will land at `build/external-dns`. +You can build ExternalDNS for your platform with `make build`, you may have to install the necessary dependencies with `make dep`. The binary will land at `build/external-dns`. ### Design @@ -14,7 +14,7 @@ This list of endpoints is passed to the [Plan](../../plan) which determines the Once the difference has been figured out the list of intended changes is passed to a `Registry` which live in the [registry](../../registry) package. The registry is a wrapper and access point to DNS provider. Registry implements the ownership concept by marking owned records and filtering out records not owned by ExternalDNS before passing them to DNS provider. -The [provider](../../provider) is the adapter to the DNS provider, e.g. Google CloudDNS. It implements two methods: `ApplyChanges` to apply a set of changes filtered by `Registry` and `Records` to retrieve the current list of records from the DNS provider. +The [provider](../../provider) is the adapter to the DNS provider, e.g. Google Cloud DNS. It implements two methods: `ApplyChanges` to apply a set of changes filtered by `Registry` and `Records` to retrieve the current list of records from the DNS provider. The orchestration between the different components is controlled by the [controller](../../controller). diff --git a/docs/contributing/sources-and-providers.md b/docs/contributing/sources-and-providers.md index e5d1cd995..3d51b3598 100644 --- a/docs/contributing/sources-and-providers.md +++ b/docs/contributing/sources-and-providers.md @@ -29,7 +29,7 @@ All sources live in package `source`. ### Providers Providers are an abstraction over any kind of sink for desired Endpoints, e.g.: -* storing them in Google CloudDNS +* storing them in Google Cloud DNS * printing them to stdout for testing purposes * fanning out to multiple nested providers @@ -46,7 +46,7 @@ The interface tries to be generic and assumes a flat list of records for both fu All providers live in package `provider`. -* `GoogleProvider`: returns and creates DNS records in Google CloudDNS +* `GoogleProvider`: returns and creates DNS records in Google Cloud DNS * `AWSProvider`: returns and creates DNS records in AWS Route 53 * `AzureProvider`: returns and creates DNS records in Azure DNS * `InMemoryProvider`: Keeps a list of records in local memory diff --git a/docs/faq.md b/docs/faq.md index d5a1666ab..9017738c7 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -28,9 +28,9 @@ ExternalDNS can solve this for you as well. ### Which DNS providers are supported? -Currently, the following providers are supported: +Currently, the following providers are supported: -- Google CloudDNS +- Google Cloud DNS - AWS Route 53 - AzureDNS - CloudFlare @@ -40,6 +40,12 @@ Currently, the following providers are supported: - Dyn - OpenStack Designate - PowerDNS +- CoreDNS +- Exoscale +- Oracle Cloud Infrastructure DNS +- Linode DNS +- RFC2136 +- TransIP As stated in the README, we are currently looking for stable maintainers for those providers, to ensure that bugfixes and new features will be available for all of those. @@ -155,6 +161,18 @@ CNAMEs cannot co-exist with other records, therefore you can use the `--txt-pref You need to add either https://www.googleapis.com/auth/ndev.clouddns.readwrite or https://www.googleapis.com/auth/cloud-platform on your instance group's scope. +### What metrics can I get from ExternalDNS and what do they mean? + +ExternalDNS exposes 2 types of metrics: Sources and Registry errors. + +`Source`s are mostly Kubernetes API objects. Examples of `source` errors may be connection errors to the Kubernetes API server itself or missing RBAC permissions. It can also stem from incompatible configuration in the objects itself like invalid characters, processing a broken fqdnTemplate, etc. + +`Registry` errors are mostly Provider errors, unless there's some coding flaw in the registry package. Provider errors often arise due to accessing their APIs due to network or missing cloud-provider permissions when reading records. When applying a changeset, errors will arise if the changeset applied is incompatible with the current state. + +In case of an increased error count, you could correlate them with the `http_request_duration_seconds{handler="instrumented_http"}` metric which should show increased numbers for status codes 4xx (permissions, configuration, invalid changeset) or 5xx (apiserver down). + +You can use the host label in the metric to figure out if the request was against the Kubernetes API server (Source errors) or the DNS provider API (Registry/Provider errors). + ### How can I run ExternalDNS under a specific GCP Service Account, e.g. to access DNS records in other projects? Have a look at https://github.com/linki/mate/blob/v0.6.2/examples/google/README.md#permissions @@ -226,3 +244,26 @@ To do this with ExternalDNS you can use the `--annotation-filter` to specificall an instance of a ingress controller. Let's assume you have two ingress controllers `nginx-internal` and `nginx-external` then you can start two ExternalDNS providers one with `--annotation-filter=kubernetes.io/ingress.class=nginx-internal` and one with `--annotation-filter=kubernetes.io/ingress.class=nginx-external`. + +### Can external-dns manage(add/remove) records in a hosted zone which is setup in different AWS account? + +Yes, give it the correct cross-account/assume-role permissions and use the `--aws-assume-role` flag https://github.com/kubernetes-incubator/external-dns/pull/524#issue-181256561 + +### How do I provide multiple values to the annotation `external-dns.alpha.kubernetes.io/hostname`? + +Separate them by `,`. + + +### Are there official Docker images provided? + +When we tag a new release, we push a Docker image on Zalando's public Docker registry with the following name: + +``` +registry.opensource.zalan.do/teapot/external-dns +``` + +As tags, you can use your version of choice or use `latest` that always resolves to the latest tag. + +If you wish to build your own image, you can use the provided [Dockerfile](../Dockerfile) as a starting point. + +We are currently working with the Kubernetes community to provide official images for the project similarly to what is done with the other official Kubernetes projects, but we don't have an ETA on when those images will be available. \ No newline at end of file diff --git a/docs/ttl.md b/docs/ttl.md index c290ea266..35295e90b 100644 --- a/docs/ttl.md +++ b/docs/ttl.md @@ -27,6 +27,7 @@ Providers - [x] Google - [ ] InMemory - [x] Linode +- [x] TransIP PRs welcome! @@ -51,4 +52,7 @@ For the moment, it is impossible to use a TTL value of 0 with the AWS, DigitalOc This behavior may change in the future. ### Linode Provider -The Linode Provider default TTL is used when the TTL is 0. The default is 24 hours \ No newline at end of file +The Linode Provider default TTL is used when the TTL is 0. The default is 24 hours + +### TransIP Provider +The TransIP Provider minimal TTL is used when the TTL is 0. The minimal TTL is 60s. diff --git a/docs/tutorials/alibabacloud.md b/docs/tutorials/alibabacloud.md index 2fc509579..5ecd087ab 100644 --- a/docs/tutorials/alibabacloud.md +++ b/docs/tutorials/alibabacloud.md @@ -117,7 +117,7 @@ spec: - --domain-filter=external-dns-test.com # will make ExternalDNS see only the hosted zones matching provided domain, omit to process all available hosted zones - --provider=alibabacloud - --policy=upsert-only # would prevent ExternalDNS from deleting any records, omit to enable full synchronization - - --alibaba-cloud-zone=public # only look at public hosted zones (valid values are public, private or no value for both) + - --alibaba-cloud-zone-type=public # only look at public hosted zones (valid values are public, private or no value for both) - --registry=txt - --txt-owner-id=my-identifier volumeMounts: @@ -194,6 +194,7 @@ spec: - --alibaba-cloud-zone-type=public # only look at public hosted zones (valid values are public, private or no value for both) - --registry=txt - --txt-owner-id=my-identifier + - --alibaba-cloud-config-file= # enable sts token volumeMounts: - mountPath: /usr/share/zoneinfo name: hostpath @@ -210,9 +211,9 @@ spec: This list is not the full list, but a few arguments that where chosen. -### alibabacloud-zone-type +### alibaba-cloud-zone-type -`alibabacloud-zone-type` allows filtering for private and public zones +`alibaba-cloud-zone-type` allows filtering for private and public zones * If value is `public`, it will sync with records in Alibaba Cloud DNS Service * If value is `private`, it will sync with records in Alibaba Cloud Private Zone Service @@ -379,3 +380,5 @@ Give ExternalDNS some time to clean up the DNS records for you. Then delete the ```console $ aliyun alidns DeleteDomain --DomainName external-dns-test.com ``` + +For more info about Alibaba Cloud external dns, please refer this [docs](https://yq.aliyun.com/articles/633412) diff --git a/docs/tutorials/aws.md b/docs/tutorials/aws.md index 5ab4050de..88a7a75a2 100644 --- a/docs/tutorials/aws.md +++ b/docs/tutorials/aws.md @@ -44,17 +44,17 @@ $ aws route53 create-hosted-zone --name "external-dns-test.my-org.com." --caller ``` -Make a note of the ID of the hosted zone you just created. +Make a note of the ID of the hosted zone you just created, which will serve as the value for my-hostedzone-identifier. ```console -$ aws route53 list-hosted-zones-by-name --dns-name "external-dns-test.my-org.com." | jq -r '.HostedZones[0].Id' +$ aws route53 list-hosted-zones-by-name --output json --dns-name "external-dns-test.my-org.com." | jq -r '.HostedZones[0].Id' /hostedzone/ZEWFWZ4R16P7IB ``` Make a note of the nameservers that were assigned to your new zone. ```console -$ aws route53 list-resource-record-sets --hosted-zone-id "/hostedzone/ZEWFWZ4R16P7IB" \ +$ aws route53 list-resource-record-sets --output json --hosted-zone-id "/hostedzone/ZEWFWZ4R16P7IB" \ --query "ResourceRecordSets[?Type == 'NS']" | jq -r '.[0].ResourceRecords[].Value' ns-5514.awsdns-53.org. ... @@ -65,7 +65,7 @@ In this case it's the ones shown above but your's will differ. ## Deploy ExternalDNS Connect your `kubectl` client to the cluster you want to test ExternalDNS with. -Then apply one of the following manifests file to deploy ExternalDNS. +Then apply one of the following manifests file to deploy ExternalDNS. You can check if your cluster has RBAC by `kubectl api-versions | grep rbac.authorization.k8s.io`. ### Manifest (for clusters without RBAC enabled) ```yaml @@ -92,7 +92,7 @@ spec: - --policy=upsert-only # would prevent ExternalDNS from deleting any records, omit to enable full synchronization - --aws-zone-type=public # only look at public hosted zones (valid values are public, private or no value for both) - --registry=txt - - --txt-owner-id=my-identifier + - --txt-owner-id=my-hostedzone-identifier ``` ### Manifest (for clusters with RBAC enabled) @@ -119,7 +119,7 @@ rules: verbs: ["get","watch","list"] - apiGroups: [""] resources: ["nodes"] - verbs: ["list"] + verbs: ["list","watch"] --- apiVersion: rbac.authorization.k8s.io/v1beta1 kind: ClusterRoleBinding @@ -158,7 +158,7 @@ spec: - --policy=upsert-only # would prevent ExternalDNS from deleting any records, omit to enable full synchronization - --aws-zone-type=public # only look at public hosted zones (valid values are public, private or no value for both) - --registry=txt - - --txt-owner-id=my-identifier + - --txt-owner-id=my-hostedzone-identifier ``` @@ -177,7 +177,7 @@ Annotations which are specific to AWS. ### alias -`external-dns.alpha.kubernetes.io/alias` if set to `true` on an ingress, it will create an ALIAS record when the target is an ALIAS as well. +`external-dns.alpha.kubernetes.io/alias` if set to `true` on an ingress, it will create an ALIAS record when the target is an ALIAS as well. To make the target an alias, the ingress needs to be configured correctly as described in [the docs](./nginx-ingress.md#with-a-separate-tcp-load-balancer). ## Verify ExternalDNS works (Ingress example) @@ -208,13 +208,15 @@ Create the following sample application to test that ExternalDNS works. > For services ExternalDNS will look for the annotation `external-dns.alpha.kubernetes.io/hostname` on the service and use the corresponding value. +> If you want to give multiple names to service, you can set it to external-dns.alpha.kubernetes.io/hostname with a comma separator. + ```yaml apiVersion: v1 kind: Service metadata: name: nginx annotations: - external-dns.alpha.kubernetes.io/hostname: nginx.external-dns-test.my-org.com. + external-dns.alpha.kubernetes.io/hostname: nginx.external-dns-test.my-org.com spec: type: LoadBalancer ports: @@ -247,7 +249,7 @@ spec: After roughly two minutes check that a corresponding DNS record for your service was created. ```console -$ aws route53 list-resource-record-sets --hosted-zone-id "/hostedzone/ZEWFWZ4R16P7IB" \ +$ aws route53 list-resource-record-sets --output json --hosted-zone-id "/hostedzone/ZEWFWZ4R16P7IB" \ --query "ResourceRecordSets[?Name == 'nginx.external-dns-test.my-org.com.']|[?Type == 'A']" [ { @@ -264,7 +266,7 @@ $ aws route53 list-resource-record-sets --hosted-zone-id "/hostedzone/ZEWFWZ4R16 "TTL": 300, "ResourceRecords": [ { - "Value": "\"heritage=external-dns,external-dns/owner=my-identifier\"" + "Value": "\"heritage=external-dns,external-dns/owner=my-hostedzone-identifier\"" } ], "Type": "TXT" @@ -310,7 +312,7 @@ kind: Service metadata: name: nginx annotations: - external-dns.alpha.kubernetes.io/hostname: nginx.external-dns-test.my-org.com. + external-dns.alpha.kubernetes.io/hostname: nginx.external-dns-test.my-org.com external-dns.alpha.kubernetes.io/ttl: 60 spec: ... diff --git a/docs/tutorials/azure.md b/docs/tutorials/azure.md index 42778721a..51f6d77fc 100644 --- a/docs/tutorials/azure.md +++ b/docs/tutorials/azure.md @@ -48,7 +48,7 @@ The preferred way to inject the configuration file is by using a Kubernetes secr "subscriptionId": "01234abc-de56-ff78-abc1-234567890def", "resourceGroup": "MyDnsResourceGroup", "aadClientId": "01234abc-de56-ff78-abc1-234567890def", - "aadClientSecret": "uKiuXeiwui4jo9quae9o", + "aadClientSecret": "uKiuXeiwui4jo9quae9o" } ``` @@ -61,13 +61,18 @@ The `resourceGroup` is the Resource Group created in a previous step. The `aadClientID` and `aaClientSecret` are assoiated with the Service Principal, that you need to create next. ### Creating service principal -A Service Principal with a minimum access level of contribute to the resource group containing the Azure DNS zone(s) is necessary for ExternalDNS to be able to edit DNS records. This is an Azure CLI example on how to query the Azure API for the information required for the Resource Group and DNS zone you would have already created in previous steps. +A Service Principal with a minimum access level of `contributor` to the DNS zone(s) and `reader` to the resource group containing the Azure DNS zone(s) is necessary for ExternalDNS to be able to edit DNS records. However, other more permissive access levels will work too (e.g. `contributor` to the resource group or the whole subscription). +This is an Azure CLI example on how to query the Azure API for the information required for the Resource Group and DNS zone you would have already created in previous steps. + +``` bash +> az login ``` ->az login -... -# find the relevant subscription and set the az context. id = subscriptionId value in the azure.json. ->az account list + +Find the relevant subscription and make sure it is selected (the same subscriptionId should be set into azure.json) + +``` bash +> az account list { "cloudName": "AzureCloud", "id": "", @@ -79,16 +84,15 @@ A Service Principal with a minimum access level of contribute to the resource gr "name": "name", "type": "user" } ->az account set -s id -... ->az group show --name externaldns -{ - "id": "/subscriptions/id/resourceGroups/externaldns", - ... -} -# use the id from the previous step in the scopes argument ->az ad sp create-for-rbac --role="Contributor" --scopes="/subscriptions/id/resourceGroups/externaldns" -n ExternalDnsServicePrincipal +# select the subscription +> az account set -s +... +``` +Create the service principal + +``` bash +> az ad sp create-for-rbac -n ExternalDnsServicePrincipal { "appId": "appId GUID", <-- aadClientId value ... @@ -97,6 +101,33 @@ A Service Principal with a minimum access level of contribute to the resource gr } ``` +Assign the rights for the service principal + +``` +# find out the resource ids of the resource group where the dns zone is deployed, and the dns zone itself +> az group show --name externaldns +{ + "id": "/subscriptions/id/resourceGroups/externaldns", + ... +} + +> az network dns zone show --name example.com -g externaldns +{ + "id": "/subscriptions/.../resourceGroups/externaldns/providers/Microsoft.Network/dnszones/example.com", + ... +} +``` +``` +# assign the rights to the created service principal, using the resource ids from previous step + +# 1. as a reader to the resource group +> az role assignment create --role "Reader" --assignee --scope + +# 2. as a contributor to DNS Zone itself +> az role assignment create --role "Contributor" --assignee --scope + +``` + Now you can create a file named 'azure.json' with values gathered above and with the structure of the example above. Use this file to create a Kubernetes secret: ``` diff --git a/docs/tutorials/cloudflare.md b/docs/tutorials/cloudflare.md index 308cb8186..e4c191690 100644 --- a/docs/tutorials/cloudflare.md +++ b/docs/tutorials/cloudflare.md @@ -16,7 +16,7 @@ Snippet from [Cloudflare - Getting Started](https://api.cloudflare.com/#getting- >Cloudflare's API exposes the entire Cloudflare infrastructure via a standardized programmatic interface. Using Cloudflare's API, you can do just about anything you can do on cloudflare.com via the customer dashboard. ->The Cloudflare API is a RESTful API based on HTTPS requests and JSON responses. If you are registered with Cloudflare, you can obtain your API key from the bottom of the "My Account" page, found here: [Go to My account](https://www.cloudflare.com/a/account). +>The Cloudflare API is a RESTful API based on HTTPS requests and JSON responses. If you are registered with Cloudflare, you can obtain your API key from the bottom of the "My Account" page, found here: [Go to My account](https://dash.cloudflare.com/profile). The environment vars `CF_API_KEY` and `CF_API_EMAIL` will be needed to run ExternalDNS with Cloudflare. @@ -193,6 +193,10 @@ This should show the external IP address of the service as the A record for your Now that we have verified that ExternalDNS will automatically manage Cloudflare DNS records, we can delete the tutorial's example: ``` -$ kubectl delete service -f nginx.yaml -$ kubectl delete service -f externaldns.yaml +$ kubectl delete -f nginx.yaml +$ kubectl delete -f externaldns.yaml ``` + +## Setting cloudflare-proxied on a per-ingress basis + +Using the `external-dns.alpha.kubernetes.io/cloudflare-proxied: "true"` annotation on your ingress, you can specify if the proxy feature of Cloudflare should be enabled for that record. This setting will override the global `--cloudflare-proxied` setting. diff --git a/docs/tutorials/coredns.md b/docs/tutorials/coredns.md index 12a357dc0..de8b16fca 100644 --- a/docs/tutorials/coredns.md +++ b/docs/tutorials/coredns.md @@ -86,8 +86,10 @@ helm install --name my-coredns --values values.yaml stable/coredns ## Installing ExternalDNS ### Install external ExternalDNS ETCD_URLS is configured to etcd client service address. -``` -$ cat external-dns.yaml + +#### Manifest (for clusters without RBAC enabled) + +```yaml apiVersion: apps/v1 kind: Deployment metadata: @@ -97,7 +99,7 @@ spec: strategy: type: Recreate selector: - matchLabels: + matchLabels: app: external-dns template: metadata: @@ -114,7 +116,76 @@ spec: env: - name: ETCD_URLS value: http://10.105.68.165:2379 -$ kubectl apply -f external-dns.yaml +``` + +#### Manifest (for clusters with RBAC enabled) + +```yaml +--- +apiVersion: rbac.authorization.k8s.io/v1beta1 +kind: ClusterRole +metadata: + name: external-dns +rules: +- apiGroups: [""] + resources: ["services"] + verbs: ["get","watch","list"] +- apiGroups: [""] + resources: ["pods"] + verbs: ["get","watch","list"] +- apiGroups: ["extensions"] + resources: ["ingresses"] + verbs: ["get","watch","list"] +- apiGroups: [""] + resources: ["nodes"] + verbs: ["list"] +--- +apiVersion: rbac.authorization.k8s.io/v1beta1 +kind: ClusterRoleBinding +metadata: + name: external-dns-viewer +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: external-dns +subjects: +- kind: ServiceAccount + name: external-dns + namespace: kube-system +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: external-dns + namespace: kube-system +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: external-dns + namespace: kube-system +spec: + strategy: + type: Recreate + selector: + matchLabels: + app: external-dns + template: + metadata: + labels: + app: external-dns + spec: + serviceAccountName: external-dns + containers: + - name: external-dns + image: registry.opensource.zalan.do/teapot/external-dns:latest + args: + - --source=ingress + - --provider=coredns + - --log-level=debug # debug only + env: + - name: ETCD_URLS + value: http://10.105.68.165:2379 ``` ## Enable the ingress controller @@ -126,6 +197,7 @@ minikube addons enable ingress ## Testing ingress example ``` $ cat ingress.yaml +apiVersion: extensions/v1beta1 kind: Ingress metadata: name: nginx @@ -157,8 +229,7 @@ nginx nginx.example.org 10.0.2.15 80 2m $ kubectl run -it --rm --restart=Never --image=infoblox/dnstools:latest dnstools If you don't see a command prompt, try pressing enter. -dnstools# dig @10.102.213.122 nginx.example.org +short -dnstools# dig @10.102.213.122 nginx.example.org +short +dnstools# dig @10.100.4.143 nginx.example.org +short 10.0.2.15 dnstools# ``` diff --git a/docs/tutorials/istio.md b/docs/tutorials/istio.md index 4f74908c7..269c10bcb 100644 --- a/docs/tutorials/istio.md +++ b/docs/tutorials/istio.md @@ -25,7 +25,7 @@ spec: - --source=service - --source=ingress - --source=istio-gateway - - --istio-ingress-gateway=custom-istio-namespace/custom-istio-ingressgateway # omit to use the default (istio-system/istio-ingressgateway) + - --istio-ingress-gateway=custom-istio-namespace/custom-istio-ingressgateway # load balancer service to be used; can be specified multiple times. Omit to use the default (istio-system/istio-ingressgateway) - --domain-filter=external-dns-test.my-org.com # will make ExternalDNS see only the hosted zones matching provided domain, omit to process all available hosted zones - --provider=aws - --policy=upsert-only # would prevent ExternalDNS from deleting any records, omit to enable full synchronization @@ -95,7 +95,7 @@ spec: - --source=service - --source=ingress - --source=istio-gateway - - --istio-ingress-gateway=custom-istio-namespace/custom-istio-ingressgateway # omit to use the default (istio-system/istio-ingressgateway) + - --istio-ingress-gateway=custom-istio-namespace/custom-istio-ingressgateway # load balancer service to be used; can be specified multiple times. Omit to use the default (istio-system/istio-ingressgateway) - --domain-filter=external-dns-test.my-org.com # will make ExternalDNS see only the hosted zones matching provided domain, omit to process all available hosted zones - --provider=aws - --policy=upsert-only # would prevent ExternalDNS from deleting any records, omit to enable full synchronization diff --git a/docs/tutorials/nginx-ingress.md b/docs/tutorials/nginx-ingress.md index 3c495be64..d180e2a0d 100644 --- a/docs/tutorials/nginx-ingress.md +++ b/docs/tutorials/nginx-ingress.md @@ -15,8 +15,7 @@ Create a GKE cluster without using the default ingress controller. ```console $ gcloud container clusters create "external-dns" \ --num-nodes 1 \ - --scopes "https://www.googleapis.com/auth/ndev.clouddns.readwrite" \ - --disable-addons=HttpLoadBalancing + --scopes "https://www.googleapis.com/auth/ndev.clouddns.readwrite" ``` Create a DNS zone which will contain the managed DNS records. diff --git a/docs/tutorials/ns1.md b/docs/tutorials/ns1.md new file mode 100644 index 000000000..607cf8221 --- /dev/null +++ b/docs/tutorials/ns1.md @@ -0,0 +1,200 @@ +# Setting up ExternalDNS for Services on NS1 + +This tutorial describes how to setup ExternalDNS for use within a +Kubernetes cluster using NS1 DNS. + +Make sure to use **>=0.5** version of ExternalDNS for this tutorial. + +## Creating a zone with NS1 DNS + +If you are new to NS1, we recommend you first read the following +instructions for creating a zone. + +[Creating a zone using the NS1 +portal](https://ns1.com/knowledgebase/creating-a-zone) + +[Creating a zone using the NS1 +API](https://ns1.com/api#put-create-a-new-dns-zone) + +## Creating NS1 Credentials + +All NS1 products are API-first, meaning everything that can be done on +the portal---including managing zones and records, data sources and +feeds, and account settings and users---can be done via API. + +The NS1 API is a standard REST API with JSON responses. The environment +var `NS1_APIKEY` will be needed to run ExternalDNS with NS1. + +### To add or delete an API key + +1. Log into the NS1 portal at [my.nsone.net](http://my.nsone.net). + +2. Click your username in the upper-right corner, and navigate to **Account Settings** \> **Users & Teams**. + +3. Navigate to the _API Keys_ tab, and click **Add Key**. + +4. Enter the name of the application and modify permissions and settings as desired. Once complete, click **Create Key**. The new API key appears in the list. + + Note: Set the permissions for your API keys just as you would for a user or team associated with your organization's NS1 account. For more information, refer to the article [Creating and Managing API Keys](https://ns1.com/knowledgebase/creating-and-managing-users) in the NS1 Knowledge Base. + +## Deploy ExternalDNS + +Connect your `kubectl` client to the cluster with which you want to test ExternalDNS, and then apply one of the following manifest files for deployment: + +### Manifest (for clusters without RBAC enabled) + +```yaml +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: external-dns +spec: + strategy: + type: Recreate + template: + metadata: + labels: + app: external-dns + spec: + containers: + - name: external-dns + image: registry.opensource.zalan.do/teapot/external-dns:latest + args: + - --source=service # ingress is also possible + - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above. + - --provider=ns1 + env: + - name: NS1_APIKEY + value: "YOUR_NS1_API_KEY" +``` + +### Manifest (for clusters with RBAC enabled) + +```yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: external-dns +--- +apiVersion: rbac.authorization.k8s.io/v1beta1 +kind: ClusterRole +metadata: + name: external-dns +rules: +- apiGroups: [""] + resources: ["services"] + verbs: ["get","watch","list"] +- apiGroups: [""] + resources: ["pods"] + verbs: ["get","watch","list"] +- apiGroups: ["extensions"] + resources: ["ingresses"] + verbs: ["get","watch","list"] +- apiGroups: [""] + resources: ["nodes"] + verbs: ["list"] +--- +apiVersion: rbac.authorization.k8s.io/v1beta1 +kind: ClusterRoleBinding +metadata: + name: external-dns-viewer +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: external-dns +subjects: +- kind: ServiceAccount + name: external-dns + namespace: default +--- +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: external-dns +spec: + strategy: + type: Recreate + template: + metadata: + labels: + app: external-dns + spec: + serviceAccountName: external-dns + containers: + - name: external-dns + image: registry.opensource.zalan.do/teapot/external-dns:latest + args: + - --source=service # ingress is also possible + - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above. + - --provider=ns1 + env: + - name: NS1_APIKEY + value: "YOUR_NS1_API_KEY" +``` + +## Deploying an Nginx Service + +Create a service file called 'nginx.yaml' with the following contents: + +```yaml +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: nginx +spec: + template: + metadata: + labels: + app: nginx + spec: + containers: + - image: nginx + name: nginx + ports: + - containerPort: 80 +--- +apiVersion: v1 +kind: Service +metadata: + name: nginx + annotations: + external-dns.alpha.kubernetes.io/hostname: example.com + external-dns.alpha.kubernetes.io/ttl: "120" #optional +spec: + selector: + app: nginx + type: LoadBalancer + ports: + - protocol: TCP + port: 80 + targetPort: 80 +``` + +**A note about annotations** + +Verify that the annotation on the service uses the same hostname as the NS1 DNS zone created above. The annotation may also be a subdomain of the DNS zone (e.g. 'www.example.com'). + +The TTL annotation can be used to configure the TTL on DNS records managed by ExternalDNS and is optional. If this annotation is not set, the TTL on records managed by ExternalDNS will default to 10. + +ExternalDNS uses the hostname annotation to determine which services should be registered with DNS. Removing the hostname annotation will cause ExternalDNS to remove the corresponding DNS records. + +### Create the deployment and service + +``` +$ kubectl create -f nginx.yaml +``` + +Depending on where you run your service, it may take some time for your cloud provider to create an external IP for the service. Once an external IP is assigned, ExternalDNS detects the new service IP address and synchronizes the NS1 DNS records. + +## Verifying NS1 DNS records + +Use the NS1 portal or API to verify that the A record for your domain shows the external IP address of the services. + +## Cleanup + +Once you successfully configure and verify record management via ExternalDNS, you can delete the tutorial's example: + +``` +$ kubectl delete -f nginx.yaml +$ kubectl delete -f externaldns.yaml +``` diff --git a/docs/tutorials/rcodezero.md b/docs/tutorials/rcodezero.md new file mode 100644 index 000000000..c408a96ba --- /dev/null +++ b/docs/tutorials/rcodezero.md @@ -0,0 +1,200 @@ +# Setting up ExternalDNS for Services on RcodeZero + +This tutorial describes how to setup ExternalDNS for usage within a Kubernetes cluster using [RcodeZero Anycast DNS](https://www.rcodezero.at). Make sure to use **>=0.5.0** version of ExternalDNS for this tutorial. + +The following steps are required to use RcodeZero with ExternalDNS: + +1. Sign up for an RcodeZero account (or use an existing account). +2. Add your zone to the RcodeZero DNS +3. Enable the RcodeZero API, and generate an API key. +4. Deploy ExternalDNS to use the RcodeZero provider. +5. Verify the setup bey deploying a test services (optional) + +## Creating a RcodeZero DNS zone + +Before records can be added to your domain name automatically, you need to add your domain name to the set of zones managed by RcodeZero. In order to add the zone, perform the following steps: + +1. Log in to the RcodeZero Dashboard, and move to the [Add Zone](https://my.rcodezero.at/domain/create) page. +2. Select "MASTER" as domain type, and add your domain name there. Use this domain name instead of "example.com" throughout the rest of this tutorial. + +Note that "SECONDARY" domains cannot be managed by ExternalDNS, because this would not allow modification of records in the zone. + +## Enable the API, and create Credentials + +> The RcodeZero Anycast-Network is provisioned via web interface or REST-API. + +Enable the RcodeZero API to generate an API key on [RcodeZero API](https://my.rcodezero.at/enableapi). The API key will be added to the environment variable 'RC0_API_KEY' via one of the Manifest templates (as described below). + +## Deploy ExternalDNS + +Connect your `kubectl` client to the cluster you want to test ExternalDNS with. Choose a Manifest from below, depending on whether or not you have RBAC enabled. Before applying it, modify the Manifest as follows: + +- Replace "example.com" with the domain name you added to RcodeZero. +- Replace YOUR_RCODEZERO_API_KEY with the API key created above. +- Replace YOUR_ENCRYPTION_KEY_STRING with a string to encrypt the TXT records + +### Manifest (for clusters without RBAC enabled) + +```yaml +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: external-dns +spec: + strategy: + type: Recreate + template: + metadata: + labels: + app: external-dns + spec: + containers: + - name: external-dns + image: registry.opensource.zalan.do/teapot/external-dns:latest + args: + - --source=service # ingress is also possible + - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above. + - --provider=rcodezero + - --rc0-enc-txt # (optional) encrypt TXT records; encryption key has to be provided with RC0_ENC_KEY env var. + env: + - name: RC0_API_KEY + value: "YOUR_RCODEZERO_API_KEY" + - name: RC0_ENC_VAR + value: "YOUR_ENCRYPTION_KEY_STRING" +``` + +### Manifest (for clusters with RBAC enabled) + +```yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: external-dns +--- +apiVersion: rbac.authorization.k8s.io/v1beta1 +kind: ClusterRole +metadata: + name: external-dns +rules: +- apiGroups: [""] + resources: ["services"] + verbs: ["get","watch","list"] +- apiGroups: [""] + resources: ["pods"] + verbs: ["get","watch","list"] +- apiGroups: ["extensions"] + resources: ["ingresses"] + verbs: ["get","watch","list"] +- apiGroups: [""] + resources: ["nodes"] + verbs: ["list"] +--- +apiVersion: rbac.authorization.k8s.io/v1beta1 +kind: ClusterRoleBinding +metadata: + name: external-dns-viewer +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: external-dns +subjects: +- kind: ServiceAccount + name: external-dns + namespace: default +--- +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: external-dns +spec: + strategy: + type: Recreate + template: + metadata: + labels: + app: external-dns + spec: + serviceAccountName: external-dns + containers: + - name: external-dns + image: registry.opensource.zalan.do/teapot/external-dns:latest + args: + - --source=service # ingress is also possible + - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above. + - --provider=rcodezero + - --rc0-enc-txt # (optional) encrypt TXT records; encryption key has to be provided with RC0_ENC_KEY env var. + env: + - name: RC0_API_KEY + value: "YOUR_RCODEZERO_API_KEY" + - name: RC0_ENC_VAR + value: "YOUR_ENCRYPTION_KEY_STRING" +``` + +## Deploying an Nginx Service + +After you have deployed ExternalDNS with RcodeZero, you can deploy a simple service based on Nginx to test the setup. This is optional, though highly recommended before using ExternalDNS in production. + +Create a service file called 'nginx.yaml' with the following contents: + +```yaml +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: nginx +spec: + template: + metadata: + labels: + app: nginx + spec: + containers: + - image: nginx + name: nginx + ports: + - containerPort: 80 +--- +apiVersion: v1 +kind: Service +metadata: + name: nginx + annotations: + external-dns.alpha.kubernetes.io/hostname: example.com + external-dns.alpha.kubernetes.io/ttl: "120" #optional +spec: + selector: + app: nginx + type: LoadBalancer + ports: + - protocol: TCP + port: 80 + targetPort: 80 +``` + +Change the file as follows: + +- Replace the annotation of the service; use the same hostname as the RcodeZero DNS zone created above. The annotation may also be a subdomain +of the DNS zone (e.g. 'www.example.com'). +- Set the TTL annotation of the service. A valid TTL of 120 or above must be given. This annotation is optional, and defaults to "300" if no value is given. + +These annotations will be used to determine what services should be registered with DNS. Removing these annotations will cause ExternalDNS to remove the corresponding DNS records. + +Create the Deployment and Service: + +```bash +$ kubectl create -f nginx.yaml +``` + +Depending on your cloud provider, it might take a while to create an external IP for the service. Once an external IP address is assigned to the service, ExternalDNS will notice the new address and synchronize the RcodeZero DNS records accordingly. + +## Verifying RcodeZero DNS records + +Check your [RcodeZero Configured Zones](https://my.rcodezero.at/domain) and select the respective zone name. The zone should now contain the external IP address of the service as an A record. + +## Cleanup + +Once you have verified that ExternalDNS successfully manages RcodeZero DNS records for external services, you can delete the tutorial example as follows: + +```bash +$ kubectl delete -f nginx.yaml +$ kubectl delete -f externaldns.yaml +``` diff --git a/docs/tutorials/rfc2136.md b/docs/tutorials/rfc2136.md index 5b0bf7304..1ff0f6160 100644 --- a/docs/tutorials/rfc2136.md +++ b/docs/tutorials/rfc2136.md @@ -12,6 +12,39 @@ key "externaldns-key" { ``` - `Warning!` Bind server configuration should enable for this key AFXR zone transfer protocol. It is used for listing DNS records. +```text +# cat /etc/named.conf +... +include "/etc/rndc.key"; + +controls { + inet 123.123.123.123 port 953 allow { 10.x.y.151; } keys { "externaldns-key"; }; +}; +options { + include "/etc/named/options.conf"; +}; + +include "/etc/named/zones.conf"; +... + +# cat /etc/named/options.conf +... +dnssec-enable yes; +dnssec-validation yes; +... + +# cat /etc/named/zones.conf +... +zone "example.com" { + type master; + file "/var/named/dynamic/db.example.com"; + update-policy { + grant externaldns-key zonesub ANY; + }; +}; +... +``` + ## RFC2136 provider configuration: - Example fragment of real configuration of ExternalDNS service pod. @@ -31,4 +64,4 @@ key "externaldns-key" { - `rfc2136-tsig-keyname` - this is string parameter with secret key name it is should `MATCH!` with server key name. In example it is `externaldns-key`. - \ No newline at end of file + diff --git a/docs/tutorials/transip.md b/docs/tutorials/transip.md new file mode 100644 index 000000000..8287baebb --- /dev/null +++ b/docs/tutorials/transip.md @@ -0,0 +1,181 @@ +# Setting up ExternalDNS for Services on TransIP + +This tutorial describes how to setup ExternalDNS for usage within a Kubernetes cluster using TransIP. + +Make sure to use **>=0.5.14** version of ExternalDNS for this tutorial, have at least 1 domain registered at TransIP and enabled the API. + +## Enable TransIP API and prepare your API key + +To use the TransIP API you need an account at TransIP and enable API usage as described in the [knowledge base](https://www.transip.eu/knowledgebase/entry/77-want-use-the-transip-api/). With the private key generated by the API, we create a kubernetes secret: + +```console +$ kubectl create secret generic transip-api-key --from-file=transip-api-key=/path/to/private.key +``` + +## Deploy ExternalDNS + +Below are example manifests, for both cluster without or with RBAC enabled. Don't forget to replace `YOUR_TRANSIP_ACCOUNT_NAME` with your TransIP account name. In these examples, an example domain-filter is defined. Such a filter can be used to prevent ExternalDNS from touching any domain not listed in the filter. Refer to the docs for any other command-line parameters you might want to use. + +### Manifest (for clusters without RBAC enabled) + +```yaml +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: external-dns +spec: + strategy: + type: Recreate + template: + metadata: + labels: + app: external-dns + spec: + containers: + - name: external-dns + image: registry.opensource.zalan.do/teapot/external-dns:latest + args: + - --source=service # ingress is also possible + - --domain-filter=example.com # (optional) limit to only example.com domains + - --provider=transip + - --transip-account=YOUR_TRANSIP_ACCOUNT_NAME + - --transip-keyfile=/transip/transip-api-key + volumeMounts: + - mountPath: /transip + name: transip-api-key + readOnly: true + volumes: + - name: transip-api-key + secret: + secretName: transip-api-key +``` + +### Manifest (for clusters with RBAC enabled) + +```yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: external-dns +--- +apiVersion: rbac.authorization.k8s.io/v1beta1 +kind: ClusterRole +metadata: + name: external-dns +rules: +- apiGroups: [""] + resources: ["services"] + verbs: ["get","watch","list"] +- apiGroups: [""] + resources: ["pods"] + verbs: ["get","watch","list"] +- apiGroups: ["extensions"] + resources: ["ingresses"] + verbs: ["get","watch","list"] +- apiGroups: [""] + resources: ["nodes"] + verbs: ["watch", "list"] +--- +apiVersion: rbac.authorization.k8s.io/v1beta1 +kind: ClusterRoleBinding +metadata: + name: external-dns-viewer +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: external-dns +subjects: +- kind: ServiceAccount + name: external-dns + namespace: default +--- +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: external-dns +spec: + strategy: + type: Recreate + template: + metadata: + labels: + app: external-dns + spec: + serviceAccountName: external-dns + containers: + - name: external-dns + image: registry.opensource.zalan.do/teapot/external-dns:latest + args: + - --source=service # ingress is also possible + - --domain-filter=example.com # (optional) limit to only example.com domains + - --provider=transip + - --transip-account=YOUR_TRANSIP_ACCOUNT_NAME + - --transip-keyfile=/transip/transip-api-key + volumeMounts: + - mountPath: /transip + name: transip-api-key + readOnly: true + volumes: + - name: transip-api-key + secret: + secretName: transip-api-key +``` + +## Deploying an Nginx Service + +Create a service file called 'nginx.yaml' with the following contents: + +```yaml +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: nginx +spec: + template: + metadata: + labels: + app: nginx + spec: + containers: + - image: nginx + name: nginx + ports: + - containerPort: 80 +--- +apiVersion: v1 +kind: Service +metadata: + name: nginx + annotations: + external-dns.alpha.kubernetes.io/hostname: my-app.example.com +spec: + selector: + app: nginx + type: LoadBalancer + ports: + - protocol: TCP + port: 80 + targetPort: 80 +``` + +Note the annotation on the service; this is the name ExternalDNS will create and manage DNS records for. + +ExternalDNS uses this annotation to determine what services should be registered with DNS. Removing the annotation will cause ExternalDNS to remove the corresponding DNS records. + +Create the deployment and service: + +```console +$ kubectl create -f nginx.yaml +``` + +Depending where you run your service it can take a little while for your cloud provider to create an external IP for the service. + +Once the service has an external IP assigned, ExternalDNS will notice the new service IP address and synchronize the TransIP DNS records. + +## Verifying TransIP DNS records + +Check your [TransIP Control Panel](https://transip.eu/cp) to view the records for your TransIP DNS zone. + +Click on the zone for the one created above if a different domain was used. + +This should show the external IP address of the service as the A record for your domain. diff --git a/endpoint/endpoint.go b/endpoint/endpoint.go index ca77f36e2..2d292fb0d 100644 --- a/endpoint/endpoint.go +++ b/endpoint/endpoint.go @@ -109,8 +109,14 @@ func (t Targets) IsLess(o Targets) bool { return false } +// ProviderSpecificProperty holds the name and value of a configuration which is specific to individual DNS providers +type ProviderSpecificProperty struct { + Name string `json:"name,omitempty"` + Value string `json:"value,omitempty"` +} + // ProviderSpecific holds configuration which is specific to individual DNS providers -type ProviderSpecific map[string]string +type ProviderSpecific []ProviderSpecificProperty // Endpoint is a high-level way of a connection between a service and an IP type Endpoint struct { @@ -160,10 +166,21 @@ func (e *Endpoint) WithProviderSpecific(key, value string) *Endpoint { if e.ProviderSpecific == nil { e.ProviderSpecific = ProviderSpecific{} } - e.ProviderSpecific[key] = value + + e.ProviderSpecific = append(e.ProviderSpecific, ProviderSpecificProperty{Name: key, Value: value}) return e } +// GetProviderSpecificProperty returns a ProviderSpecificProperty if the property exists. +func (e *Endpoint) GetProviderSpecificProperty(key string) (ProviderSpecificProperty, bool) { + for _, providerSpecific := range e.ProviderSpecific { + if providerSpecific.Name == key { + return providerSpecific, true + } + } + return ProviderSpecificProperty{}, false +} + func (e *Endpoint) String() string { return fmt.Sprintf("%s %d IN %s %s %s", e.DNSName, e.RecordTTL, e.RecordType, e.Targets, e.ProviderSpecific) } diff --git a/go.mod b/go.mod new file mode 100644 index 000000000..abc39dfd8 --- /dev/null +++ b/go.mod @@ -0,0 +1,108 @@ +module github.com/kubernetes-incubator/external-dns + +go 1.12 + +require ( + cloud.google.com/go v0.34.0 + github.com/Azure/azure-sdk-for-go v10.0.4-beta+incompatible + github.com/Azure/go-autorest v10.9.0+incompatible + github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38 // indirect + github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721 // indirect + github.com/alecthomas/kingpin v2.2.5+incompatible + github.com/alecthomas/repr v0.0.0-20181024024818-d37bc2a10ba1 // indirect + github.com/aliyun/alibaba-cloud-sdk-go v0.0.0-20180828111155-cad214d7d71f + github.com/aws/aws-sdk-go v1.13.32 + github.com/cenkalti/backoff v2.1.1+incompatible // indirect + github.com/cloudflare/cloudflare-go v0.0.0-20190102215809-0c85496d8730 + github.com/cloudfoundry-community/go-cfclient v0.0.0-20190201205600-f136f9222381 + github.com/coreos/bbolt v1.3.2 // indirect + github.com/coreos/etcd v3.3.10+incompatible + github.com/coreos/go-semver v0.2.0 // indirect + github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e // indirect + github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f // indirect + github.com/denverdino/aliyungo v0.0.0-20180815121905-69560d9530f5 + github.com/dgrijalva/jwt-go v3.2.0+incompatible // indirect + github.com/digitalocean/godo v1.1.1 + github.com/dnaeon/go-vcr v1.0.1 // indirect + github.com/dnsimple/dnsimple-go v0.14.0 + github.com/envoyproxy/go-control-plane v0.6.9 // indirect + github.com/exoscale/egoscale v0.11.0 + github.com/ffledgling/pdns-go v0.0.0-20180219074714-524e7daccd99 + github.com/go-ini/ini v1.32.0 // indirect + github.com/go-resty/resty v1.8.0 // indirect + github.com/gogo/googleapis v1.1.0 // indirect + github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef // indirect + github.com/golang/mock v1.2.0 // indirect + github.com/google/btree v0.0.0-20180124185431-e89373fe6b4a // indirect + github.com/google/go-querystring v0.0.0-20170111101155-53e6ce116135 // indirect + github.com/google/gofuzz v0.0.0-20170612174753-24818f796faf // indirect + github.com/googleapis/gnostic v0.2.0 // indirect + github.com/gophercloud/gophercloud v0.0.0-20190126172459-c818fa66e4c8 + github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c // indirect + github.com/gregjones/httpcache v0.0.0-20190212212710-3befbb6ad0cc // indirect + github.com/grpc-ecosystem/go-grpc-middleware v0.0.0-20190222133341-cfaf5686ec79 // indirect + github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 // indirect + github.com/grpc-ecosystem/grpc-gateway v1.8.5 // indirect + github.com/hashicorp/go-multierror v1.0.0 // indirect + github.com/imdario/mergo v0.3.5 // indirect + github.com/inconshreveable/mousetrap v1.0.0 // indirect + github.com/infobloxopen/infoblox-go-client v0.0.0-20180606155407-61dc5f9b0a65 + github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8 // indirect + github.com/jonboulle/clockwork v0.1.0 // indirect + github.com/json-iterator/go v1.1.6 // indirect + github.com/linki/instrumented_http v0.2.0 + github.com/linode/linodego v0.3.0 + github.com/lyft/protoc-gen-validate v0.0.14 // indirect + github.com/mattn/go-isatty v0.0.7 // indirect + github.com/miekg/dns v1.0.8 + github.com/mitchellh/mapstructure v1.1.2 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.1 // indirect + github.com/natefinch/lumberjack v2.0.0+incompatible // indirect + github.com/nesv/go-dynect v0.6.0 + github.com/nic-at/rc0go v1.1.0 + github.com/onsi/ginkgo v1.8.0 // indirect + github.com/onsi/gomega v1.5.0 // indirect + github.com/oracle/oci-go-sdk v1.8.0 + github.com/peterbourgon/diskv v2.0.1+incompatible // indirect + github.com/pkg/errors v0.8.1 + github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829 + github.com/sanyu/dynectsoap v0.0.0-20181203081243-b83de5edc4e0 + github.com/satori/go.uuid v1.2.0 // indirect + github.com/sergi/go-diff v1.0.0 // indirect + github.com/sirupsen/logrus v1.2.0 + github.com/smartystreets/goconvey v0.0.0-20190330032615-68dc04aab96a // indirect + github.com/soheilhy/cmux v0.1.3 // indirect + github.com/spf13/cobra v0.0.3 // indirect + github.com/spf13/pflag v1.0.2 // indirect + github.com/stretchr/testify v1.2.2 + github.com/tent/http-link-go v0.0.0-20130702225549-ac974c61c2f9 // indirect + github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8 // indirect + github.com/transip/gotransip v5.8.2+incompatible + github.com/ugorji/go/codec v0.0.0-20190320090025-2dc34c0b8780 // indirect + github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2 // indirect + github.com/yl2chen/cidranger v0.0.0-20180214081945-928b519e5268 // indirect + go.etcd.io/bbolt v1.3.2 // indirect + go.uber.org/atomic v1.3.2 // indirect + go.uber.org/multierr v1.1.0 // indirect + go.uber.org/zap v1.9.1 // indirect + golang.org/x/net v0.0.0-20190311183353-d8887717615a + golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421 + google.golang.org/api v0.3.0 + google.golang.org/appengine v1.5.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/ini.v1 v1.42.0 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect + gopkg.in/ns1/ns1-go.v2 v2.0.0-20190322154155-0dafb5275fd1 + gopkg.in/yaml.v2 v2.2.2 + istio.io/api v0.0.0-20190321180614-db16d82d3672 + istio.io/istio v0.0.0-20190322063008-2b1331886076 + k8s.io/api v0.0.0-20180628040859-072894a440bd + k8s.io/apiextensions-apiserver v0.0.0-20180628053655-3de98c57bc05 // indirect + k8s.io/apimachinery v0.0.0-20180621070125-103fd098999d + k8s.io/client-go v8.0.0+incompatible + k8s.io/kube-openapi v0.0.0-20190401085232-94e1e7b7574c // indirect + launchpad.net/gocheck v0.0.0-20140225173054-000000000087 // indirect +) + +replace github.com/golang/glog => github.com/kubermatic/glog-logrus v0.0.0-20180829085450-3fa5b9870d1d diff --git a/go.sum b/go.sum new file mode 100644 index 000000000..a2ee94344 --- /dev/null +++ b/go.sum @@ -0,0 +1,385 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0 h1:eOI3/cP2VTU6uZLDYAoic+eyzzB9YyGmJ7eIjl8rOPg= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +code.cloudfoundry.org/gofileutils v0.0.0-20170111115228-4d0c80011a0f h1:UrKzEwTgeiff9vxdrfdqxibzpWjxLnuXDI5m6z3GJAk= +code.cloudfoundry.org/gofileutils v0.0.0-20170111115228-4d0c80011a0f/go.mod h1:sk5LnIjB/nIEU7yP5sDQExVm62wu0pBh3yrElngUisI= +git.apache.org/thrift.git v0.12.0/go.mod h1:fPE2ZNJGynbRyZ4dJvy6G277gSllfV2HJqblrnkyeyg= +github.com/Azure/azure-sdk-for-go v10.0.4-beta+incompatible h1:FhnlL7/4O3gAB7EBgN43vA3Bb0fAlCBIMm9avXbcHlE= +github.com/Azure/azure-sdk-for-go v10.0.4-beta+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= +github.com/Azure/go-autorest v10.9.0+incompatible h1:3ccqKLQg+scl0J6krcDgih2Rl+GC1eNuHZeRQYQxKkk= +github.com/Azure/go-autorest v10.9.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= +github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/Masterminds/semver v1.4.2 h1:WBLTQ37jOCzSLtXNdoo8bNM8876KhNqOKvrlGITgsTc= +github.com/Masterminds/semver v1.4.2/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= +github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo= +github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI= +github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38 h1:smF2tmSOzy2Mm+0dGI2AIUHY+w0BUc+4tn40djz7+6U= +github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38/go.mod h1:r7bzyVFMNntcxPZXK3/+KdruV1H5KSlyVY0gc+NgInI= +github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721 h1:JHZL0hZKJ1VENNfmXvHbgYlbUOvpzYzvy2aZU5gXVeo= +github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721/go.mod h1:QO9JBoKquHd+jz9nshCh40fOfO+JzsoXy8qTHF68zU0= +github.com/alecthomas/kingpin v2.2.5+incompatible h1:umWl1NNd72+ZvRti3T9C0SYean2hPZ7ZhxU8bsgc9BQ= +github.com/alecthomas/kingpin v2.2.5+incompatible/go.mod h1:59OFYbFVLKQKq+mqrL6Rw5bR0c3ACQaawgXx0QYndlE= +github.com/alecthomas/repr v0.0.0-20181024024818-d37bc2a10ba1 h1:GDQdwm/gAcJcLAKQQZGOJ4knlw+7rfEQQcmwTbt4p5E= +github.com/alecthomas/repr v0.0.0-20181024024818-d37bc2a10ba1/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc h1:cAKDfWh5VpdgMhJosfJnn5/FoN2SRZ4p7fJNX58YPaU= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf h1:qet1QNfXsQxTZqLG4oE62mJzwPIB8+Tee4RNCL9ulrY= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/aliyun/alibaba-cloud-sdk-go v0.0.0-20180828111155-cad214d7d71f h1:hinXH9rcBjRoIih5tl4f1BCbNjOmPJ2UnZwcYDhEHR0= +github.com/aliyun/alibaba-cloud-sdk-go v0.0.0-20180828111155-cad214d7d71f/go.mod h1:T9M45xf79ahXVelWoOBmH0y4aC1t5kXO5BxwyakgIGA= +github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= +github.com/aws/aws-sdk-go v1.13.32 h1:AoV2boU+diwKoMaschMtUJim3nmBpM/4y45UqY708F4= +github.com/aws/aws-sdk-go v1.13.32/go.mod h1:ZRmQr0FajVIyZ4ZzBYKG5P3ZqPz9IHG41ZoMu1ADI3k= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973 h1:xJ4a3vCFaGF/jqvzLMYoU8P317H5OQ+Via4RmuPwCS0= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/cenkalti/backoff v2.1.1+incompatible h1:tKJnvO2kl0zmb/jA5UKAt4VoEVw1qxKWjE/Bpp46npY= +github.com/cenkalti/backoff v2.1.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cloudflare/cloudflare-go v0.0.0-20190102215809-0c85496d8730 h1:+TK6ytATp7coqI4UlTBboFYD0kSkWZt6L6/T+1yBK6k= +github.com/cloudflare/cloudflare-go v0.0.0-20190102215809-0c85496d8730/go.mod h1:qKQ9S///VKEax9N8kFel9/AvmnkYgvb8uiKTnoVFvpg= +github.com/cloudfoundry-community/go-cfclient v0.0.0-20190201205600-f136f9222381 h1:rdRS5BT13Iae9ssvcslol66gfOOXjaLYwqerEn/cl9s= +github.com/cloudfoundry-community/go-cfclient v0.0.0-20190201205600-f136f9222381/go.mod h1:e5+USP2j8Le2M0Jo3qKPFnNhuo1wueU4nWHCXBOfQ14= +github.com/codegangsta/cli v1.20.0/go.mod h1:/qJNoX69yVSKu5o4jLyXAENLRyk1uhi7zkbQ3slBdOA= +github.com/codegangsta/inject v0.0.0-20150114235600-33e0aa1cb7c0/go.mod h1:4Zcjuz89kmFXt9morQgcfYZAYZ5n8WHjt81YYWIwtTM= +github.com/coreos/bbolt v1.3.2 h1:wZwiHHUieZCquLkDL0B8UhzreNWsPHooDAG3q34zk0s= +github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= +github.com/coreos/etcd v3.3.10+incompatible h1:jFneRYjIvLMLhDLCzuTuU4rSJUjRplcJQ7pD7MnhC04= +github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-semver v0.2.0 h1:3Jm3tLmsgAYcjC+4Up7hJrFBPr+n7rAqYeSw/SZazuY= +github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e h1:Wf6HqHfScWJN9/ZjdUKyjop4mf3Qdd+1TvvltAvM3m8= +github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f h1:lBNOc5arjvs8E5mO2tbpBpLoyyu8B6e44T7hJy6potg= +github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/denverdino/aliyungo v0.0.0-20180815121905-69560d9530f5 h1:YjnQWGUNtqeKqndapy9V1BzlfMwc/dBJf2MU9dmuXSQ= +github.com/denverdino/aliyungo v0.0.0-20180815121905-69560d9530f5/go.mod h1:dV8lFg6daOBZbT6/BDGIz6Y3WFGn8juu6G+CQ6LHtl0= +github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/digitalocean/godo v1.1.1 h1:v0A7yF3xmKLjjdJGIeBbINfMufcrrRhqZsxuVQMoT+U= +github.com/digitalocean/godo v1.1.1/go.mod h1:h6faOIcZ8lWIwNQ+DN7b3CgX4Kwby5T+nbpNqkUIozU= +github.com/dnaeon/go-vcr v1.0.1 h1:r8L/HqC0Hje5AXMu1ooW8oyQyOFv4GxqpL0nRP7SLLY= +github.com/dnaeon/go-vcr v1.0.1/go.mod h1:aBB1+wY4s93YsC3HHjMBMrwTj2R9FHDzUr9KyGc8n1E= +github.com/dnsimple/dnsimple-go v0.14.0 h1:JGYtVVA/uHc91q0LjDWqR1oVj6EGu9Kn0lMRxjH/w30= +github.com/dnsimple/dnsimple-go v0.14.0/go.mod h1:0FYu4qVNv/UcfZPNwa9zi68IkggJu3TIwM54D7rhmI4= +github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= +github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= +github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= +github.com/envoyproxy/go-control-plane v0.6.9 h1:deEH9W8ZAUGNbCdX+9iNzBOGrAOrnpJGoy0PcTqk/tE= +github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g= +github.com/exoscale/egoscale v0.11.0 h1:g+UBsxLDouKWW2BK/UTgQFAVnM2aHygheF0Dxj0ycC8= +github.com/exoscale/egoscale v0.11.0/go.mod h1:Ee3U4ZjSDpbbEc9VkQ/jttUU8USE8Nv7L3YzVi03Y1U= +github.com/ffledgling/pdns-go v0.0.0-20180219074714-524e7daccd99 h1:jmwW6QWvUO2OPe22YfgFvBaaZlSr8Rlrac5lZvG6IdM= +github.com/ffledgling/pdns-go v0.0.0-20180219074714-524e7daccd99/go.mod h1:4mP9w9+vYGw2jUx2+2v03IA+phyQQjNRR4AL3uxlNrs= +github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-ini/ini v1.32.0 h1:/MArBHSS0TFR28yPPDK1vPIjt4wUnPBfb81i6iiyKvA= +github.com/go-ini/ini v1.32.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-martini/martini v0.0.0-20170121215854-22fa46961aab/go.mod h1:/P9AEU963A2AYjv4d1V5eVL1CQbEJq6aCNHDDjibzu8= +github.com/go-resty/resty v1.8.0 h1:vbNCxbHOWCototzwxf3L63PQCKx6xgT6v8SHfoqkp6U= +github.com/go-resty/resty v1.8.0/go.mod h1:n37daLLGIHq2FFYHxg+FYQiwA95FpfNI+A9uxoIYGRk= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/gogo/googleapis v1.1.0 h1:kFkMAZBNAn4j7K0GiZr8cRYzejq68VbheufiV3YuyFI= +github.com/gogo/googleapis v1.1.0/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFGgqEef3s= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.2.0 h1:xU6/SpYbvkNYiptHJYEDRseDLvYE7wSqhYYNy0QSUzI= +github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef h1:veQD95Isof8w9/WXiA+pa3tz3fJXkt5B7QaRBrM62gk= +github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/btree v0.0.0-20180124185431-e89373fe6b4a h1:ZJu5NB1Bk5ms4vw0Xu4i+jD32SE9jQXyfnOvwhHqlT0= +github.com/google/btree v0.0.0-20180124185431-e89373fe6b4a/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-querystring v0.0.0-20170111101155-53e6ce116135 h1:zLTLjkaOFEFIOxY5BWLFLwh+cL8vOBW4XJ2aqLE/Tf0= +github.com/google/go-querystring v0.0.0-20170111101155-53e6ce116135/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +github.com/google/gofuzz v0.0.0-20170612174753-24818f796faf h1:+RRA9JqSOZFfKrOeqr2z77+8R2RKyh8PG66dcu1V0ck= +github.com/google/gofuzz v0.0.0-20170612174753-24818f796faf/go.mod h1:HP5RmnzzSNb993RKQDq4+1A4ia9nllfqcQFTQJedwGI= +github.com/googleapis/gnostic v0.2.0 h1:l6N3VoaVzTncYYW+9yOz2LJJammFZGBO13sqgEhpy9g= +github.com/googleapis/gnostic v0.2.0/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY= +github.com/gophercloud/gophercloud v0.0.0-20190126172459-c818fa66e4c8 h1:L9JPKrtsHMQ4VCRQfHvbbHBfB2Urn8xf6QZeXZ+OrN4= +github.com/gophercloud/gophercloud v0.0.0-20190126172459-c818fa66e4c8/go.mod h1:3WdhXV3rUYy9p6AUW8d94kr+HS62Y4VL9mBnFxsD8q4= +github.com/gopherjs/gopherjs v0.0.0-20180628210949-0892b62f0d9f/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= +github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= +github.com/gorilla/mux v1.6.2 h1:Pgr17XVTNXAk3q/r4CpKzC5xBM/qW1uVLV+IhRZpIIk= +github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= +github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c h1:Lh2aW+HnU2Nbe1gqD9SOJLJxW1jBMmQOktN2acDyJk8= +github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/gregjones/httpcache v0.0.0-20190212212710-3befbb6ad0cc h1:f8eY6cV/x1x+HLjOp4r72s/31/V2aTUtg5oKRRPf8/Q= +github.com/gregjones/httpcache v0.0.0-20190212212710-3befbb6ad0cc/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +github.com/grpc-ecosystem/go-grpc-middleware v0.0.0-20190222133341-cfaf5686ec79 h1:lR9ssWAqp9qL0bALxqEEkuudiP1eweOdv9jsRK3e7lE= +github.com/grpc-ecosystem/go-grpc-middleware v0.0.0-20190222133341-cfaf5686ec79/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= +github.com/grpc-ecosystem/grpc-gateway v1.6.2/go.mod h1:RSKVYQBd5MCa4OVpNdGskqpgL2+G+NZTnrVHpWWfpdw= +github.com/grpc-ecosystem/grpc-gateway v1.8.5 h1:2+KSC78XiO6Qy0hIjfc1OD9H+hsaJdJlb8Kqsd41CTE= +github.com/grpc-ecosystem/grpc-gateway v1.8.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= +github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.0.0 h1:iVjPR7a6H0tWELX5NxNe7bYopibicUzc7uPribsnS6o= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/golang-lru v0.5.0 h1:CL2msUPvZTLb5O648aiLNJw3hnBxN2+1Jq8rCOH9wdo= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/imdario/mergo v0.3.5 h1:JboBksRwiiAJWvIYJVo46AfV+IAIKZpfrSzVKj42R4Q= +github.com/imdario/mergo v0.3.5/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= +github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/infobloxopen/infoblox-go-client v0.0.0-20180606155407-61dc5f9b0a65 h1:FP5rOFP4ifbtFIjFHJmwhFrsbDyONILK/FNntl/Pou8= +github.com/infobloxopen/infoblox-go-client v0.0.0-20180606155407-61dc5f9b0a65/go.mod h1:BXiw7S2b9qJoM8MS40vfgCNB2NLHGusk1DtO16BD9zI= +github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8 h1:12VvqtR6Aowv3l/EQUlocDHW2Cp4G9WJVH7uyH8QFJE= +github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= +github.com/jonboulle/clockwork v0.1.0 h1:VKV+ZcuP6l3yW9doeqz6ziZGgcynBVQO+obU0+0hcPo= +github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= +github.com/json-iterator/go v1.1.6 h1:MrUvLMLTMxbqFJ9kzlvat/rYZqZnW3u4wkLzWTaFwKs= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +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/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kubermatic/glog-logrus v0.0.0-20180829085450-3fa5b9870d1d h1:JV46OtdhH2vVt8mJ1EWUE94k99vbN9fZs1WQ8kcEapU= +github.com/kubermatic/glog-logrus v0.0.0-20180829085450-3fa5b9870d1d/go.mod h1:CHQ3o5KBH1PIS2Fb1mRLTIWO5YzP9kSUB3KoCICwlvA= +github.com/linki/instrumented_http v0.2.0 h1:zLhcB3Q/McQQqml3qd5kzdZ0cGnL3vquPFIW2338f5Y= +github.com/linki/instrumented_http v0.2.0/go.mod h1:pjYbItoegfuVi2GUOMhEqzvm/SJKuEL3H0tc8QRLRFk= +github.com/linode/linodego v0.3.0 h1:I83pEPg4owSy5pCPaKix7xkGbWIjPxmAoc/Yu5OYDDY= +github.com/linode/linodego v0.3.0/go.mod h1:ga11n3ivecUrPCHN0rANxKmfWBJVkOXfLMZinAbj2sY= +github.com/lyft/protoc-gen-validate v0.0.14 h1:xbdDVIHd0Xq5Bfzu+8JR9s7mFmJPMvNLmfGhgcHJdFU= +github.com/lyft/protoc-gen-validate v0.0.14/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ= +github.com/martini-contrib/render v0.0.0-20150707142108-ec18f8345a11/go.mod h1:Ah2dBMoxZEqk118as2T4u4fjfXarE0pPnMJaArZQZsI= +github.com/mattn/go-isatty v0.0.7 h1:UvyT9uN+3r7yLEYSlJsbQGdsaB/a0DlgWP3pql6iwOc= +github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/miekg/dns v1.0.8 h1:Zi8HNpze3NeRWH1PQV6O71YcvJRQ6j0lORO6DAEmAAI= +github.com/miekg/dns v1.0.8/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= +github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/natefinch/lumberjack v2.0.0+incompatible h1:4QJd3OLAMgj7ph+yZTuX13Ld4UpgHp07nNdFX7mqFfM= +github.com/natefinch/lumberjack v2.0.0+incompatible/go.mod h1:Wi9p2TTF5DG5oU+6YfsmYQpsTIOm0B1VNzQg9Mw6nPk= +github.com/nesv/go-dynect v0.6.0 h1:Ow/DiSm4LAISwnFku/FITSQHnU6pBvhQMsUE5Gu6Oq4= +github.com/nesv/go-dynect v0.6.0/go.mod h1:GHRBRKzTwjAMhosHJQq/KrZaFkXIFyJ5zRE7thGXXrs= +github.com/nic-at/rc0go v1.1.0 h1:k6/Bru/npTjmCSFw65ulYRw/b3ycIS30t6/YM4r42V4= +github.com/nic-at/rc0go v1.1.0/go.mod h1:KEa3H5fmDNXCaXSqOeAZxkKnG/8ggr1OHIG25Ve7fjU= +github.com/olekukonko/tablewriter v0.0.1/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.8.0 h1:VkHVNpR4iVnU8XQR6DBm8BqYjN7CRzw+xKUbVVbbW9w= +github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.5.0 h1:izbySO9zDPmjJ8rDjLvkA2zJHIo+HkYXHnf7eN7SSyo= +github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/openzipkin/zipkin-go v0.1.3/go.mod h1:NtoC/o8u3JlF1lSlyPNswIbeQH9bJTmOf0Erfk+hxe8= +github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw= +github.com/oracle/oci-go-sdk v1.8.0 h1:4SO45bKV0I3/Mn1os3ANDZmV0eSE5z5CLdSUIkxtyzs= +github.com/oracle/oci-go-sdk v1.8.0/go.mod h1:VQb79nF8Z2cwLkLS35ukwStZIg5F66tcBccjip/j888= +github.com/oxtoacart/bpool v0.0.0-20150712133111-4e1c5567d7c2/go.mod h1:L3UMQOThbttwfYRNFOWLLVXMhk5Lkio4GGOtw5UrxS0= +github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= +github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= +github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= +github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829 h1:D+CiwcpGTW6pL6bv6KI3KbyEyCKyS+1JWS2h8PNDnGA= +github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f h1:BVwpUVJDADN2ufcGik7W992pyps0wZ888b/y9GXcLTU= +github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/common v0.2.0 h1:kUZDBDTdBVBYBj5Tmh2NZLlF60mfjA27rM34b+cVwNU= +github.com/prometheus/common v0.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1 h1:/K3IL0Z1quvmJ7X0A1AwNEK7CRkVK3YwfOU/QAL4WGg= +github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= +github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= +github.com/sanyu/dynectsoap v0.0.0-20181203081243-b83de5edc4e0 h1:vOcHdR1nu7DO4BAx1rwzdHV7jQTzW3gqcBT5qxHSc6A= +github.com/sanyu/dynectsoap v0.0.0-20181203081243-b83de5edc4e0/go.mod h1:FeplEtXXejBYC4NPAFTrs5L7KuK+5RL9bf5nB2vZe9o= +github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= +github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= +github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= +github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/sirupsen/logrus v1.2.0 h1:juTguoYk5qI21pwyTXY3B3Y5cOTH3ZUyZCg1v/mihuo= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/smartystreets/assertions v0.0.0-20180725160413-e900ae048470/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v0.0.0-20180222194500-ef6db91d284a/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s= +github.com/smartystreets/goconvey v0.0.0-20190330032615-68dc04aab96a/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/soheilhy/cmux v0.1.3 h1:09wy7WZk4AqO03yH85Ex1X+Uo3vDsil3Fa9AgF8Emss= +github.com/soheilhy/cmux v0.1.3/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= +github.com/spf13/cobra v0.0.3 h1:ZlrZ4XsMRm04Fr5pSFxBgfND2EBVa1nLpiy1stUsX/8= +github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= +github.com/spf13/pflag v1.0.2 h1:Fy0orTDgHdbnzHcsOgfCN4LtHf0ec3wwtiwJqwvf3Gc= +github.com/spf13/pflag v1.0.2/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/tent/http-link-go v0.0.0-20130702225549-ac974c61c2f9 h1:/Bsw4C+DEdqPjt8vAqaC9LAqpAQnaCQQqmolqq3S1T4= +github.com/tent/http-link-go v0.0.0-20130702225549-ac974c61c2f9/go.mod h1:RHkNRtSLfOK7qBTHaeSX1D6BNpI3qw7NTxsmNr4RvN8= +github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8 h1:ndzgwNDnKIqyCvHTXaCqh9KlOWKvBry6nuXMJmonVsE= +github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= +github.com/transip/gotransip v5.8.2+incompatible h1:aNJhw/w/3QBqFcHAIPz1ytoK5FexeMzbUCGrrhWr3H0= +github.com/transip/gotransip v5.8.2+incompatible/go.mod h1:uacMoJVmrfOcscM4Bi5NVg708b7c6rz2oDTWqa7i2Ic= +github.com/ugorji/go v1.1.2 h1:JON3E2/GPW2iDNGoSAusl1KDf5TRQ8k8q7Tp097pZGs= +github.com/ugorji/go v1.1.2/go.mod h1:hnLbHMwcvSihnDhEfx2/BzKp2xb0Y+ErdfYcrs9tkJQ= +github.com/ugorji/go/codec v0.0.0-20190320090025-2dc34c0b8780 h1:vG/gY/PxA3v3l04qxe3tDjXyu3bozii8ulSlIPOYKhI= +github.com/ugorji/go/codec v0.0.0-20190320090025-2dc34c0b8780/go.mod h1:iT03XoTwV7xq/+UGwKO3UbC1nNNlopQiY61beSdrtOA= +github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2 h1:eY9dn8+vbi4tKz5Qo6v2eYzo7kUS51QINcR5jNpbZS8= +github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +github.com/yl2chen/cidranger v0.0.0-20180214081945-928b519e5268 h1:lkoOjizoHqOcEFsvYGE5c8Ykdijjnd0R3r1yDYHzLno= +github.com/yl2chen/cidranger v0.0.0-20180214081945-928b519e5268/go.mod h1:mq0zhomp/G6rRTb0dvHWXRHr/2+Qgeq5hMXfJ670+i4= +go.etcd.io/bbolt v1.3.2 h1:Z/90sZLPOeCy2PwprqkFa25PdkusRzaj9P8zm/KNyvk= +go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= +go.opencensus.io v0.19.1/go.mod h1:gug0GbSHa8Pafr0d2urOSgoXHZ6x/RUlaiT0d9pqb4A= +go.opencensus.io v0.19.2 h1:ZZpq6xI6kv/LuE/5s5UQvBU5vMjvRnPb8PvJrIntAnc= +go.opencensus.io v0.19.2/go.mod h1:NO/8qkisMZLZ1FCsKNqtJPwc8/TaclWyY0B6wcYNg9M= +go.uber.org/atomic v1.3.2 h1:2Oa65PReHzfn29GpvgsYwloV9AVFHPDk8tYxt2c2tr4= +go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/multierr v1.1.0 h1:HoEmRHQPVSqub6w2z2d2EOVs2fjyFRGyofhKuyDq0QI= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/zap v1.9.1 h1:XCJQEf3W6eZaVwhRBof6ImoYGJSITeKWsyeh3HFu/5o= +go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20181217174547-8f45f776aaf1/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/net v0.0.0-20180611182652-db08ff08e862/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181029044818-c44066c5c816/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181106065722-10aee1819953/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190125091013-d26f9f9a57f3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a h1:oWX7TPOiFAMXLq8o0ikBYfCJVlRHBcsciT5bXOrH628= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190130055435-99b60b757ec1/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421 h1:Wo7BWFiOk0QRFMLYMqJGFMd9CgUAcGx7V+qEg/h5IBI= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f h1:Bl/8QSvNqXvPGPGXa2z5xUTmV7VDcZyvRZ+QQXkXTZQ= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6 h1:bjcUS9ztw9kFmmIxJInhon/0Is3p+EHBKNgquIzo1OI= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181218192612-074acd46bca6/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223 h1:DH4skfRX4EBpamg7iV4ZlCpblAHI6s6TDM39bFZumv8= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2 h1:+DCIGbF/swA92ohVg0//6X2IVY3KZs6p9mix0ziNYJM= +golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181219222714-6e267b5cc78e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +google.golang.org/api v0.0.0-20181220000619-583d854617af/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0= +google.golang.org/api v0.2.0/go.mod h1:IfRCZScioGtypHNTlz3gFk67J8uePVW7uDTBzXuIkhU= +google.golang.org/api v0.3.0 h1:UIJY20OEo3+tK5MBlcdx37kmdH6EnRjGkW78mc6+EeA= +google.golang.org/api v0.3.0/go.mod h1:IuvZyQh8jgscv8qWfQ4ABd8m7hEudgBFM/EdhA3BnXw= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.5.0 h1:KxkO13IPW4Lslp2bz+KHP2E3gtFlrIGNThxkZQ3g+4c= +google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20181219182458-5a97ab628bfb/go.mod h1:7Ep/1NZk928CDR8SjdVbjWNpdIf6nzjE3BTgJDr2Atg= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19 h1:Lj2SnHtxkRGJDqnGaSjo+CCdIieEnwVazbOXILwQemk= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/grpc v1.16.0/go.mod h1:0JHn/cJsOMiMfNA9+DeHDlAU7KAAB5GDlYFpa9MZMio= +google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= +google.golang.org/grpc v1.19.0 h1:cfg4PD8YEdSFnm7qLV4++93WcmhH2nIUhMjhdCvl3j8= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/ini.v1 v1.42.0 h1:7N3gPTt50s8GuLortA00n8AqRTk75qOP98+mTPpgzRk= +gopkg.in/ini.v1 v1.42.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/natefinch/lumberjack.v2 v2.0.0 h1:1Lc07Kr7qY4U2YPouBjpCLxpiyxIVoxqXgkXLknAOE8= +gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= +gopkg.in/ns1/ns1-go.v2 v2.0.0-20190322154155-0dafb5275fd1 h1:+fgY/3ngqdBW9oLQCMwL5g+QRkKFPJH05fx2/pipqRQ= +gopkg.in/ns1/ns1-go.v2 v2.0.0-20190322154155-0dafb5275fd1/go.mod h1:VV+3haRsgDiVLxyifmMBrBIuCWFBPYKbRssXB9z67Hw= +gopkg.in/resty.v1 v1.12.0 h1:CuXP0Pjfw9rOuY6EP+UvtNvt5DSqHpIxILZKT/quCZI= +gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20180920025451-e3ad64cb4ed3/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +istio.io/api v0.0.0-20190321180614-db16d82d3672 h1:luY97pBVarSo1v++zf2kgb84Q55G5hv/ult2A4KPQuk= +istio.io/api v0.0.0-20190321180614-db16d82d3672/go.mod h1:hhLFQmpHia8zgaM37vb2ml9iS5NfNfqZGRt1pS9aVEo= +istio.io/istio v0.0.0-20190322063008-2b1331886076 h1:gZhCrmVzfQJoDl4oav8i5+NF7p7v0M1Pou+2O+hZBtc= +istio.io/istio v0.0.0-20190322063008-2b1331886076/go.mod h1:OWBySrQjjk549IhxWCt7DTl9ZSsXdvbgm+SmgGVRsGA= +k8s.io/api v0.0.0-20180628040859-072894a440bd h1:HzgYeLDS1jLxw8DGr68KJh9cdQ5iZJizG0HZWstIhfQ= +k8s.io/api v0.0.0-20180628040859-072894a440bd/go.mod h1:iuAfoD4hCxJ8Onx9kaTIt30j7jUFS00AXQi6QMi99vA= +k8s.io/apiextensions-apiserver v0.0.0-20180628053655-3de98c57bc05 h1:uKDX+1GgQuV/J6TTgrtHYGRFZUPWxC13mJwBhjIhm/w= +k8s.io/apiextensions-apiserver v0.0.0-20180628053655-3de98c57bc05/go.mod h1:IxkesAMoaCRoLrPJdZNZUQp9NfZnzqaVzLhb2VEQzXE= +k8s.io/apimachinery v0.0.0-20180621070125-103fd098999d h1:MZjlsu9igBoVPZkXpIGoxI6EonqNsXXZU7hhvfQLkd4= +k8s.io/apimachinery v0.0.0-20180621070125-103fd098999d/go.mod h1:ccL7Eh7zubPUSh9A3USN90/OzHNSVN6zxzde07TDCL0= +k8s.io/client-go v8.0.0+incompatible h1:tTI4hRmb1DRMl4fG6Vclfdi6nTM82oIrTT7HfitmxC4= +k8s.io/client-go v8.0.0+incompatible/go.mod h1:7vJpHMYJwNQCWgzmNV+VYUl1zCObLyodBc8nIyt8L5s= +k8s.io/kube-openapi v0.0.0-20190401085232-94e1e7b7574c h1:kJCzg2vGCzah5icgkKR7O1Dzn0NA2iGlym27sb0ZfGE= +k8s.io/kube-openapi v0.0.0-20190401085232-94e1e7b7574c/go.mod h1:BXM9ceUBTj2QnfH2MK1odQs778ajze1RxcmP6S8RVVc= +launchpad.net/gocheck v0.0.0-20140225173054-000000000087 h1:Izowp2XBH6Ya6rv+hqbceQyw/gSGoXfH/UPoTGduL54= +launchpad.net/gocheck v0.0.0-20140225173054-000000000087/go.mod h1:hj7XX3B/0A+80Vse0e+BUHsHMTEhd0O4cpUHr/e/BUM= diff --git a/internal/testutils/endpoint.go b/internal/testutils/endpoint.go index f13a6b4c7..d804c75ea 100644 --- a/internal/testutils/endpoint.go +++ b/internal/testutils/endpoint.go @@ -17,6 +17,7 @@ limitations under the License. package testutils import ( + "reflect" "sort" "github.com/kubernetes-incubator/external-dns/endpoint" @@ -49,7 +50,7 @@ func SameEndpoint(a, b *endpoint.Endpoint) bool { return a.DNSName == b.DNSName && a.Targets.Same(b.Targets) && a.RecordType == b.RecordType && a.Labels[endpoint.OwnerLabelKey] == b.Labels[endpoint.OwnerLabelKey] && a.RecordTTL == b.RecordTTL && a.Labels[endpoint.ResourceLabelKey] == b.Labels[endpoint.ResourceLabelKey] && - SameMap(a.ProviderSpecific, b.ProviderSpecific) + SameProverSpecific(a.ProviderSpecific, b.ProviderSpecific) } // SameEndpoints compares two slices of endpoints regardless of order @@ -81,17 +82,7 @@ func SamePlanChanges(a, b map[string][]*endpoint.Endpoint) bool { SameEndpoints(a["UpdateOld"], b["UpdateOld"]) && SameEndpoints(a["UpdateNew"], b["UpdateNew"]) } -// SameMap verifies that two maps contain the same string/string key/value pairs -func SameMap(a, b map[string]string) bool { - if len(a) != len(b) { - return false - } - - for k, v := range a { - if v != b[k] { - return false - } - } - - return true +// SameProverSpecific verifies that two maps contain the same string/string key/value pairs +func SameProverSpecific(a, b endpoint.ProviderSpecific) bool { + return reflect.DeepEqual(a, b) } diff --git a/internal/testutils/endpoint_test.go b/internal/testutils/endpoint_test.go index f14ae655c..2f1204fd6 100644 --- a/internal/testutils/endpoint_test.go +++ b/internal/testutils/endpoint_test.go @@ -56,9 +56,11 @@ func ExampleSameEndpoints() { RecordTTL: endpoint.TTL(60), }, { - DNSName: "example.org", - Targets: endpoint.Targets{"load-balancer.org"}, - ProviderSpecific: endpoint.ProviderSpecific{"foo": "bar"}, + DNSName: "example.org", + Targets: endpoint.Targets{"load-balancer.org"}, + ProviderSpecific: endpoint.ProviderSpecific{ + endpoint.ProviderSpecificProperty{Name: "foo", Value: "bar"}, + }, }, } sort.Sort(byAllFields(eps)) @@ -66,11 +68,11 @@ func ExampleSameEndpoints() { fmt.Println(ep) } // Output: - // abc.com 0 IN A 1.2.3.4 map[] - // abc.com 0 IN TXT something map[] - // bbc.com 0 IN CNAME foo.com map[] - // cbc.com 60 IN CNAME foo.com map[] - // example.org 0 IN load-balancer.org map[] - // example.org 0 IN load-balancer.org map[foo:bar] - // example.org 0 IN TXT load-balancer.org map[] + // abc.com 0 IN A 1.2.3.4 [] + // abc.com 0 IN TXT something [] + // bbc.com 0 IN CNAME foo.com [] + // cbc.com 60 IN CNAME foo.com [] + // example.org 0 IN load-balancer.org [] + // example.org 0 IN load-balancer.org [{foo bar}] + // example.org 0 IN TXT load-balancer.org [] } diff --git a/main.go b/main.go index b32b5fd45..d707b645a 100644 --- a/main.go +++ b/main.go @@ -67,20 +67,24 @@ func main() { // Create a source.Config from the flags passed by the user. sourceCfg := &source.Config{ - Namespace: cfg.Namespace, - AnnotationFilter: cfg.AnnotationFilter, - FQDNTemplate: cfg.FQDNTemplate, - CombineFQDNAndAnnotation: cfg.CombineFQDNAndAnnotation, - Compatibility: cfg.Compatibility, - PublishInternal: cfg.PublishInternal, - PublishHostIP: cfg.PublishHostIP, - ConnectorServer: cfg.ConnectorSourceServer, - CRDSourceAPIVersion: cfg.CRDSourceAPIVersion, - CRDSourceKind: cfg.CRDSourceKind, - KubeConfig: cfg.KubeConfig, - KubeMaster: cfg.Master, - ServiceTypeFilter: cfg.ServiceTypeFilter, - IstioIngressGateway: cfg.IstioIngressGateway, + Namespace: cfg.Namespace, + AnnotationFilter: cfg.AnnotationFilter, + FQDNTemplate: cfg.FQDNTemplate, + CombineFQDNAndAnnotation: cfg.CombineFQDNAndAnnotation, + IgnoreHostnameAnnotation: cfg.IgnoreHostnameAnnotation, + Compatibility: cfg.Compatibility, + PublishInternal: cfg.PublishInternal, + PublishHostIP: cfg.PublishHostIP, + ConnectorServer: cfg.ConnectorSourceServer, + CRDSourceAPIVersion: cfg.CRDSourceAPIVersion, + CRDSourceKind: cfg.CRDSourceKind, + KubeConfig: cfg.KubeConfig, + KubeMaster: cfg.Master, + ServiceTypeFilter: cfg.ServiceTypeFilter, + IstioIngressGatewayServices: cfg.IstioIngressGatewayServices, + CFAPIEndpoint: cfg.CFAPIEndpoint, + CFUsername: cfg.CFUsername, + CFPassword: cfg.CFPassword, } // Lookup all the selected sources by names and pass them the desired configuration. @@ -99,6 +103,7 @@ func main() { domainFilter := provider.NewDomainFilter(cfg.DomainFilter) zoneIDFilter := provider.NewZoneIDFilter(cfg.ZoneIDFilter) zoneTypeFilter := provider.NewZoneTypeFilter(cfg.AWSZoneType) + zoneTagFilter := provider.NewZoneTagFilter(cfg.AWSZoneTagFilter) var p provider.Provider switch cfg.Provider { @@ -110,10 +115,12 @@ func main() { DomainFilter: domainFilter, ZoneIDFilter: zoneIDFilter, ZoneTypeFilter: zoneTypeFilter, + ZoneTagFilter: zoneTagFilter, BatchChangeSize: cfg.AWSBatchChangeSize, BatchChangeInterval: cfg.AWSBatchChangeInterval, EvaluateTargetHealth: cfg.AWSEvaluateTargetHealth, AssumeRole: cfg.AWSAssumeRole, + APIRetries: cfg.AWSAPIRetries, DryRun: cfg.DryRun, }, ) @@ -127,7 +134,9 @@ func main() { case "azure": p, err = provider.NewAzureProvider(cfg.AzureConfigFile, domainFilter, zoneIDFilter, cfg.AzureResourceGroup, cfg.DryRun) case "cloudflare": - p, err = provider.NewCloudFlareProvider(domainFilter, zoneIDFilter, cfg.CloudflareProxied, cfg.DryRun) + p, err = provider.NewCloudFlareProvider(domainFilter, zoneIDFilter, cfg.CloudflareZonesPerPage, cfg.CloudflareProxied, cfg.DryRun) + case "rcodezero": + p, err = provider.NewRcodeZeroProvider(domainFilter, cfg.DryRun, cfg.RcodezeroTXTEncrypt) case "google": p, err = provider.NewGoogleProvider(cfg.GoogleProject, domainFilter, zoneIDFilter, cfg.DryRun) case "digitalocean": @@ -147,6 +156,7 @@ func main() { Password: cfg.InfobloxWapiPassword, Version: cfg.InfobloxWapiVersion, SSLVerify: cfg.InfobloxSSLVerify, + View: cfg.InfobloxView, DryRun: cfg.DryRun, }, ) @@ -194,6 +204,18 @@ func main() { } case "rfc2136": p, err = provider.NewRfc2136Provider(cfg.RFC2136Host, cfg.RFC2136Port, cfg.RFC2136Zone, cfg.RFC2136Insecure, cfg.RFC2136TSIGKeyName, cfg.RFC2136TSIGSecret, cfg.RFC2136TSIGSecretAlg, cfg.RFC2136TAXFR, domainFilter, cfg.DryRun, nil) + case "ns1": + p, err = provider.NewNS1Provider( + provider.NS1Config{ + DomainFilter: domainFilter, + ZoneIDFilter: zoneIDFilter, + NS1Endpoint: cfg.NS1Endpoint, + NS1IgnoreSSL: cfg.NS1IgnoreSSL, + DryRun: cfg.DryRun, + }, + ) + case "transip": + p, err = provider.NewTransIPProvider(cfg.TransIPAccountName, cfg.TransIPPrivateKeyFile, domainFilter, cfg.DryRun) default: log.Fatalf("unknown dns provider: %s", cfg.Provider) } diff --git a/pkg/apis/externaldns/types.go b/pkg/apis/externaldns/types.go index 3e67a8bf9..0b3c4dde5 100644 --- a/pkg/apis/externaldns/types.go +++ b/pkg/apis/externaldns/types.go @@ -18,6 +18,7 @@ package externaldns import ( "fmt" + "reflect" "strconv" "time" @@ -36,143 +37,169 @@ var ( // Config is a project-wide configuration type Config struct { - Master string - KubeConfig string - RequestTimeout time.Duration - IstioIngressGateway string - Sources []string - Namespace string - AnnotationFilter string - FQDNTemplate string - CombineFQDNAndAnnotation bool - Compatibility string - PublishInternal bool - PublishHostIP bool - ConnectorSourceServer string - Provider string - GoogleProject string - DomainFilter []string - ZoneIDFilter []string - AlibabaCloudConfigFile string - AlibabaCloudZoneType string - AWSZoneType string - AWSAssumeRole string - AWSBatchChangeSize int - AWSBatchChangeInterval time.Duration - AWSEvaluateTargetHealth bool - AzureConfigFile string - AzureResourceGroup string - CloudflareProxied bool - InfobloxGridHost string - InfobloxWapiPort int - InfobloxWapiUsername string - InfobloxWapiPassword string - InfobloxWapiVersion string - InfobloxSSLVerify bool - DynCustomerName string - DynUsername string - DynPassword string - DynMinTTLSeconds int - OCIConfigFile string - InMemoryZones []string - PDNSServer string - PDNSAPIKey string - PDNSTLSEnabled bool - TLSCA string - TLSClientCert string - TLSClientCertKey string - Policy string - Registry string - TXTOwnerID string - TXTPrefix string - Interval time.Duration - Once bool - DryRun bool - LogFormat string - MetricsAddress string - LogLevel string - TXTCacheInterval time.Duration - ExoscaleEndpoint string - ExoscaleAPIKey string - ExoscaleAPISecret string - CRDSourceAPIVersion string - CRDSourceKind string - ServiceTypeFilter []string - RFC2136Host string - RFC2136Port int - RFC2136Zone string - RFC2136Insecure bool - RFC2136TSIGKeyName string - RFC2136TSIGSecret string - RFC2136TSIGSecretAlg string - RFC2136TAXFR bool + Master string + KubeConfig string + RequestTimeout time.Duration + IstioIngressGatewayServices []string + Sources []string + Namespace string + AnnotationFilter string + FQDNTemplate string + CombineFQDNAndAnnotation bool + IgnoreHostnameAnnotation bool + Compatibility string + PublishInternal bool + PublishHostIP bool + ConnectorSourceServer string + Provider string + GoogleProject string + DomainFilter []string + ZoneIDFilter []string + AlibabaCloudConfigFile string + AlibabaCloudZoneType string + AWSZoneType string + AWSZoneTagFilter []string + AWSAssumeRole string + AWSBatchChangeSize int + AWSBatchChangeInterval time.Duration + AWSEvaluateTargetHealth bool + AWSAPIRetries int + AzureConfigFile string + AzureResourceGroup string + CloudflareProxied bool + CloudflareZonesPerPage int + RcodezeroTXTEncrypt bool + InfobloxGridHost string + InfobloxWapiPort int + InfobloxWapiUsername string + InfobloxWapiPassword string `secure:"yes"` + InfobloxWapiVersion string + InfobloxSSLVerify bool + InfobloxView string + DynCustomerName string + DynUsername string + DynPassword string `secure:"yes"` + DynMinTTLSeconds int + OCIConfigFile string + InMemoryZones []string + PDNSServer string + PDNSAPIKey string `secure:"yes"` + PDNSTLSEnabled bool + TLSCA string + TLSClientCert string + TLSClientCertKey string + Policy string + Registry string + TXTOwnerID string + TXTPrefix string + Interval time.Duration + Once bool + DryRun bool + LogFormat string + MetricsAddress string + LogLevel string + TXTCacheInterval time.Duration + ExoscaleEndpoint string + ExoscaleAPIKey string `secure:"yes"` + ExoscaleAPISecret string `secure:"yes"` + CRDSourceAPIVersion string + CRDSourceKind string + ServiceTypeFilter []string + CFAPIEndpoint string + CFUsername string + CFPassword string + RFC2136Host string + RFC2136Port int + RFC2136Zone string + RFC2136Insecure bool + RFC2136TSIGKeyName string + RFC2136TSIGSecret string `secure:"yes"` + RFC2136TSIGSecretAlg string + RFC2136TAXFR bool + NS1Endpoint string + NS1IgnoreSSL bool + TransIPAccountName string + TransIPPrivateKeyFile string } var defaultConfig = &Config{ - Master: "", - KubeConfig: "", - RequestTimeout: time.Second * 30, - IstioIngressGateway: "istio-system/istio-ingressgateway", - Sources: nil, - Namespace: "", - AnnotationFilter: "", - FQDNTemplate: "", - CombineFQDNAndAnnotation: false, - Compatibility: "", - PublishInternal: false, - PublishHostIP: false, - ConnectorSourceServer: "localhost:8080", - Provider: "", - GoogleProject: "", - DomainFilter: []string{}, - AlibabaCloudConfigFile: "/etc/kubernetes/alibaba-cloud.json", - AWSZoneType: "", - AWSAssumeRole: "", - AWSBatchChangeSize: 4000, - AWSBatchChangeInterval: time.Second, - AWSEvaluateTargetHealth: true, - AzureConfigFile: "/etc/kubernetes/azure.json", - AzureResourceGroup: "", - CloudflareProxied: false, - InfobloxGridHost: "", - InfobloxWapiPort: 443, - InfobloxWapiUsername: "admin", - InfobloxWapiPassword: "", - InfobloxWapiVersion: "2.3.1", - InfobloxSSLVerify: true, - OCIConfigFile: "/etc/kubernetes/oci.yaml", - InMemoryZones: []string{}, - PDNSServer: "http://localhost:8081", - PDNSAPIKey: "", - PDNSTLSEnabled: false, - TLSCA: "", - TLSClientCert: "", - TLSClientCertKey: "", - Policy: "sync", - Registry: "txt", - TXTOwnerID: "default", - TXTPrefix: "", - TXTCacheInterval: 0, - Interval: time.Minute, - Once: false, - DryRun: false, - LogFormat: "text", - MetricsAddress: ":7979", - LogLevel: logrus.InfoLevel.String(), - ExoscaleEndpoint: "https://api.exoscale.ch/dns", - ExoscaleAPIKey: "", - ExoscaleAPISecret: "", - CRDSourceAPIVersion: "externaldns.k8s.io/v1alpha1", - CRDSourceKind: "DNSEndpoint", - ServiceTypeFilter: []string{}, - RFC2136Host: "", - RFC2136Port: 0, - RFC2136Zone: "", - RFC2136Insecure: false, - RFC2136TSIGKeyName: "", - RFC2136TSIGSecret: "", - RFC2136TSIGSecretAlg: "", - RFC2136TAXFR: true, + Master: "", + KubeConfig: "", + RequestTimeout: time.Second * 30, + IstioIngressGatewayServices: []string{"istio-system/istio-ingressgateway"}, + Sources: nil, + Namespace: "", + AnnotationFilter: "", + FQDNTemplate: "", + CombineFQDNAndAnnotation: false, + IgnoreHostnameAnnotation: false, + Compatibility: "", + PublishInternal: false, + PublishHostIP: false, + ConnectorSourceServer: "localhost:8080", + Provider: "", + GoogleProject: "", + DomainFilter: []string{}, + AlibabaCloudConfigFile: "/etc/kubernetes/alibaba-cloud.json", + AWSZoneType: "", + AWSZoneTagFilter: []string{}, + AWSAssumeRole: "", + AWSBatchChangeSize: 1000, + AWSBatchChangeInterval: time.Second, + AWSEvaluateTargetHealth: true, + AWSAPIRetries: 3, + AzureConfigFile: "/etc/kubernetes/azure.json", + AzureResourceGroup: "", + CloudflareProxied: false, + CloudflareZonesPerPage: 50, + RcodezeroTXTEncrypt: false, + InfobloxGridHost: "", + InfobloxWapiPort: 443, + InfobloxWapiUsername: "admin", + InfobloxWapiPassword: "", + InfobloxWapiVersion: "2.3.1", + InfobloxSSLVerify: true, + InfobloxView: "", + OCIConfigFile: "/etc/kubernetes/oci.yaml", + InMemoryZones: []string{}, + PDNSServer: "http://localhost:8081", + PDNSAPIKey: "", + PDNSTLSEnabled: false, + TLSCA: "", + TLSClientCert: "", + TLSClientCertKey: "", + Policy: "sync", + Registry: "txt", + TXTOwnerID: "default", + TXTPrefix: "", + TXTCacheInterval: 0, + Interval: time.Minute, + Once: false, + DryRun: false, + LogFormat: "text", + MetricsAddress: ":7979", + LogLevel: logrus.InfoLevel.String(), + ExoscaleEndpoint: "https://api.exoscale.ch/dns", + ExoscaleAPIKey: "", + ExoscaleAPISecret: "", + CRDSourceAPIVersion: "externaldns.k8s.io/v1alpha1", + CRDSourceKind: "DNSEndpoint", + ServiceTypeFilter: []string{}, + CFAPIEndpoint: "", + CFUsername: "", + CFPassword: "", + RFC2136Host: "", + RFC2136Port: 0, + RFC2136Zone: "", + RFC2136Insecure: false, + RFC2136TSIGKeyName: "", + RFC2136TSIGSecret: "", + RFC2136TSIGSecretAlg: "", + RFC2136TAXFR: true, + NS1Endpoint: "", + NS1IgnoreSSL: false, + TransIPAccountName: "", + TransIPPrivateKeyFile: "", } // NewConfig returns new Config object @@ -183,14 +210,19 @@ func NewConfig() *Config { func (cfg *Config) String() string { // prevent logging of sensitive information temp := *cfg - if temp.DynPassword != "" { - temp.DynPassword = passwordMask - } - if temp.InfobloxWapiPassword != "" { - temp.InfobloxWapiPassword = passwordMask - } - if temp.PDNSAPIKey != "" { - temp.PDNSAPIKey = "" + + t := reflect.TypeOf(temp) + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + if val, ok := f.Tag.Lookup("secure"); ok && val == "yes" { + if f.Type.Kind() != reflect.String { + continue + } + v := reflect.ValueOf(&temp).Elem().Field(i) + if v.String() != "" { + v.SetString(passwordMask) + } + } } return fmt.Sprintf("%+v", temp) @@ -217,14 +249,20 @@ func (cfg *Config) ParseFlags(args []string) error { app.Flag("request-timeout", "Request timeout when calling Kubernetes APIs. 0s means no timeout").Default(defaultConfig.RequestTimeout.String()).DurationVar(&cfg.RequestTimeout) // Flags related to Istio - app.Flag("istio-ingress-gateway", "The fully-qualified name of the Istio ingress gateway service (default: istio-system/istio-ingressgateway)").Default(defaultConfig.IstioIngressGateway).StringVar(&cfg.IstioIngressGateway) + app.Flag("istio-ingress-gateway", "The fully-qualified name of the Istio ingress gateway service. Flag can be specified multiple times (default: istio-system/istio-ingressgateway)").Default("istio-system/istio-ingressgateway").StringsVar(&cfg.IstioIngressGatewayServices) + + // Flags related to cloud foundry + app.Flag("cf-api-endpoint", "The fully-qualified domain name of the cloud foundry instance you are targeting").Default(defaultConfig.CFAPIEndpoint).StringVar(&cfg.CFAPIEndpoint) + app.Flag("cf-username", "The username to log into the cloud foundry API").Default(defaultConfig.CFUsername).StringVar(&cfg.CFUsername) + app.Flag("cf-password", "The password to log into the cloud foundry API").Default(defaultConfig.CFPassword).StringVar(&cfg.CFPassword) // Flags related to processing sources - app.Flag("source", "The resource types that are queried for endpoints; specify multiple times for multiple sources (required, options: service, ingress, fake, connector, istio-gateway, crd").Required().PlaceHolder("source").EnumsVar(&cfg.Sources, "service", "ingress", "istio-gateway", "fake", "connector", "crd") + app.Flag("source", "The resource types that are queried for endpoints; specify multiple times for multiple sources (required, options: service, ingress, fake, connector, istio-gateway, cloudfoundry, crd").Required().PlaceHolder("source").EnumsVar(&cfg.Sources, "service", "ingress", "istio-gateway", "cloudfoundry", "fake", "connector", "crd") app.Flag("namespace", "Limit sources of endpoints to a specific namespace (default: all namespaces)").Default(defaultConfig.Namespace).StringVar(&cfg.Namespace) app.Flag("annotation-filter", "Filter sources managed by external-dns via annotation using label selector semantics (default: all sources)").Default(defaultConfig.AnnotationFilter).StringVar(&cfg.AnnotationFilter) app.Flag("fqdn-template", "A templated string that's used to generate DNS names from sources that don't define a hostname themselves, or to add a hostname suffix when paired with the fake source (optional). Accepts comma separated list for multiple global FQDN.").Default(defaultConfig.FQDNTemplate).StringVar(&cfg.FQDNTemplate) app.Flag("combine-fqdn-annotation", "Combine FQDN template and Annotations instead of overwriting").BoolVar(&cfg.CombineFQDNAndAnnotation) + app.Flag("ignore-hostname-annotation", "Ignore hostname annotation when generating DNS names, valid only when using fqdn-template is set (optional, default: false)").BoolVar(&cfg.IgnoreHostnameAnnotation) app.Flag("compatibility", "Process annotation semantics from legacy implementations (optional, options: mate, molecule)").Default(defaultConfig.Compatibility).EnumVar(&cfg.Compatibility, "", "mate", "molecule") app.Flag("publish-internal-services", "Allow external-dns to publish DNS records for ClusterIP services (optional)").BoolVar(&cfg.PublishInternal) app.Flag("publish-host-ip", "Allow external-dns to publish host-ip for headless services (optional)").BoolVar(&cfg.PublishHostIP) @@ -234,36 +272,43 @@ func (cfg *Config) ParseFlags(args []string) error { app.Flag("service-type-filter", "The service types to take care about (default: all, expected: ClusterIP, NodePort, LoadBalancer or ExternalName)").StringsVar(&cfg.ServiceTypeFilter) // Flags related to providers - app.Flag("provider", "The DNS provider where the DNS records will be created (required, options: aws, aws-sd, google, azure, cloudflare, digitalocean, dnsimple, infoblox, dyn, designate, coredns, skydns, inmemory, pdns, oci, exoscale, linode, rfc2136)").Required().PlaceHolder("provider").EnumVar(&cfg.Provider, "aws", "aws-sd", "google", "azure", "alibabacloud", "cloudflare", "digitalocean", "dnsimple", "infoblox", "dyn", "designate", "coredns", "skydns", "inmemory", "pdns", "oci", "exoscale", "linode", "rfc2136") + app.Flag("provider", "The DNS provider where the DNS records will be created (required, options: aws, aws-sd, google, azure, cloudflare, rcodezero, digitalocean, dnsimple, infoblox, dyn, designate, coredns, skydns, inmemory, pdns, oci, exoscale, linode, rfc2136, ns1, transip)").Required().PlaceHolder("provider").EnumVar(&cfg.Provider, "aws", "aws-sd", "google", "azure", "alibabacloud", "cloudflare", "rcodezero", "digitalocean", "dnsimple", "infoblox", "dyn", "designate", "coredns", "skydns", "inmemory", "pdns", "oci", "exoscale", "linode", "rfc2136", "ns1", "transip") 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("zone-id-filter", "Filter target zones by hosted zone id; specify multiple times for multiple zones (optional)").Default("").StringsVar(&cfg.ZoneIDFilter) app.Flag("google-project", "When using the Google provider, current project is auto-detected, when running on GCP. Specify other project with this. Must be specified when running outside GCP.").Default(defaultConfig.GoogleProject).StringVar(&cfg.GoogleProject) app.Flag("alibaba-cloud-config-file", "When using the Alibaba Cloud provider, specify the Alibaba Cloud configuration file (required when --provider=alibabacloud").Default(defaultConfig.AlibabaCloudConfigFile).StringVar(&cfg.AlibabaCloudConfigFile) app.Flag("alibaba-cloud-zone-type", "When using the Alibaba Cloud provider, filter for zones of this type (optional, options: public, private)").Default(defaultConfig.AlibabaCloudZoneType).EnumVar(&cfg.AlibabaCloudZoneType, "", "public", "private") app.Flag("aws-zone-type", "When using the AWS provider, filter for zones of this type (optional, options: public, private)").Default(defaultConfig.AWSZoneType).EnumVar(&cfg.AWSZoneType, "", "public", "private") + app.Flag("aws-zone-tags", "When using the AWS provider, filter for zones with these tags").Default("").StringsVar(&cfg.AWSZoneTagFilter) app.Flag("aws-assume-role", "When using the AWS provider, assume this IAM role. Useful for hosted zones in another AWS account. Specify the full ARN, e.g. `arn:aws:iam::123455567:role/external-dns` (optional)").Default(defaultConfig.AWSAssumeRole).StringVar(&cfg.AWSAssumeRole) app.Flag("aws-batch-change-size", "When using the AWS provider, set the maximum number of changes that will be applied in each batch.").Default(strconv.Itoa(defaultConfig.AWSBatchChangeSize)).IntVar(&cfg.AWSBatchChangeSize) app.Flag("aws-batch-change-interval", "When using the AWS provider, set the interval between batch changes.").Default(defaultConfig.AWSBatchChangeInterval.String()).DurationVar(&cfg.AWSBatchChangeInterval) app.Flag("aws-evaluate-target-health", "When using the AWS provider, set whether to evaluate the health of a DNS target (default: enabled, disable with --no-aws-evaluate-target-health)").Default(strconv.FormatBool(defaultConfig.AWSEvaluateTargetHealth)).BoolVar(&cfg.AWSEvaluateTargetHealth) + app.Flag("aws-api-retries", "When using the AWS provider, set the maximum number of retries for API calls before giving up.").Default(strconv.Itoa(defaultConfig.AWSAPIRetries)).IntVar(&cfg.AWSAPIRetries) app.Flag("azure-config-file", "When using the Azure provider, specify the Azure configuration file (required when --provider=azure").Default(defaultConfig.AzureConfigFile).StringVar(&cfg.AzureConfigFile) app.Flag("azure-resource-group", "When using the Azure provider, override the Azure resource group to use (optional)").Default(defaultConfig.AzureResourceGroup).StringVar(&cfg.AzureResourceGroup) 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("infoblox-grid-host", "When using the Infoblox provider, specify the Grid Manager host (required when --provider=infoblox)").Default(defaultConfig.InfobloxGridHost).StringVar(&cfg.InfobloxGridHost) app.Flag("infoblox-wapi-port", "When using the Infoblox provider, specify the WAPI port (default: 443)").Default(strconv.Itoa(defaultConfig.InfobloxWapiPort)).IntVar(&cfg.InfobloxWapiPort) app.Flag("infoblox-wapi-username", "When using the Infoblox provider, specify the WAPI username (default: admin)").Default(defaultConfig.InfobloxWapiUsername).StringVar(&cfg.InfobloxWapiUsername) app.Flag("infoblox-wapi-password", "When using the Infoblox provider, specify the WAPI password (required when --provider=infoblox)").Default(defaultConfig.InfobloxWapiPassword).StringVar(&cfg.InfobloxWapiPassword) app.Flag("infoblox-wapi-version", "When using the Infoblox provider, specify the WAPI version (default: 2.3.1)").Default(defaultConfig.InfobloxWapiVersion).StringVar(&cfg.InfobloxWapiVersion) app.Flag("infoblox-ssl-verify", "When using the Infoblox provider, specify whether to verify the SSL certificate (default: true, disable with --no-infoblox-ssl-verify)").Default(strconv.FormatBool(defaultConfig.InfobloxSSLVerify)).BoolVar(&cfg.InfobloxSSLVerify) + app.Flag("infoblox-view", "DNS view (default: \"\")").Default(defaultConfig.InfobloxView).StringVar(&cfg.InfobloxView) app.Flag("dyn-customer-name", "When using the Dyn provider, specify the Customer Name").Default("").StringVar(&cfg.DynCustomerName) app.Flag("dyn-username", "When using the Dyn provider, specify the Username").Default("").StringVar(&cfg.DynUsername) app.Flag("dyn-password", "When using the Dyn provider, specify the pasword").Default("").StringVar(&cfg.DynPassword) app.Flag("dyn-min-ttl", "Minimal TTL (in seconds) for records. This value will be used if the provided TTL for a service/ingress is lower than this.").IntVar(&cfg.DynMinTTLSeconds) app.Flag("oci-config-file", "When using the OCI provider, specify the OCI configuration file (required when --provider=oci").Default(defaultConfig.OCIConfigFile).StringVar(&cfg.OCIConfigFile) + app.Flag("rcodezero-txt-encrypt", "When using the Rcodezero provider with txt registry option, set if TXT rrs are encrypted (default: false)").Default(strconv.FormatBool(defaultConfig.RcodezeroTXTEncrypt)).BoolVar(&cfg.RcodezeroTXTEncrypt) app.Flag("inmemory-zone", "Provide a list of pre-configured zones for the inmemory provider; specify multiple times for multiple zones (optional)").Default("").StringsVar(&cfg.InMemoryZones) app.Flag("pdns-server", "When using the PowerDNS/PDNS provider, specify the URL to the pdns server (required when --provider=pdns)").Default(defaultConfig.PDNSServer).StringVar(&cfg.PDNSServer) app.Flag("pdns-api-key", "When using the PowerDNS/PDNS provider, specify the API key to use to authorize requests (required when --provider=pdns)").Default(defaultConfig.PDNSAPIKey).StringVar(&cfg.PDNSAPIKey) app.Flag("pdns-tls-enabled", "When using the PowerDNS/PDNS provider, specify whether to use TLS (default: false, requires --tls-ca, optionally specify --tls-client-cert and --tls-client-cert-key)").Default(strconv.FormatBool(defaultConfig.PDNSTLSEnabled)).BoolVar(&cfg.PDNSTLSEnabled) + app.Flag("ns1-endpoint", "When using the NS1 provider, specify the URL of the API endpoint to target (default: https://api.nsone.net/v1/)").Default(defaultConfig.NS1Endpoint).StringVar(&cfg.NS1Endpoint) + app.Flag("ns1-ignoressl", "When using the NS1 provider, specify whether to verify the SSL certificate (default: false)").Default(strconv.FormatBool(defaultConfig.NS1IgnoreSSL)).BoolVar(&cfg.NS1IgnoreSSL) // Flags related to TLS communication app.Flag("tls-ca", "When using TLS communication, the path to the certificate authority to verify server communications (optionally specify --tls-client-cert for two-way TLS)").Default(defaultConfig.TLSCA).StringVar(&cfg.TLSCA) @@ -284,6 +329,10 @@ func (cfg *Config) ParseFlags(args []string) error { app.Flag("rfc2136-tsig-secret-alg", "When using the RFC2136 provider, specify the TSIG (base64) value to attached to DNS messages (required when --rfc2136-insecure=false)").Default(defaultConfig.RFC2136TSIGSecretAlg).StringVar(&cfg.RFC2136TSIGSecretAlg) app.Flag("rfc2136-tsig-axfr", "When using the RFC2136 provider, specify the TSIG (base64) value to attached to DNS messages (required when --rfc2136-insecure=false)").BoolVar(&cfg.RFC2136TAXFR) + // Flags related to TransIP provider + app.Flag("transip-account", "When using the TransIP provider, specify the account name (required when --provider=transip)").Default(defaultConfig.TransIPAccountName).StringVar(&cfg.TransIPAccountName) + app.Flag("transip-keyfile", "When using the TransIP provider, specify the path to the private key file (required when --provider=transip)").Default(defaultConfig.TransIPPrivateKeyFile).StringVar(&cfg.TransIPPrivateKeyFile) + // Flags related to policies app.Flag("policy", "Modify how DNS records are synchronized between sources and providers (default: sync, options: sync, upsert-only)").Default(defaultConfig.Policy).EnumVar(&cfg.Policy, "sync", "upsert-only") diff --git a/pkg/apis/externaldns/types_test.go b/pkg/apis/externaldns/types_test.go index 87740640f..51f4a0574 100644 --- a/pkg/apis/externaldns/types_test.go +++ b/pkg/apis/externaldns/types_test.go @@ -29,109 +29,183 @@ import ( var ( minimalConfig = &Config{ - Master: "", - KubeConfig: "", - RequestTimeout: time.Second * 30, - IstioIngressGateway: "istio-system/istio-ingressgateway", - Sources: []string{"service"}, - Namespace: "", - FQDNTemplate: "", - Compatibility: "", - Provider: "google", - GoogleProject: "", - DomainFilter: []string{""}, - ZoneIDFilter: []string{""}, - AlibabaCloudConfigFile: "/etc/kubernetes/alibaba-cloud.json", - AWSZoneType: "", - AWSAssumeRole: "", - AWSBatchChangeSize: 4000, - AWSBatchChangeInterval: time.Second, - AWSEvaluateTargetHealth: true, - AzureConfigFile: "/etc/kubernetes/azure.json", - AzureResourceGroup: "", - CloudflareProxied: false, - InfobloxGridHost: "", - InfobloxWapiPort: 443, - InfobloxWapiUsername: "admin", - InfobloxWapiPassword: "", - InfobloxWapiVersion: "2.3.1", - InfobloxSSLVerify: true, - OCIConfigFile: "/etc/kubernetes/oci.yaml", - InMemoryZones: []string{""}, - PDNSServer: "http://localhost:8081", - PDNSAPIKey: "", - Policy: "sync", - Registry: "txt", - TXTOwnerID: "default", - TXTPrefix: "", - TXTCacheInterval: 0, - Interval: time.Minute, - Once: false, - DryRun: false, - LogFormat: "text", - MetricsAddress: ":7979", - LogLevel: logrus.InfoLevel.String(), - ConnectorSourceServer: "localhost:8080", - ExoscaleEndpoint: "https://api.exoscale.ch/dns", - ExoscaleAPIKey: "", - ExoscaleAPISecret: "", - CRDSourceAPIVersion: "externaldns.k8s.io/v1alpha1", - CRDSourceKind: "DNSEndpoint", + Master: "", + KubeConfig: "", + RequestTimeout: time.Second * 30, + IstioIngressGatewayServices: []string{"istio-system/istio-ingressgateway"}, + Sources: []string{"service"}, + Namespace: "", + FQDNTemplate: "", + Compatibility: "", + Provider: "google", + GoogleProject: "", + DomainFilter: []string{""}, + ZoneIDFilter: []string{""}, + AlibabaCloudConfigFile: "/etc/kubernetes/alibaba-cloud.json", + AWSZoneType: "", + AWSZoneTagFilter: []string{""}, + AWSAssumeRole: "", + AWSBatchChangeSize: 1000, + AWSBatchChangeInterval: time.Second, + AWSEvaluateTargetHealth: true, + AWSAPIRetries: 3, + AzureConfigFile: "/etc/kubernetes/azure.json", + AzureResourceGroup: "", + CloudflareProxied: false, + CloudflareZonesPerPage: 50, + InfobloxGridHost: "", + InfobloxWapiPort: 443, + InfobloxWapiUsername: "admin", + InfobloxWapiPassword: "", + InfobloxWapiVersion: "2.3.1", + InfobloxView: "", + InfobloxSSLVerify: true, + OCIConfigFile: "/etc/kubernetes/oci.yaml", + InMemoryZones: []string{""}, + PDNSServer: "http://localhost:8081", + PDNSAPIKey: "", + Policy: "sync", + Registry: "txt", + TXTOwnerID: "default", + TXTPrefix: "", + TXTCacheInterval: 0, + Interval: time.Minute, + Once: false, + DryRun: false, + LogFormat: "text", + MetricsAddress: ":7979", + LogLevel: logrus.InfoLevel.String(), + ConnectorSourceServer: "localhost:8080", + ExoscaleEndpoint: "https://api.exoscale.ch/dns", + ExoscaleAPIKey: "", + ExoscaleAPISecret: "", + CRDSourceAPIVersion: "externaldns.k8s.io/v1alpha1", + CRDSourceKind: "DNSEndpoint", + RcodezeroTXTEncrypt: false, + TransIPAccountName: "", + TransIPPrivateKeyFile: "", } overriddenConfig = &Config{ - Master: "http://127.0.0.1:8080", - KubeConfig: "/some/path", - RequestTimeout: time.Second * 77, - IstioIngressGateway: "istio-other/istio-otheringressgateway", - Sources: []string{"service", "ingress", "connector"}, - Namespace: "namespace", - FQDNTemplate: "{{.Name}}.service.example.com", - Compatibility: "mate", - Provider: "google", - GoogleProject: "project", - DomainFilter: []string{"example.org", "company.com"}, - ZoneIDFilter: []string{"/hostedzone/ZTST1", "/hostedzone/ZTST2"}, - AlibabaCloudConfigFile: "/etc/kubernetes/alibaba-cloud.json", - AWSZoneType: "private", - AWSAssumeRole: "some-other-role", - AWSBatchChangeSize: 100, - AWSBatchChangeInterval: time.Second * 2, - AWSEvaluateTargetHealth: false, - AzureConfigFile: "azure.json", - AzureResourceGroup: "arg", - CloudflareProxied: true, - InfobloxGridHost: "127.0.0.1", - InfobloxWapiPort: 8443, - InfobloxWapiUsername: "infoblox", - InfobloxWapiPassword: "infoblox", - InfobloxWapiVersion: "2.6.1", - InfobloxSSLVerify: false, - OCIConfigFile: "oci.yaml", - InMemoryZones: []string{"example.org", "company.com"}, - PDNSServer: "http://ns.example.com:8081", - PDNSAPIKey: "some-secret-key", - PDNSTLSEnabled: true, - TLSCA: "/path/to/ca.crt", - TLSClientCert: "/path/to/cert.pem", - TLSClientCertKey: "/path/to/key.pem", - Policy: "upsert-only", - Registry: "noop", - TXTOwnerID: "owner-1", - TXTPrefix: "associated-txt-record", - TXTCacheInterval: 12 * time.Hour, - Interval: 10 * time.Minute, - Once: true, - DryRun: true, - LogFormat: "json", - MetricsAddress: "127.0.0.1:9099", - LogLevel: logrus.DebugLevel.String(), - ConnectorSourceServer: "localhost:8081", - ExoscaleEndpoint: "https://api.foo.ch/dns", - ExoscaleAPIKey: "1", - ExoscaleAPISecret: "2", - CRDSourceAPIVersion: "test.k8s.io/v1alpha1", - CRDSourceKind: "Endpoint", + Master: "http://127.0.0.1:8080", + KubeConfig: "/some/path", + RequestTimeout: time.Second * 77, + IstioIngressGatewayServices: []string{"istio-other/istio-otheringressgateway"}, + Sources: []string{"service", "ingress", "connector"}, + Namespace: "namespace", + IgnoreHostnameAnnotation: true, + FQDNTemplate: "{{.Name}}.service.example.com", + Compatibility: "mate", + Provider: "google", + GoogleProject: "project", + DomainFilter: []string{"example.org", "company.com"}, + ZoneIDFilter: []string{"/hostedzone/ZTST1", "/hostedzone/ZTST2"}, + AlibabaCloudConfigFile: "/etc/kubernetes/alibaba-cloud.json", + AWSZoneType: "private", + AWSZoneTagFilter: []string{"tag=foo"}, + AWSAssumeRole: "some-other-role", + AWSBatchChangeSize: 100, + AWSBatchChangeInterval: time.Second * 2, + AWSEvaluateTargetHealth: false, + AWSAPIRetries: 13, + AzureConfigFile: "azure.json", + AzureResourceGroup: "arg", + CloudflareProxied: true, + CloudflareZonesPerPage: 20, + InfobloxGridHost: "127.0.0.1", + InfobloxWapiPort: 8443, + InfobloxWapiUsername: "infoblox", + InfobloxWapiPassword: "infoblox", + InfobloxWapiVersion: "2.6.1", + InfobloxView: "internal", + InfobloxSSLVerify: false, + OCIConfigFile: "oci.yaml", + InMemoryZones: []string{"example.org", "company.com"}, + PDNSServer: "http://ns.example.com:8081", + PDNSAPIKey: "some-secret-key", + PDNSTLSEnabled: true, + TLSCA: "/path/to/ca.crt", + TLSClientCert: "/path/to/cert.pem", + TLSClientCertKey: "/path/to/key.pem", + Policy: "upsert-only", + Registry: "noop", + TXTOwnerID: "owner-1", + TXTPrefix: "associated-txt-record", + TXTCacheInterval: 12 * time.Hour, + Interval: 10 * time.Minute, + Once: true, + DryRun: true, + LogFormat: "json", + MetricsAddress: "127.0.0.1:9099", + LogLevel: logrus.DebugLevel.String(), + ConnectorSourceServer: "localhost:8081", + ExoscaleEndpoint: "https://api.foo.ch/dns", + ExoscaleAPIKey: "1", + ExoscaleAPISecret: "2", + CRDSourceAPIVersion: "test.k8s.io/v1alpha1", + CRDSourceKind: "Endpoint", + RcodezeroTXTEncrypt: true, + NS1Endpoint: "https://api.example.com/v1", + NS1IgnoreSSL: true, + TransIPAccountName: "transip", + TransIPPrivateKeyFile: "/path/to/transip.key", + } + + // minimal config with istio gateway source and multiple ingressgateway load balancer services + multipleIstioIngressGatewaysConfig = &Config{ + Master: "", + KubeConfig: "", + RequestTimeout: time.Second * 30, + IstioIngressGatewayServices: []string{"istio-system/istio-ingressgateway", "istio-other/istio-otheringressgateway"}, + Sources: []string{"istio-gateway"}, + Namespace: "", + FQDNTemplate: "", + Compatibility: "", + Provider: "google", + GoogleProject: "", + DomainFilter: []string{""}, + ZoneIDFilter: []string{""}, + AlibabaCloudConfigFile: "/etc/kubernetes/alibaba-cloud.json", + AWSZoneType: "", + AWSZoneTagFilter: []string{""}, + AWSAssumeRole: "", + AWSBatchChangeSize: 1000, + AWSBatchChangeInterval: time.Second, + AWSEvaluateTargetHealth: true, + AWSAPIRetries: 3, + AzureConfigFile: "/etc/kubernetes/azure.json", + AzureResourceGroup: "", + CloudflareProxied: false, + CloudflareZonesPerPage: 50, + InfobloxGridHost: "", + InfobloxWapiPort: 443, + InfobloxWapiUsername: "admin", + InfobloxWapiPassword: "", + InfobloxWapiVersion: "2.3.1", + InfobloxView: "", + InfobloxSSLVerify: true, + OCIConfigFile: "/etc/kubernetes/oci.yaml", + InMemoryZones: []string{""}, + PDNSServer: "http://localhost:8081", + PDNSAPIKey: "", + Policy: "sync", + Registry: "txt", + TXTOwnerID: "default", + TXTPrefix: "", + TXTCacheInterval: 0, + Interval: time.Minute, + Once: false, + DryRun: false, + LogFormat: "text", + MetricsAddress: ":7979", + LogLevel: logrus.InfoLevel.String(), + ConnectorSourceServer: "localhost:8080", + ExoscaleEndpoint: "https://api.exoscale.ch/dns", + ExoscaleAPIKey: "", + ExoscaleAPISecret: "", + CRDSourceAPIVersion: "externaldns.k8s.io/v1alpha1", + CRDSourceKind: "DNSEndpoint", + RcodezeroTXTEncrypt: false, } ) @@ -163,17 +237,20 @@ func TestParseFlags(t *testing.T) { "--source=connector", "--namespace=namespace", "--fqdn-template={{.Name}}.service.example.com", + "--ignore-hostname-annotation", "--compatibility=mate", "--provider=google", "--google-project=project", "--azure-config-file=azure.json", "--azure-resource-group=arg", "--cloudflare-proxied", + "--cloudflare-zones-per-page=20", "--infoblox-grid-host=127.0.0.1", "--infoblox-wapi-port=8443", "--infoblox-wapi-username=infoblox", "--infoblox-wapi-password=infoblox", "--infoblox-wapi-version=2.6.1", + "--infoblox-view=internal", "--inmemory-zone=example.org", "--inmemory-zone=company.com", "--pdns-server=http://ns.example.com:8081", @@ -189,9 +266,11 @@ func TestParseFlags(t *testing.T) { "--zone-id-filter=/hostedzone/ZTST1", "--zone-id-filter=/hostedzone/ZTST2", "--aws-zone-type=private", + "--aws-zone-tags=tag=foo", "--aws-assume-role=some-other-role", "--aws-batch-change-size=100", "--aws-batch-change-interval=2s", + "--aws-api-retries=13", "--no-aws-evaluate-target-health", "--policy=upsert-only", "--registry=noop", @@ -210,6 +289,11 @@ func TestParseFlags(t *testing.T) { "--exoscale-apisecret=2", "--crd-source-apiversion=test.k8s.io/v1alpha1", "--crd-source-kind=Endpoint", + "--rcodezero-txt-encrypt", + "--ns1-endpoint=https://api.example.com/v1", + "--ns1-ignoressl", + "--transip-account=transip", + "--transip-keyfile=/path/to/transip.key", }, envVars: map[string]string{}, expected: overriddenConfig, @@ -225,17 +309,20 @@ func TestParseFlags(t *testing.T) { "EXTERNAL_DNS_SOURCE": "service\ningress\nconnector", "EXTERNAL_DNS_NAMESPACE": "namespace", "EXTERNAL_DNS_FQDN_TEMPLATE": "{{.Name}}.service.example.com", + "EXTERNAL_DNS_IGNORE_HOSTNAME_ANNOTATION": "1", "EXTERNAL_DNS_COMPATIBILITY": "mate", "EXTERNAL_DNS_PROVIDER": "google", "EXTERNAL_DNS_GOOGLE_PROJECT": "project", "EXTERNAL_DNS_AZURE_CONFIG_FILE": "azure.json", "EXTERNAL_DNS_AZURE_RESOURCE_GROUP": "arg", "EXTERNAL_DNS_CLOUDFLARE_PROXIED": "1", + "EXTERNAL_DNS_CLOUDFLARE_ZONES_PER_PAGE": "20", "EXTERNAL_DNS_INFOBLOX_GRID_HOST": "127.0.0.1", "EXTERNAL_DNS_INFOBLOX_WAPI_PORT": "8443", "EXTERNAL_DNS_INFOBLOX_WAPI_USERNAME": "infoblox", "EXTERNAL_DNS_INFOBLOX_WAPI_PASSWORD": "infoblox", "EXTERNAL_DNS_INFOBLOX_WAPI_VERSION": "2.6.1", + "EXTERNAL_DNS_INFOBLOX_VIEW": "internal", "EXTERNAL_DNS_INFOBLOX_SSL_VERIFY": "0", "EXTERNAL_DNS_OCI_CONFIG_FILE": "oci.yaml", "EXTERNAL_DNS_INMEMORY_ZONE": "example.org\ncompany.com", @@ -248,10 +335,12 @@ func TestParseFlags(t *testing.T) { "EXTERNAL_DNS_TLS_CLIENT_CERT_KEY": "/path/to/key.pem", "EXTERNAL_DNS_ZONE_ID_FILTER": "/hostedzone/ZTST1\n/hostedzone/ZTST2", "EXTERNAL_DNS_AWS_ZONE_TYPE": "private", + "EXTERNAL_DNS_AWS_ZONE_TAGS": "tag=foo", "EXTERNAL_DNS_AWS_ASSUME_ROLE": "some-other-role", "EXTERNAL_DNS_AWS_BATCH_CHANGE_SIZE": "100", "EXTERNAL_DNS_AWS_BATCH_CHANGE_INTERVAL": "2s", "EXTERNAL_DNS_AWS_EVALUATE_TARGET_HEALTH": "0", + "EXTERNAL_DNS_AWS_API_RETRIES": "13", "EXTERNAL_DNS_POLICY": "upsert-only", "EXTERNAL_DNS_REGISTRY": "noop", "EXTERNAL_DNS_TXT_OWNER_ID": "owner-1", @@ -269,9 +358,35 @@ func TestParseFlags(t *testing.T) { "EXTERNAL_DNS_EXOSCALE_APISECRET": "2", "EXTERNAL_DNS_CRD_SOURCE_APIVERSION": "test.k8s.io/v1alpha1", "EXTERNAL_DNS_CRD_SOURCE_KIND": "Endpoint", + "EXTERNAL_DNS_RCODEZERO_TXT_ENCRYPT": "1", + "EXTERNAL_DNS_NS1_ENDPOINT": "https://api.example.com/v1", + "EXTERNAL_DNS_NS1_IGNORESSL": "1", + "EXTERNAL_DNS_TRANSIP_ACCOUNT": "transip", + "EXTERNAL_DNS_TRANSIP_KEYFILE": "/path/to/transip.key", }, expected: overriddenConfig, }, + { + title: "istio config with 2 ingressgateways", + args: []string{ + "--provider=google", + "--source=istio-gateway", + "--istio-ingress-gateway=istio-system/istio-ingressgateway", + "--istio-ingress-gateway=istio-other/istio-otheringressgateway", + }, + envVars: map[string]string{}, + expected: multipleIstioIngressGatewaysConfig, + }, + { + title: "override everything via environment variables with multiple istio ingress gateway load balancer services", + args: []string{}, + envVars: map[string]string{ + "EXTERNAL_DNS_PROVIDER": "google", + "EXTERNAL_DNS_SOURCE": "istio-gateway", + "EXTERNAL_DNS_ISTIO_INGRESS_GATEWAY": "istio-system/istio-ingressgateway\nistio-other/istio-otheringressgateway", + }, + expected: multipleIstioIngressGatewaysConfig, + }, } { t.Run(ti.title, func(t *testing.T) { originalEnv := setEnv(t, ti.envVars) @@ -308,6 +423,7 @@ func TestPasswordsNotLogged(t *testing.T) { DynPassword: "dyn-pass", InfobloxWapiPassword: "infoblox-pass", PDNSAPIKey: "pdns-api-key", + RFC2136TSIGSecret: "tsig-secret", } s := cfg.String() @@ -315,4 +431,5 @@ func TestPasswordsNotLogged(t *testing.T) { assert.False(t, strings.Contains(s, "dyn-pass")) assert.False(t, strings.Contains(s, "infoblox-pass")) assert.False(t, strings.Contains(s, "pdns-api-key")) + assert.False(t, strings.Contains(s, "tsig-secret")) } diff --git a/pkg/apis/externaldns/validation/validation.go b/pkg/apis/externaldns/validation/validation.go index cdbe6ef4e..174550cb4 100644 --- a/pkg/apis/externaldns/validation/validation.go +++ b/pkg/apis/externaldns/validation/validation.go @@ -65,5 +65,9 @@ func ValidateConfig(cfg *externaldns.Config) error { return errors.New("TTL specified for Dyn is negative") } } + + if cfg.IgnoreHostnameAnnotation && cfg.FQDNTemplate == "" { + return errors.New("FQDN Template must be set if ignoring annotations") + } return nil } diff --git a/pkg/apis/externaldns/validation/validation_test.go b/pkg/apis/externaldns/validation/validation_test.go index c50073ae4..da3d9e300 100644 --- a/pkg/apis/externaldns/validation/validation_test.go +++ b/pkg/apis/externaldns/validation/validation_test.go @@ -116,3 +116,11 @@ func TestValidateGoodDynConfig(t *testing.T) { assert.Nil(t, err, "Configuration should be valid, got this error instead", err) } } + +func TestValidateBadIgnoreHostnameAnnotationsConfig(t *testing.T) { + cfg := externaldns.NewConfig() + cfg.IgnoreHostnameAnnotation = true + cfg.FQDNTemplate = "" + + assert.Error(t, ValidateConfig(cfg)) +} diff --git a/plan/plan.go b/plan/plan.go index 21bf5b677..efe949be6 100644 --- a/plan/plan.go +++ b/plan/plan.go @@ -17,6 +17,7 @@ limitations under the License. package plan import ( + "fmt" "strings" "github.com/kubernetes-incubator/external-dns/endpoint" @@ -78,8 +79,12 @@ type planTableRow struct { candidates []*endpoint.Endpoint } +func (t planTableRow) String() string { + return fmt.Sprintf("planTableRow{current=%v, candidates=%v}", t.current, t.candidates) +} + func (t planTable) addCurrent(e *endpoint.Endpoint) { - dnsName := sanitizeDNSName(e.DNSName) + dnsName := normalizeDNSName(e.DNSName) if _, ok := t.rows[dnsName]; !ok { t.rows[dnsName] = &planTableRow{} } @@ -87,7 +92,7 @@ func (t planTable) addCurrent(e *endpoint.Endpoint) { } func (t planTable) addCandidate(e *endpoint.Endpoint) { - dnsName := sanitizeDNSName(e.DNSName) + dnsName := normalizeDNSName(e.DNSName) if _, ok := t.rows[dnsName]; !ok { t.rows[dnsName] = &planTableRow{} } @@ -100,7 +105,7 @@ func (t planTable) getUpdates() (updateNew []*endpoint.Endpoint, updateOld []*en if row.current != nil && len(row.candidates) > 0 { //dns name is taken update := t.resolver.ResolveUpdate(row.current, row.candidates) // compare "update" to "current" to figure out if actual update is required - if shouldUpdateTTL(update, row.current) || targetChanged(update, row.current) { + if shouldUpdateTTL(update, row.current) || targetChanged(update, row.current) || shouldUpdateProviderSpecific(update, row.current) { inheritOwner(row.current, update) updateNew = append(updateNew, update) updateOld = append(updateOld, row.current) @@ -180,6 +185,27 @@ func shouldUpdateTTL(desired, current *endpoint.Endpoint) bool { return desired.RecordTTL != current.RecordTTL } +func shouldUpdateProviderSpecific(desired, current *endpoint.Endpoint) bool { + if current.ProviderSpecific == nil && len(desired.ProviderSpecific) == 0 { + return false + } + for _, c := range current.ProviderSpecific { + // don't consider target health when detecting changes + // see: https://github.com/kubernetes-incubator/external-dns/issues/869#issuecomment-458576954 + if c.Name == "aws/evaluate-target-health" { + continue + } + + for _, d := range desired.ProviderSpecific { + if d.Name == c.Name && d.Value != c.Value { + return true + } + } + } + + return false +} + // filterRecordsForPlan removes records that are not relevant to the planner. // Currently this just removes TXT records to prevent them from being // deleted erroneously by the planner (only the TXT registry should do this.) @@ -204,8 +230,12 @@ func filterRecordsForPlan(records []*endpoint.Endpoint) []*endpoint.Endpoint { return filtered } -// sanitizeDNSName checks if the DNS name is correct -// for now it only removes space and lower case -func sanitizeDNSName(dnsName string) string { - return strings.TrimSpace(strings.ToLower(dnsName)) +// normalizeDNSName converts a DNS name to a canonical form, so that we can use string equality +// it: removes space, converts to lower case, ensures there is a trailing dot +func normalizeDNSName(dnsName string) string { + s := strings.TrimSpace(strings.ToLower(dnsName)) + if !strings.HasSuffix(s, ".") { + s += "." + } + return s } diff --git a/plan/plan_test.go b/plan/plan_test.go index e36b343a6..eb742e11a 100644 --- a/plan/plan_test.go +++ b/plan/plan_test.go @@ -27,15 +27,17 @@ import ( type PlanTestSuite struct { suite.Suite - fooV1Cname *endpoint.Endpoint - fooV2Cname *endpoint.Endpoint - fooV2TXT *endpoint.Endpoint - fooV2CnameNoLabel *endpoint.Endpoint - fooV3CnameSameResource *endpoint.Endpoint - fooA5 *endpoint.Endpoint - bar127A *endpoint.Endpoint - bar127AWithTTL *endpoint.Endpoint - bar192A *endpoint.Endpoint + fooV1Cname *endpoint.Endpoint + fooV2Cname *endpoint.Endpoint + fooV2TXT *endpoint.Endpoint + fooV2CnameNoLabel *endpoint.Endpoint + fooV3CnameSameResource *endpoint.Endpoint + fooA5 *endpoint.Endpoint + bar127A *endpoint.Endpoint + bar127AWithTTL *endpoint.Endpoint + bar127AWithProviderSpecificTrue *endpoint.Endpoint + bar127AWithProviderSpecificFalse *endpoint.Endpoint + bar192A *endpoint.Endpoint } func (suite *PlanTestSuite) SetupTest() { @@ -100,6 +102,34 @@ func (suite *PlanTestSuite) SetupTest() { endpoint.ResourceLabelKey: "ingress/default/bar-127", }, } + suite.bar127AWithProviderSpecificTrue = &endpoint.Endpoint{ + DNSName: "bar", + Targets: endpoint.Targets{"127.0.0.1"}, + RecordType: "A", + Labels: map[string]string{ + endpoint.ResourceLabelKey: "ingress/default/bar-127", + }, + ProviderSpecific: endpoint.ProviderSpecific{ + endpoint.ProviderSpecificProperty{ + Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied", + Value: "true", + }, + }, + } + suite.bar127AWithProviderSpecificFalse = &endpoint.Endpoint{ + DNSName: "bar", + Targets: endpoint.Targets{"127.0.0.1"}, + RecordType: "A", + Labels: map[string]string{ + endpoint.ResourceLabelKey: "ingress/default/bar-127", + }, + ProviderSpecific: endpoint.ProviderSpecific{ + endpoint.ProviderSpecificProperty{ + Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied", + Value: "false", + }, + }, + } suite.bar192A = &endpoint.Endpoint{ DNSName: "bar", Targets: endpoint.Targets{"192.168.0.1"}, @@ -108,6 +138,7 @@ func (suite *PlanTestSuite) SetupTest() { endpoint.ResourceLabelKey: "ingress/default/bar-192", }, } + } func (suite *PlanTestSuite) TestSyncFirstRound() { @@ -194,6 +225,27 @@ func (suite *PlanTestSuite) TestSyncSecondRoundWithTTLChange() { validateEntries(suite.T(), changes.Delete, expectedDelete) } +func (suite *PlanTestSuite) TestSyncSecondRoundWithProviderSpecificChange() { + current := []*endpoint.Endpoint{suite.bar127AWithProviderSpecificTrue} + desired := []*endpoint.Endpoint{suite.bar127AWithProviderSpecificFalse} + expectedCreate := []*endpoint.Endpoint{} + expectedUpdateOld := []*endpoint.Endpoint{suite.bar127AWithProviderSpecificTrue} + expectedUpdateNew := []*endpoint.Endpoint{suite.bar127AWithProviderSpecificFalse} + expectedDelete := []*endpoint.Endpoint{} + + p := &Plan{ + Policies: []Policy{&SyncPolicy{}}, + Current: current, + Desired: desired, + } + + changes := p.Calculate().Changes + validateEntries(suite.T(), changes.Create, expectedCreate) + validateEntries(suite.T(), changes.UpdateNew, expectedUpdateNew) + validateEntries(suite.T(), changes.UpdateOld, expectedUpdateOld) + validateEntries(suite.T(), changes.Delete, expectedDelete) +} + func (suite *PlanTestSuite) TestSyncSecondRoundWithOwnerInherited() { current := []*endpoint.Endpoint{suite.fooV1Cname} desired := []*endpoint.Endpoint{suite.fooV2Cname} @@ -354,6 +406,7 @@ func (suite *PlanTestSuite) TestDuplicatedEndpointsForSameResourceReplace() { //TODO: remove once multiple-target per endpoint is supported func (suite *PlanTestSuite) TestDuplicatedEndpointsForSameResourceRetain() { + current := []*endpoint.Endpoint{suite.fooV1Cname, suite.bar192A} desired := []*endpoint.Endpoint{suite.fooV1Cname, suite.fooV3CnameSameResource} expectedCreate := []*endpoint.Endpoint{} @@ -385,54 +438,58 @@ func validateEntries(t *testing.T, entries, expected []*endpoint.Endpoint) { } } -func TestSanitizeDNSName(t *testing.T) { +func TestNormalizeDNSName(t *testing.T) { records := []struct { dnsName string expect string }{ { "3AAAA.FOO.BAR.COM ", - "3aaaa.foo.bar.com", + "3aaaa.foo.bar.com.", }, { - " example.foo.com", - "example.foo.com", + " example.foo.com.", + "example.foo.com.", }, { "example123.foo.com ", - "example123.foo.com", + "example123.foo.com.", }, { "foo", - "foo", + "foo.", }, { "123foo.bar", - "123foo.bar", + "123foo.bar.", }, { "foo.com", - "foo.com", + "foo.com.", + }, + { + "foo.com.", + "foo.com.", }, { "foo123.COM", - "foo123.com", + "foo123.com.", }, { "my-exaMple3.FOO.BAR.COM", - "my-example3.foo.bar.com", + "my-example3.foo.bar.com.", }, { " my-example1214.FOO-1235.BAR-foo.COM ", - "my-example1214.foo-1235.bar-foo.com", + "my-example1214.foo-1235.bar-foo.com.", }, { "my-example-my-example-1214.FOO-1235.BAR-foo.COM", - "my-example-my-example-1214.foo-1235.bar-foo.com", + "my-example-my-example-1214.foo-1235.bar-foo.com.", }, } for _, r := range records { - gotName := sanitizeDNSName(r.dnsName) + gotName := normalizeDNSName(r.dnsName) assert.Equal(t, r.expect, gotName) } } diff --git a/provider/alibaba_cloud.go b/provider/alibaba_cloud.go index 79663577d..6a06949ce 100644 --- a/provider/alibaba_cloud.go +++ b/provider/alibaba_cloud.go @@ -17,24 +17,21 @@ limitations under the License. package provider import ( + "context" "fmt" "io/ioutil" - - log "github.com/sirupsen/logrus" - - "gopkg.in/yaml.v2" - - "github.com/aliyun/alibaba-cloud-sdk-go/services/alidns" - - "github.com/kubernetes-incubator/external-dns/endpoint" - "github.com/kubernetes-incubator/external-dns/plan" - - "github.com/aliyun/alibaba-cloud-sdk-go/sdk/requests" - "github.com/aliyun/alibaba-cloud-sdk-go/services/pvtz" - "github.com/denverdino/aliyungo/metadata" "strings" "sync" "time" + + "github.com/aliyun/alibaba-cloud-sdk-go/sdk/requests" + "github.com/aliyun/alibaba-cloud-sdk-go/services/alidns" + "github.com/aliyun/alibaba-cloud-sdk-go/services/pvtz" + "github.com/denverdino/aliyungo/metadata" + "github.com/kubernetes-incubator/external-dns/endpoint" + "github.com/kubernetes-incubator/external-dns/plan" + log "github.com/sirupsen/logrus" + yaml "gopkg.in/yaml.v2" ) const ( @@ -42,6 +39,7 @@ const ( defaultAlibabaCloudPrivateZoneRecordTTL = 60 defaultAlibabaCloudPageSize = 50 nullHostAlibabaCloud = "@" + pVTZDoamin = "pvtz.aliyuncs.com" ) // AlibabaCloudDNSAPI is a minimal implementation of DNS API that we actually use, used primarily for unit testing. @@ -154,6 +152,10 @@ func NewAlibabaCloudProvider(configFile string, domainFilter DomainFilter, zoneI ) } + if err != nil { + return nil, err + } + provider := &AlibabaCloudProvider{ domainFilter: domainFilter, zoneIDFilter: zoneIDFileter, @@ -290,7 +292,7 @@ func (p *AlibabaCloudProvider) Records() (endpoints []*endpoint.Endpoint, err er // ApplyChanges applies the given changes. // // Returns nil if the operation was successful or an error if the operation failed. -func (p *AlibabaCloudProvider) ApplyChanges(changes *plan.Changes) error { +func (p *AlibabaCloudProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { if changes == nil || len(changes.Create)+len(changes.Delete)+len(changes.UpdateNew) == 0 { // No op return nil @@ -708,6 +710,7 @@ func (p *AlibabaCloudProvider) splitDNSName(endpoint *endpoint.Endpoint) (rr str func (p *AlibabaCloudProvider) matchVPC(zoneID string) bool { request := pvtz.CreateDescribeZoneInfoRequest() request.ZoneId = zoneID + request.Domain = pVTZDoamin response, err := p.getPvtzClient().DescribeZoneInfo(request) if err != nil { log.Errorf("Failed to describe zone info %s in Alibaba Cloud DNS: %v", zoneID, err) @@ -730,7 +733,7 @@ func (p *AlibabaCloudProvider) privateZones() ([]pvtz.Zone, error) { request := pvtz.CreateDescribeZonesRequest() request.PageSize = requests.NewInteger(defaultAlibabaCloudPageSize) request.PageNumber = "1" - + request.Domain = pVTZDoamin for { response, err := p.getPvtzClient().DescribeZones(request) if err != nil { @@ -738,7 +741,7 @@ func (p *AlibabaCloudProvider) privateZones() ([]pvtz.Zone, error) { return nil, err } for _, zone := range response.Zones.Zone { - log.Debugf("Zone: %++v", zone) + log.Infof("PrivateZones zone: %++v", zone) if !p.zoneIDFilter.Match(zone.ZoneId) { continue @@ -784,6 +787,7 @@ func (p *AlibabaCloudProvider) getPrivateZones() (map[string]*alibabaPrivateZone request.ZoneId = zone.ZoneId request.PageSize = requests.NewInteger(defaultAlibabaCloudPageSize) request.PageNumber = "1" + request.Domain = pVTZDoamin var records []pvtz.Record for { @@ -884,6 +888,7 @@ func (p *AlibabaCloudProvider) createPrivateZoneRecord(zones map[string]*alibaba request.ZoneId = zone.ZoneId request.Type = endpoint.RecordType request.Rr = rr + request.Domain = pVTZDoamin ttl := int(endpoint.RecordTTL) if ttl != 0 { @@ -927,6 +932,7 @@ func (p *AlibabaCloudProvider) deletePrivateZoneRecord(recordID int) error { request := pvtz.CreateDeleteZoneRecordRequest() request.RecordId = requests.NewInteger(recordID) + request.Domain = pVTZDoamin response, err := p.getPvtzClient().DeleteZoneRecord(request) if err == nil { @@ -998,6 +1004,7 @@ func (p *AlibabaCloudProvider) updatePrivateZoneRecord(record pvtz.Record, endpo request.Rr = record.Rr request.Type = record.Type request.Value = record.Value + request.Domain = pVTZDoamin ttl := int(endpoint.RecordTTL) if ttl != 0 { request.Ttl = requests.NewInteger(ttl) diff --git a/provider/alibaba_cloud_test.go b/provider/alibaba_cloud_test.go index d9d70a1cc..4e86dc537 100644 --- a/provider/alibaba_cloud_test.go +++ b/provider/alibaba_cloud_test.go @@ -17,10 +17,12 @@ limitations under the License. package provider import ( + "context" + "testing" + "github.com/aliyun/alibaba-cloud-sdk-go/services/alidns" "github.com/aliyun/alibaba-cloud-sdk-go/services/pvtz" "github.com/kubernetes-incubator/external-dns/endpoint" - "testing" "github.com/kubernetes-incubator/external-dns/plan" ) @@ -300,7 +302,7 @@ func TestAlibabaCloudProvider_ApplyChanges(t *testing.T) { }, }, } - p.ApplyChanges(&changes) + p.ApplyChanges(context.Background(), &changes) endpoints, err := p.Records() if err != nil { t.Errorf("Failed to get records: %v", err) @@ -357,7 +359,7 @@ func TestAlibabaCloudProvider_ApplyChanges_PrivateZone(t *testing.T) { }, }, } - p.ApplyChanges(&changes) + p.ApplyChanges(context.Background(), &changes) endpoints, err := p.Records() if err != nil { t.Errorf("Failed to get records: %v", err) diff --git a/provider/aws.go b/provider/aws.go index 7ec5e79aa..baabba378 100644 --- a/provider/aws.go +++ b/provider/aws.go @@ -17,6 +17,7 @@ limitations under the License. package provider import ( + "context" "fmt" "sort" "strings" @@ -43,38 +44,44 @@ var ( // see: https://docs.aws.amazon.com/general/latest/gr/rande.html#elb_region canonicalHostedZones = map[string]string{ // Application Load Balancers and Classic Load Balancers - "us-east-2.elb.amazonaws.com": "Z3AADJGX6KTTL2", - "us-east-1.elb.amazonaws.com": "Z35SXDOTRQ7X7K", - "us-west-1.elb.amazonaws.com": "Z368ELLRRE2KJ0", - "us-west-2.elb.amazonaws.com": "Z1H1FL5HABSF5", - "ca-central-1.elb.amazonaws.com": "ZQSVJUPU6J1EY", - "ap-south-1.elb.amazonaws.com": "ZP97RAFLXTNZK", - "ap-northeast-2.elb.amazonaws.com": "ZWKZPGTI48KDX", - "ap-northeast-3.elb.amazonaws.com": "Z5LXEXXYW11ES", - "ap-southeast-1.elb.amazonaws.com": "Z1LMS91P8CMLE5", - "ap-southeast-2.elb.amazonaws.com": "Z1GM3OXH4ZPM65", - "ap-northeast-1.elb.amazonaws.com": "Z14GRHDCWA56QT", - "eu-central-1.elb.amazonaws.com": "Z215JYRZR1TBD5", - "eu-west-1.elb.amazonaws.com": "Z32O12XQLNTSW2", - "eu-west-2.elb.amazonaws.com": "ZHURV8PSTC4K8", - "eu-west-3.elb.amazonaws.com": "Z3Q77PNBQS71R4", - "sa-east-1.elb.amazonaws.com": "Z2P70J7HTTTPLU", + "us-east-2.elb.amazonaws.com": "Z3AADJGX6KTTL2", + "us-east-1.elb.amazonaws.com": "Z35SXDOTRQ7X7K", + "us-west-1.elb.amazonaws.com": "Z368ELLRRE2KJ0", + "us-west-2.elb.amazonaws.com": "Z1H1FL5HABSF5", + "ca-central-1.elb.amazonaws.com": "ZQSVJUPU6J1EY", + "ap-south-1.elb.amazonaws.com": "ZP97RAFLXTNZK", + "ap-northeast-2.elb.amazonaws.com": "ZWKZPGTI48KDX", + "ap-northeast-3.elb.amazonaws.com": "Z5LXEXXYW11ES", + "ap-southeast-1.elb.amazonaws.com": "Z1LMS91P8CMLE5", + "ap-southeast-2.elb.amazonaws.com": "Z1GM3OXH4ZPM65", + "ap-northeast-1.elb.amazonaws.com": "Z14GRHDCWA56QT", + "eu-central-1.elb.amazonaws.com": "Z215JYRZR1TBD5", + "eu-west-1.elb.amazonaws.com": "Z32O12XQLNTSW2", + "eu-west-2.elb.amazonaws.com": "ZHURV8PSTC4K8", + "eu-west-3.elb.amazonaws.com": "Z3Q77PNBQS71R4", + "eu-north-1.elb.amazonaws.com": "Z23TAZ6LKFMNIO", + "sa-east-1.elb.amazonaws.com": "Z2P70J7HTTTPLU", + "cn-north-1.elb.amazonaws.com.cn": "Z3BX2TMKNYI13Y", + "cn-northwest-1.elb.amazonaws.com.cn": "Z3BX2TMKNYI13Y", // Network Load Balancers - "elb.us-east-2.amazonaws.com": "ZLMOA37VPKANP", - "elb.us-east-1.amazonaws.com": "Z26RNL4JYFTOTI", - "elb.us-west-1.amazonaws.com": "Z24FKFUX50B4VW", - "elb.us-west-2.amazonaws.com": "Z18D5FSROUN65G", - "elb.ca-central-1.amazonaws.com": "Z2EPGBW3API2WT", - "elb.ap-south-1.amazonaws.com": "ZVDDRBQ08TROA", - "elb.ap-northeast-2.amazonaws.com": "ZIBE1TIR4HY56", - "elb.ap-southeast-1.amazonaws.com": "ZKVM4W9LS7TM", - "elb.ap-southeast-2.amazonaws.com": "ZCT6FZBF4DROD", - "elb.ap-northeast-1.amazonaws.com": "Z31USIVHYNEOWT", - "elb.eu-central-1.amazonaws.com": "Z3F0SRJ5LGBH90", - "elb.eu-west-1.amazonaws.com": "Z2IFOLAFXWLO4F", - "elb.eu-west-2.amazonaws.com": "ZD4D7Y8KGAS4G", - "elb.eu-west-3.amazonaws.com": "Z1CMS0P5QUZ6D5", - "elb.sa-east-1.amazonaws.com": "ZTK26PT1VY4CU", + "elb.us-east-2.amazonaws.com": "ZLMOA37VPKANP", + "elb.us-east-1.amazonaws.com": "Z26RNL4JYFTOTI", + "elb.us-west-1.amazonaws.com": "Z24FKFUX50B4VW", + "elb.us-west-2.amazonaws.com": "Z18D5FSROUN65G", + "elb.ca-central-1.amazonaws.com": "Z2EPGBW3API2WT", + "elb.ap-south-1.amazonaws.com": "ZVDDRBQ08TROA", + "elb.ap-northeast-2.amazonaws.com": "ZIBE1TIR4HY56", + "elb.ap-southeast-1.amazonaws.com": "ZKVM4W9LS7TM", + "elb.ap-southeast-2.amazonaws.com": "ZCT6FZBF4DROD", + "elb.ap-northeast-1.amazonaws.com": "Z31USIVHYNEOWT", + "elb.eu-central-1.amazonaws.com": "Z3F0SRJ5LGBH90", + "elb.eu-west-1.amazonaws.com": "Z2IFOLAFXWLO4F", + "elb.eu-west-2.amazonaws.com": "ZD4D7Y8KGAS4G", + "elb.eu-west-3.amazonaws.com": "Z1CMS0P5QUZ6D5", + "elb.eu-north-1.amazonaws.com": "Z1UDT6IFJ4EJM", + "elb.sa-east-1.amazonaws.com": "ZTK26PT1VY4CU", + "elb.cn-north-1.amazonaws.com.cn": "Z3QFB96KMJ7ED6", + "elb.cn-northwest-1.amazonaws.com.cn": "ZQEIKTCZ8352D", } ) @@ -85,6 +92,7 @@ type Route53API interface { ChangeResourceRecordSets(*route53.ChangeResourceRecordSetsInput) (*route53.ChangeResourceRecordSetsOutput, error) CreateHostedZone(*route53.CreateHostedZoneInput) (*route53.CreateHostedZoneOutput, error) ListHostedZonesPages(input *route53.ListHostedZonesInput, fn func(resp *route53.ListHostedZonesOutput, lastPage bool) (shouldContinue bool)) error + ListTagsForResource(input *route53.ListTagsForResourceInput) (*route53.ListTagsForResourceOutput, error) } // AWSProvider is an implementation of Provider for AWS Route53. @@ -100,6 +108,8 @@ type AWSProvider struct { zoneIDFilter ZoneIDFilter // filter hosted zones by type (e.g. private or public) zoneTypeFilter ZoneTypeFilter + // filter hosted zones by tags + zoneTagFilter ZoneTagFilter } // AWSConfig contains configuration to create a new AWS provider. @@ -107,16 +117,18 @@ type AWSConfig struct { DomainFilter DomainFilter ZoneIDFilter ZoneIDFilter ZoneTypeFilter ZoneTypeFilter + ZoneTagFilter ZoneTagFilter BatchChangeSize int BatchChangeInterval time.Duration EvaluateTargetHealth bool AssumeRole string + APIRetries int DryRun bool } // NewAWSProvider initializes a new AWS Route53 based Provider. func NewAWSProvider(awsConfig AWSConfig) (*AWSProvider, error) { - config := aws.NewConfig() + config := aws.NewConfig().WithMaxRetries(awsConfig.APIRetries) config.WithHTTPClient( instrumented_http.NewClient(config.HTTPClient, &instrumented_http.Callbacks{ @@ -145,6 +157,7 @@ func NewAWSProvider(awsConfig AWSConfig) (*AWSProvider, error) { domainFilter: awsConfig.DomainFilter, zoneIDFilter: awsConfig.ZoneIDFilter, zoneTypeFilter: awsConfig.ZoneTypeFilter, + zoneTagFilter: awsConfig.ZoneTagFilter, batchChangeSize: awsConfig.BatchChangeSize, batchChangeInterval: awsConfig.BatchChangeInterval, evaluateTargetHealth: awsConfig.EvaluateTargetHealth, @@ -158,6 +171,7 @@ func NewAWSProvider(awsConfig AWSConfig) (*AWSProvider, error) { func (p *AWSProvider) Zones() (map[string]*route53.HostedZone, error) { zones := make(map[string]*route53.HostedZone) + var tagErr error f := func(resp *route53.ListHostedZonesOutput, lastPage bool) (shouldContinue bool) { for _, zone := range resp.HostedZones { if !p.zoneIDFilter.Match(aws.StringValue(zone.Id)) { @@ -172,6 +186,18 @@ func (p *AWSProvider) Zones() (map[string]*route53.HostedZone, error) { continue } + // Only fetch tags if a tag filter was specified + if !p.zoneTagFilter.IsEmpty() { + tags, err := p.tagsForZone(*zone.Id) + if err != nil { + tagErr = err + return false + } + if !p.zoneTagFilter.Match(tags) { + continue + } + } + zones[aws.StringValue(zone.Id)] = zone } @@ -182,6 +208,9 @@ func (p *AWSProvider) Zones() (map[string]*route53.HostedZone, error) { if err != nil { return nil, err } + if tagErr != nil { + return nil, tagErr + } for _, zone := range zones { log.Debugf("Considering zone: %s (domain: %s)", aws.StringValue(zone.Id), aws.StringValue(zone.Name)) @@ -206,6 +235,11 @@ func (p *AWSProvider) Records() (endpoints []*endpoint.Endpoint, _ error) { return nil, err } + return p.records(zones) +} + +func (p *AWSProvider) records(zones map[string]*route53.HostedZone) ([]*endpoint.Endpoint, error) { + endpoints := make([]*endpoint.Endpoint, 0) f := func(resp *route53.ListResourceRecordSetsOutput, lastPage bool) (shouldContinue bool) { for _, r := range resp.ResourceRecordSets { // TODO(linki, ownership): Remove once ownership system is in place. @@ -230,6 +264,10 @@ func (p *AWSProvider) Records() (endpoints []*endpoint.Endpoint, _ error) { } if r.AliasTarget != nil { + // Alias records don't have TTLs so provide the default to match the TXT generation + if ttl == 0 { + ttl = recordTTL + } ep := endpoint. NewEndpointWithTTL(wildcardUnescape(aws.StringValue(r.Name)), endpoint.RecordTypeCNAME, ttl, aws.StringValue(r.AliasTarget.DNSName)). WithProviderSpecific(providerSpecificEvaluateTargetHealth, fmt.Sprintf("%t", aws.BoolValue(r.AliasTarget.EvaluateTargetHealth))) @@ -255,43 +293,65 @@ func (p *AWSProvider) Records() (endpoints []*endpoint.Endpoint, _ error) { // CreateRecords creates a given set of DNS records in the given hosted zone. func (p *AWSProvider) CreateRecords(endpoints []*endpoint.Endpoint) error { - return p.submitChanges(p.newChanges(route53.ChangeActionCreate, endpoints)) + return p.doRecords(route53.ChangeActionCreate, endpoints) } // UpdateRecords updates a given set of old records to a new set of records in a given hosted zone. func (p *AWSProvider) UpdateRecords(endpoints, _ []*endpoint.Endpoint) error { - return p.submitChanges(p.newChanges(route53.ChangeActionUpsert, endpoints)) + return p.doRecords(route53.ChangeActionUpsert, endpoints) } // DeleteRecords deletes a given set of DNS records in a given zone. func (p *AWSProvider) DeleteRecords(endpoints []*endpoint.Endpoint) error { - return p.submitChanges(p.newChanges(route53.ChangeActionDelete, endpoints)) + return p.doRecords(route53.ChangeActionDelete, endpoints) +} + +func (p *AWSProvider) doRecords(action string, endpoints []*endpoint.Endpoint) error { + zones, err := p.Zones() + if err != nil { + return err + } + + records, err := p.records(zones) + if err != nil { + log.Errorf("getting records failed: %v", err) + } + return p.submitChanges(p.newChanges(action, endpoints, records, zones), zones) } // ApplyChanges applies a given set of changes in a given zone. -func (p *AWSProvider) ApplyChanges(changes *plan.Changes) error { +func (p *AWSProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { + zones, err := p.Zones() + if err != nil { + return err + } + + records, ok := ctx.Value(RecordsContextKey).([]*endpoint.Endpoint) + if !ok { + var err error + records, err = p.records(zones) + if err != nil { + log.Errorf("getting records failed: %v", err) + } + } + combinedChanges := make([]*route53.Change, 0, len(changes.Create)+len(changes.UpdateNew)+len(changes.Delete)) - combinedChanges = append(combinedChanges, p.newChanges(route53.ChangeActionCreate, changes.Create)...) - combinedChanges = append(combinedChanges, p.newChanges(route53.ChangeActionUpsert, changes.UpdateNew)...) - combinedChanges = append(combinedChanges, p.newChanges(route53.ChangeActionDelete, changes.Delete)...) + combinedChanges = append(combinedChanges, p.newChanges(route53.ChangeActionCreate, changes.Create, records, zones)...) + combinedChanges = append(combinedChanges, p.newChanges(route53.ChangeActionUpsert, changes.UpdateNew, records, zones)...) + combinedChanges = append(combinedChanges, p.newChanges(route53.ChangeActionDelete, changes.Delete, records, zones)...) - return p.submitChanges(combinedChanges) + return p.submitChanges(combinedChanges, zones) } // submitChanges takes a zone and a collection of Changes and sends them as a single transaction. -func (p *AWSProvider) submitChanges(changes []*route53.Change) error { +func (p *AWSProvider) submitChanges(changes []*route53.Change, zones map[string]*route53.HostedZone) error { // return early if there is nothing to change if len(changes) == 0 { log.Info("All records are already up to date") return nil } - zones, err := p.Zones() - if err != nil { - return err - } - // separate into per-zone change sets to be passed to the API. changesByZone := changesByZone(zones, changes) if len(changesByZone) == 0 { @@ -343,11 +403,11 @@ func (p *AWSProvider) submitChanges(changes []*route53.Change) error { } // newChanges returns a collection of Changes based on the given records and action. -func (p *AWSProvider) newChanges(action string, endpoints []*endpoint.Endpoint) []*route53.Change { +func (p *AWSProvider) newChanges(action string, endpoints []*endpoint.Endpoint, recordsCache []*endpoint.Endpoint, zones map[string]*route53.HostedZone) []*route53.Change { changes := make([]*route53.Change, 0, len(endpoints)) for _, endpoint := range endpoints { - changes = append(changes, p.newChange(action, endpoint)) + changes = append(changes, p.newChange(action, endpoint, recordsCache, zones)) } return changes @@ -356,7 +416,7 @@ func (p *AWSProvider) newChanges(action string, endpoints []*endpoint.Endpoint) // newChange returns a Change of the given record by the given action, e.g. // action=ChangeActionCreate returns a change for creation of the record and // action=ChangeActionDelete returns a change for deletion of the record. -func (p *AWSProvider) newChange(action string, endpoint *endpoint.Endpoint) *route53.Change { +func (p *AWSProvider) newChange(action string, endpoint *endpoint.Endpoint, recordsCache []*endpoint.Endpoint, zones map[string]*route53.HostedZone) *route53.Change { change := &route53.Change{ Action: aws.String(action), ResourceRecordSet: &route53.ResourceRecordSet{ @@ -364,15 +424,10 @@ func (p *AWSProvider) newChange(action string, endpoint *endpoint.Endpoint) *rou }, } - rec, err := p.Records() - if err != nil { - log.Infof("getting records failed: %v", err) - } - if isAWSLoadBalancer(endpoint) { evalTargetHealth := p.evaluateTargetHealth - if _, ok := endpoint.ProviderSpecific[providerSpecificEvaluateTargetHealth]; ok { - evalTargetHealth = endpoint.ProviderSpecific[providerSpecificEvaluateTargetHealth] == "true" + if prop, ok := endpoint.GetProviderSpecificProperty(providerSpecificEvaluateTargetHealth); ok { + evalTargetHealth = prop.Value == "true" } change.ResourceRecordSet.Type = aws.String(route53.RRTypeA) @@ -381,11 +436,7 @@ func (p *AWSProvider) newChange(action string, endpoint *endpoint.Endpoint) *rou HostedZoneId: aws.String(canonicalHostedZone(endpoint.Targets[0])), EvaluateTargetHealth: aws.Bool(evalTargetHealth), } - } else if hostedZone := isAWSAlias(endpoint, rec); hostedZone != "" { - zones, err := p.Zones() - if err != nil { - log.Errorf("getting zones failed: %v", err) - } + } else if hostedZone := isAWSAlias(endpoint, recordsCache); hostedZone != "" { for _, zone := range zones { change.ResourceRecordSet.Type = aws.String(route53.RRTypeA) change.ResourceRecordSet.AliasTarget = &route53.AliasTarget{ @@ -412,6 +463,21 @@ func (p *AWSProvider) newChange(action string, endpoint *endpoint.Endpoint) *rou return change } +func (p *AWSProvider) tagsForZone(zoneID string) (map[string]string, error) { + response, err := p.client.ListTagsForResource(&route53.ListTagsForResourceInput{ + ResourceType: aws.String("hostedzone"), + ResourceId: aws.String(zoneID), + }) + if err != nil { + return nil, err + } + tagMap := map[string]string{} + for _, tag := range response.ResourceTagSet.Tags { + tagMap[*tag.Key] = *tag.Value + } + return tagMap, nil +} + func batchChangeSet(cs []*route53.Change, batchSize int) [][]*route53.Change { if len(cs) <= batchSize { return [][]*route53.Change{cs} @@ -549,7 +615,7 @@ func isAWSLoadBalancer(ep *endpoint.Endpoint) bool { // isAWSAlias determines if a given hostname belongs to an AWS Alias record by doing an reverse lookup. func isAWSAlias(ep *endpoint.Endpoint, addrs []*endpoint.Endpoint) string { - if val, exists := ep.ProviderSpecific["alias"]; ep.RecordType == endpoint.RecordTypeCNAME && exists && val == "true" { + if prop, exists := ep.GetProviderSpecificProperty("alias"); ep.RecordType == endpoint.RecordTypeCNAME && exists && prop.Value == "true" { for _, addr := range addrs { if addr.DNSName == ep.Targets[0] { if hostedZone := canonicalHostedZone(addr.Targets[0]); hostedZone != "" { diff --git a/provider/aws_sd.go b/provider/aws_sd.go index 480dfbd5a..8f921c7ef 100644 --- a/provider/aws_sd.go +++ b/provider/aws_sd.go @@ -17,11 +17,13 @@ limitations under the License. package provider import ( + "context" "strings" "crypto/sha256" "encoding/hex" "fmt" + "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/credentials/stscreds" "github.com/aws/aws-sdk-go/aws/request" @@ -192,7 +194,7 @@ func (p *AWSSDProvider) instancesToEndpoint(ns *sd.NamespaceSummary, srv *sd.Ser } // ApplyChanges applies Kubernetes changes in endpoints to AWS API -func (p *AWSSDProvider) ApplyChanges(changes *plan.Changes) error { +func (p *AWSSDProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { // return early if there is nothing to change if len(changes.Create) == 0 && len(changes.Delete) == 0 && len(changes.UpdateNew) == 0 { log.Info("All records are already up to date") diff --git a/provider/aws_sd_test.go b/provider/aws_sd_test.go index c567094fb..f25130fc9 100644 --- a/provider/aws_sd_test.go +++ b/provider/aws_sd_test.go @@ -17,6 +17,7 @@ limitations under the License. package provider import ( + "context" "errors" "math/rand" "reflect" @@ -316,7 +317,7 @@ func TestAWSSDProvider_ApplyChanges(t *testing.T) { provider := newTestAWSSDProvider(api, NewDomainFilter([]string{}), "") // apply creates - provider.ApplyChanges(&plan.Changes{ + provider.ApplyChanges(context.Background(), &plan.Changes{ Create: expectedEndpoints, }) @@ -332,7 +333,7 @@ func TestAWSSDProvider_ApplyChanges(t *testing.T) { assert.True(t, testutils.SameEndpoints(expectedEndpoints, endpoints), "expected and actual endpoints don't match, expected=%v, actual=%v", expectedEndpoints, endpoints) // apply deletes - provider.ApplyChanges(&plan.Changes{ + provider.ApplyChanges(context.Background(), &plan.Changes{ Delete: expectedEndpoints, }) diff --git a/provider/aws_test.go b/provider/aws_test.go index 6852b6df2..8802054c7 100644 --- a/provider/aws_test.go +++ b/provider/aws_test.go @@ -17,6 +17,7 @@ limitations under the License. package provider import ( + "context" "fmt" "net" "sort" @@ -50,6 +51,7 @@ var _ Route53API = &Route53APIStub{} type Route53APIStub struct { zones map[string]*route53.HostedZone recordSets map[string]map[string][]*route53.ResourceRecordSet + zoneTags map[string][]*route53.Tag m dynamicMock } @@ -66,6 +68,7 @@ func NewRoute53APIStub() *Route53APIStub { return &Route53APIStub{ zones: make(map[string]*route53.HostedZone), recordSets: make(map[string]map[string][]*route53.ResourceRecordSet), + zoneTags: make(map[string][]*route53.Tag), } } @@ -87,6 +90,43 @@ func (r *Route53APIStub) ListResourceRecordSetsPages(input *route53.ListResource return nil } +type Route53APICounter struct { + wrapped Route53API + calls map[string]int +} + +func NewRoute53APICounter(w Route53API) *Route53APICounter { + return &Route53APICounter{ + wrapped: w, + calls: map[string]int{}, + } +} + +func (c *Route53APICounter) ListResourceRecordSetsPages(input *route53.ListResourceRecordSetsInput, fn func(resp *route53.ListResourceRecordSetsOutput, lastPage bool) (shouldContinue bool)) error { + c.calls["ListResourceRecordSetsPages"]++ + return c.wrapped.ListResourceRecordSetsPages(input, fn) +} + +func (c *Route53APICounter) ChangeResourceRecordSets(input *route53.ChangeResourceRecordSetsInput) (*route53.ChangeResourceRecordSetsOutput, error) { + c.calls["ChangeResourceRecordSets"]++ + return c.wrapped.ChangeResourceRecordSets(input) +} + +func (c *Route53APICounter) CreateHostedZone(input *route53.CreateHostedZoneInput) (*route53.CreateHostedZoneOutput, error) { + c.calls["CreateHostedZone"]++ + return c.wrapped.CreateHostedZone(input) +} + +func (c *Route53APICounter) ListHostedZonesPages(input *route53.ListHostedZonesInput, fn func(resp *route53.ListHostedZonesOutput, lastPage bool) (shouldContinue bool)) error { + c.calls["ListHostedZonesPages"]++ + return c.wrapped.ListHostedZonesPages(input, fn) +} + +func (c *Route53APICounter) ListTagsForResource(input *route53.ListTagsForResourceInput) (*route53.ListTagsForResourceOutput, error) { + c.calls["ListTagsForResource"]++ + return c.wrapped.ListTagsForResource(input) +} + // Route53 stores wildcards escaped: http://docs.aws.amazon.com/Route53/latest/DeveloperGuide/DomainNameFormat.html?shortFooter=true#domain-name-format-asterisk func wildcardEscape(s string) string { if strings.Contains(s, "*") { @@ -95,6 +135,20 @@ func wildcardEscape(s string) string { return s } +func (r *Route53APIStub) ListTagsForResource(input *route53.ListTagsForResourceInput) (*route53.ListTagsForResourceOutput, error) { + if aws.StringValue(input.ResourceType) == "hostedzone" { + tags := r.zoneTags[aws.StringValue(input.ResourceId)] + return &route53.ListTagsForResourceOutput{ + ResourceTagSet: &route53.ResourceTagSet{ + ResourceId: input.ResourceId, + ResourceType: input.ResourceType, + Tags: tags, + }, + }, nil + } + return &route53.ListTagsForResourceOutput{}, nil +} + func (r *Route53APIStub) ChangeResourceRecordSets(input *route53.ChangeResourceRecordSetsInput) (*route53.ChangeResourceRecordSetsOutput, error) { if r.m.isMocked("ChangeResourceRecordSets", input) { return r.m.ChangeResourceRecordSets(input) @@ -231,15 +285,17 @@ func TestAWSZones(t *testing.T) { msg string zoneIDFilter ZoneIDFilter zoneTypeFilter ZoneTypeFilter + zoneTagFilter ZoneTagFilter expectedZones map[string]*route53.HostedZone }{ - {"no filter", NewZoneIDFilter([]string{}), NewZoneTypeFilter(""), allZones}, - {"public filter", NewZoneIDFilter([]string{}), NewZoneTypeFilter("public"), publicZones}, - {"private filter", NewZoneIDFilter([]string{}), NewZoneTypeFilter("private"), privateZones}, - {"unknown filter", NewZoneIDFilter([]string{}), NewZoneTypeFilter("unknown"), noZones}, - {"zone id filter", NewZoneIDFilter([]string{"/hostedzone/zone-3.ext-dns-test-2.teapot.zalan.do."}), NewZoneTypeFilter(""), privateZones}, + {"no filter", NewZoneIDFilter([]string{}), NewZoneTypeFilter(""), NewZoneTagFilter([]string{}), allZones}, + {"public filter", NewZoneIDFilter([]string{}), NewZoneTypeFilter("public"), NewZoneTagFilter([]string{}), publicZones}, + {"private filter", NewZoneIDFilter([]string{}), NewZoneTypeFilter("private"), NewZoneTagFilter([]string{}), privateZones}, + {"unknown filter", NewZoneIDFilter([]string{}), NewZoneTypeFilter("unknown"), NewZoneTagFilter([]string{}), noZones}, + {"zone id filter", NewZoneIDFilter([]string{"/hostedzone/zone-3.ext-dns-test-2.teapot.zalan.do."}), NewZoneTypeFilter(""), NewZoneTagFilter([]string{}), privateZones}, + {"tag filter", NewZoneIDFilter([]string{}), NewZoneTypeFilter(""), NewZoneTagFilter([]string{"zone=3"}), privateZones}, } { - provider, _ := newAWSProvider(t, NewDomainFilter([]string{"ext-dns-test-2.teapot.zalan.do."}), ti.zoneIDFilter, ti.zoneTypeFilter, defaultEvaluateTargetHealth, false, []*endpoint.Endpoint{}) + provider, _ := newAWSProviderWithTagFilter(t, NewDomainFilter([]string{"ext-dns-test-2.teapot.zalan.do."}), ti.zoneIDFilter, ti.zoneTypeFilter, ti.zoneTagFilter, defaultEvaluateTargetHealth, false, []*endpoint.Endpoint{}) zones, err := provider.Zones() require.NoError(t, err) @@ -267,9 +323,9 @@ func TestAWSRecords(t *testing.T) { endpoint.NewEndpointWithTTL("list-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "1.2.3.4"), endpoint.NewEndpointWithTTL("list-test.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "8.8.8.8"), endpoint.NewEndpointWithTTL("*.wildcard-test.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "8.8.8.8"), - endpoint.NewEndpoint("list-test-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "false"), - endpoint.NewEndpoint("*.wildcard-test-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "false"), - endpoint.NewEndpoint("list-test-alias-evaluate.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "true"), + endpoint.NewEndpointWithTTL("list-test-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, endpoint.TTL(recordTTL), "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "false"), + endpoint.NewEndpointWithTTL("*.wildcard-test-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, endpoint.TTL(recordTTL), "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "false"), + endpoint.NewEndpointWithTTL("list-test-alias-evaluate.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, endpoint.TTL(recordTTL), "foo.eu-central-1.elb.amazonaws.com").WithProviderSpecific(providerSpecificEvaluateTargetHealth, "true"), endpoint.NewEndpointWithTTL("list-test-multiple.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "8.8.8.8", "8.8.4.4"), endpoint.NewEndpointWithTTL("prefix-*.wildcard.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeTXT, endpoint.TTL(recordTTL), "random"), }) @@ -357,74 +413,96 @@ func TestAWSDeleteRecords(t *testing.T) { } func TestAWSApplyChanges(t *testing.T) { - provider, _ := newAWSProvider(t, NewDomainFilter([]string{"ext-dns-test-2.teapot.zalan.do."}), NewZoneIDFilter([]string{}), NewZoneTypeFilter(""), defaultEvaluateTargetHealth, false, []*endpoint.Endpoint{ - endpoint.NewEndpointWithTTL("update-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "8.8.8.8"), - endpoint.NewEndpointWithTTL("delete-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "8.8.8.8"), - endpoint.NewEndpointWithTTL("update-test.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "8.8.4.4"), - endpoint.NewEndpointWithTTL("delete-test.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "8.8.4.4"), - endpoint.NewEndpointWithTTL("update-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, endpoint.TTL(recordTTL), "bar.elb.amazonaws.com"), - endpoint.NewEndpointWithTTL("delete-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, endpoint.TTL(recordTTL), "qux.elb.amazonaws.com"), - endpoint.NewEndpointWithTTL("update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, endpoint.TTL(recordTTL), "bar.elb.amazonaws.com"), - endpoint.NewEndpointWithTTL("delete-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, endpoint.TTL(recordTTL), "qux.elb.amazonaws.com"), - endpoint.NewEndpointWithTTL("update-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "8.8.8.8", "8.8.4.4"), - endpoint.NewEndpointWithTTL("delete-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "1.2.3.4", "4.3.2.1"), - }) - - createRecords := []*endpoint.Endpoint{ - endpoint.NewEndpoint("create-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8"), - endpoint.NewEndpoint("create-test.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.4.4"), - endpoint.NewEndpoint("create-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "foo.elb.amazonaws.com"), - endpoint.NewEndpoint("create-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "foo.elb.amazonaws.com"), - endpoint.NewEndpoint("create-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8", "8.8.4.4"), + tests := []struct { + name string + setup func(p *AWSProvider) context.Context + listRRSets int + }{ + {"no cache", func(p *AWSProvider) context.Context { return context.Background() }, 3}, + {"cached", func(p *AWSProvider) context.Context { + records, err := p.Records() + require.NoError(t, err) + return context.WithValue(context.Background(), RecordsContextKey, records) + }, 0}, } - currentRecords := []*endpoint.Endpoint{ - endpoint.NewEndpoint("update-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8"), - endpoint.NewEndpoint("update-test.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.4.4"), - endpoint.NewEndpoint("update-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "bar.elb.amazonaws.com"), - endpoint.NewEndpoint("update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "bar.elb.amazonaws.com"), - endpoint.NewEndpoint("update-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8", "8.8.4.4"), + for _, tt := range tests { + provider, _ := newAWSProvider(t, NewDomainFilter([]string{"ext-dns-test-2.teapot.zalan.do."}), NewZoneIDFilter([]string{}), NewZoneTypeFilter(""), defaultEvaluateTargetHealth, false, []*endpoint.Endpoint{ + endpoint.NewEndpointWithTTL("update-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "8.8.8.8"), + endpoint.NewEndpointWithTTL("delete-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "8.8.8.8"), + endpoint.NewEndpointWithTTL("update-test.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "8.8.4.4"), + endpoint.NewEndpointWithTTL("delete-test.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "8.8.4.4"), + endpoint.NewEndpointWithTTL("update-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, endpoint.TTL(recordTTL), "bar.elb.amazonaws.com"), + endpoint.NewEndpointWithTTL("delete-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, endpoint.TTL(recordTTL), "qux.elb.amazonaws.com"), + endpoint.NewEndpointWithTTL("update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, endpoint.TTL(recordTTL), "bar.elb.amazonaws.com"), + endpoint.NewEndpointWithTTL("delete-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, endpoint.TTL(recordTTL), "qux.elb.amazonaws.com"), + endpoint.NewEndpointWithTTL("update-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "8.8.8.8", "8.8.4.4"), + endpoint.NewEndpointWithTTL("delete-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "1.2.3.4", "4.3.2.1"), + }) + + createRecords := []*endpoint.Endpoint{ + endpoint.NewEndpoint("create-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8"), + endpoint.NewEndpoint("create-test.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.4.4"), + endpoint.NewEndpoint("create-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "foo.elb.amazonaws.com"), + endpoint.NewEndpoint("create-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "foo.elb.amazonaws.com"), + endpoint.NewEndpoint("create-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8", "8.8.4.4"), + } + + currentRecords := []*endpoint.Endpoint{ + endpoint.NewEndpoint("update-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8"), + endpoint.NewEndpoint("update-test.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.4.4"), + endpoint.NewEndpoint("update-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "bar.elb.amazonaws.com"), + endpoint.NewEndpoint("update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "bar.elb.amazonaws.com"), + endpoint.NewEndpoint("update-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8", "8.8.4.4"), + } + updatedRecords := []*endpoint.Endpoint{ + endpoint.NewEndpoint("update-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4"), + endpoint.NewEndpoint("update-test.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "4.3.2.1"), + endpoint.NewEndpoint("update-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "baz.elb.amazonaws.com"), + endpoint.NewEndpoint("update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "baz.elb.amazonaws.com"), + endpoint.NewEndpoint("update-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4", "4.3.2.1"), + } + + deleteRecords := []*endpoint.Endpoint{ + endpoint.NewEndpoint("delete-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8"), + endpoint.NewEndpoint("delete-test.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.4.4"), + endpoint.NewEndpoint("delete-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "qux.elb.amazonaws.com"), + endpoint.NewEndpoint("delete-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "qux.elb.amazonaws.com"), + endpoint.NewEndpoint("delete-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4", "4.3.2.1"), + } + + changes := &plan.Changes{ + Create: createRecords, + UpdateNew: updatedRecords, + UpdateOld: currentRecords, + Delete: deleteRecords, + } + + ctx := tt.setup(provider) + + counter := NewRoute53APICounter(provider.client) + provider.client = counter + require.NoError(t, provider.ApplyChanges(ctx, changes)) + + assert.Equal(t, 1, counter.calls["ListHostedZonesPages"], tt.name) + assert.Equal(t, tt.listRRSets, counter.calls["ListResourceRecordSetsPages"], tt.name) + + records, err := provider.Records() + require.NoError(t, err, tt.name) + + validateEndpoints(t, records, []*endpoint.Endpoint{ + endpoint.NewEndpointWithTTL("create-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "8.8.8.8"), + endpoint.NewEndpointWithTTL("update-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "1.2.3.4"), + endpoint.NewEndpointWithTTL("create-test.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "8.8.4.4"), + endpoint.NewEndpointWithTTL("update-test.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "4.3.2.1"), + endpoint.NewEndpointWithTTL("create-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, endpoint.TTL(recordTTL), "foo.elb.amazonaws.com"), + endpoint.NewEndpointWithTTL("update-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, endpoint.TTL(recordTTL), "baz.elb.amazonaws.com"), + endpoint.NewEndpointWithTTL("create-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, endpoint.TTL(recordTTL), "foo.elb.amazonaws.com"), + endpoint.NewEndpointWithTTL("update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, endpoint.TTL(recordTTL), "baz.elb.amazonaws.com"), + endpoint.NewEndpointWithTTL("create-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "8.8.8.8", "8.8.4.4"), + endpoint.NewEndpointWithTTL("update-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "1.2.3.4", "4.3.2.1"), + }) } - updatedRecords := []*endpoint.Endpoint{ - endpoint.NewEndpoint("update-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4"), - endpoint.NewEndpoint("update-test.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "4.3.2.1"), - endpoint.NewEndpoint("update-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "baz.elb.amazonaws.com"), - endpoint.NewEndpoint("update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "baz.elb.amazonaws.com"), - endpoint.NewEndpoint("update-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4", "4.3.2.1"), - } - - deleteRecords := []*endpoint.Endpoint{ - endpoint.NewEndpoint("delete-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8"), - endpoint.NewEndpoint("delete-test.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.4.4"), - endpoint.NewEndpoint("delete-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "qux.elb.amazonaws.com"), - endpoint.NewEndpoint("delete-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, "qux.elb.amazonaws.com"), - endpoint.NewEndpoint("delete-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4", "4.3.2.1"), - } - - changes := &plan.Changes{ - Create: createRecords, - UpdateNew: updatedRecords, - UpdateOld: currentRecords, - Delete: deleteRecords, - } - - require.NoError(t, provider.ApplyChanges(changes)) - - records, err := provider.Records() - require.NoError(t, err) - - validateEndpoints(t, records, []*endpoint.Endpoint{ - endpoint.NewEndpointWithTTL("create-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "8.8.8.8"), - endpoint.NewEndpointWithTTL("update-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "1.2.3.4"), - endpoint.NewEndpointWithTTL("create-test.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "8.8.4.4"), - endpoint.NewEndpointWithTTL("update-test.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "4.3.2.1"), - endpoint.NewEndpointWithTTL("create-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, endpoint.TTL(recordTTL), "foo.elb.amazonaws.com"), - endpoint.NewEndpointWithTTL("update-test-cname.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, endpoint.TTL(recordTTL), "baz.elb.amazonaws.com"), - endpoint.NewEndpointWithTTL("create-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, endpoint.TTL(recordTTL), "foo.elb.amazonaws.com"), - endpoint.NewEndpointWithTTL("update-test-cname-alias.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeCNAME, endpoint.TTL(recordTTL), "baz.elb.amazonaws.com"), - endpoint.NewEndpointWithTTL("create-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "8.8.8.8", "8.8.4.4"), - endpoint.NewEndpointWithTTL("update-test-multiple.zone-2.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "1.2.3.4", "4.3.2.1"), - }) } func TestAWSApplyChangesDryRun(t *testing.T) { @@ -481,7 +559,7 @@ func TestAWSApplyChangesDryRun(t *testing.T) { Delete: deleteRecords, } - require.NoError(t, provider.ApplyChanges(changes)) + require.NoError(t, provider.ApplyChanges(context.Background(), changes)) records, err := provider.Records() require.NoError(t, err) @@ -601,10 +679,12 @@ func TestAWSsubmitChanges(t *testing.T) { } } + zones, _ := provider.Zones() + records, _ := provider.Records() cs := make([]*route53.Change, 0, len(endpoints)) - cs = append(cs, provider.newChanges(route53.ChangeActionCreate, endpoints)...) + cs = append(cs, provider.newChanges(route53.ChangeActionCreate, endpoints, records, zones)...) - require.NoError(t, provider.submitChanges(cs)) + require.NoError(t, provider.submitChanges(cs, zones)) records, err := provider.Records() require.NoError(t, err) @@ -616,10 +696,15 @@ func TestAWSsubmitChangesError(t *testing.T) { provider, clientStub := newAWSProvider(t, NewDomainFilter([]string{"ext-dns-test-2.teapot.zalan.do."}), NewZoneIDFilter([]string{}), NewZoneTypeFilter(""), defaultEvaluateTargetHealth, false, []*endpoint.Endpoint{}) clientStub.MockMethod("ChangeResourceRecordSets", mock.Anything).Return(nil, fmt.Errorf("Mock route53 failure")) - ep := endpoint.NewEndpointWithTTL("fail.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "1.0.0.1") - cs := provider.newChanges(route53.ChangeActionCreate, []*endpoint.Endpoint{ep}) + zones, err := provider.Zones() + require.NoError(t, err) + records, err := provider.Records() + require.NoError(t, err) - require.Error(t, provider.submitChanges(cs)) + ep := endpoint.NewEndpointWithTTL("fail.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "1.0.0.1") + cs := provider.newChanges(route53.ChangeActionCreate, []*endpoint.Endpoint{ep}, records, zones) + + require.Error(t, provider.submitChanges(cs, zones)) } func TestAWSBatchChangeSet(t *testing.T) { @@ -781,7 +866,10 @@ func TestAWSCreateRecordsWithALIAS(t *testing.T) { Targets: endpoint.Targets{"foo.eu-central-1.elb.amazonaws.com"}, RecordType: endpoint.RecordTypeCNAME, ProviderSpecific: endpoint.ProviderSpecific{ - providerSpecificEvaluateTargetHealth: key, + endpoint.ProviderSpecificProperty{ + Name: providerSpecificEvaluateTargetHealth, + Value: key, + }, }, }, } @@ -832,9 +920,14 @@ func TestAWSisAWSAlias(t *testing.T) { {"foo.example.org", endpoint.RecordTypeCNAME, "true", ""}, } { ep := &endpoint.Endpoint{ - Targets: endpoint.Targets{tc.target}, - RecordType: tc.recordType, - ProviderSpecific: map[string]string{"alias": tc.alias}, + Targets: endpoint.Targets{tc.target}, + RecordType: tc.recordType, + ProviderSpecific: endpoint.ProviderSpecific{ + endpoint.ProviderSpecificProperty{ + Name: "alias", + Value: tc.alias, + }, + }, } addrs := []*endpoint.Endpoint{ { @@ -872,6 +965,8 @@ func TestAWSCanonicalHostedZone(t *testing.T) { {"foo.eu-west-2.elb.amazonaws.com", "ZHURV8PSTC4K8"}, {"foo.eu-west-3.elb.amazonaws.com", "Z3Q77PNBQS71R4"}, {"foo.sa-east-1.elb.amazonaws.com", "Z2P70J7HTTTPLU"}, + {"foo.cn-north-1.elb.amazonaws.com.cn", "Z3BX2TMKNYI13Y"}, + {"foo.cn-northwest-1.elb.amazonaws.com.cn", "Z3BX2TMKNYI13Y"}, // Network Load Balancers {"foo.elb.us-east-2.amazonaws.com", "ZLMOA37VPKANP"}, {"foo.elb.us-east-1.amazonaws.com", "Z26RNL4JYFTOTI"}, @@ -888,6 +983,8 @@ func TestAWSCanonicalHostedZone(t *testing.T) { {"foo.elb.eu-west-2.amazonaws.com", "ZD4D7Y8KGAS4G"}, {"foo.elb.eu-west-3.amazonaws.com", "Z1CMS0P5QUZ6D5"}, {"foo.elb.sa-east-1.amazonaws.com", "ZTK26PT1VY4CU"}, + {"foo.elb.cn-north-1.amazonaws.com.cn", "Z3QFB96KMJ7ED6"}, + {"foo.elb.cn-northwest-1.amazonaws.com.cn", "ZQEIKTCZ8352D"}, // No Load Balancer {"foo.example.org", ""}, } { @@ -964,7 +1061,7 @@ func setupAWSRecords(t *testing.T, provider *AWSProvider, endpoints []*endpoint. escapeAWSRecords(t, provider, "/hostedzone/zone-2.ext-dns-test-2.teapot.zalan.do.") escapeAWSRecords(t, provider, "/hostedzone/zone-3.ext-dns-test-2.teapot.zalan.do.") - records, err = provider.Records() + _, err = provider.Records() require.NoError(t, err) } @@ -1027,8 +1124,11 @@ func escapeAWSRecords(t *testing.T, provider *AWSProvider, zone string) { require.NoError(t, err) } } - func newAWSProvider(t *testing.T, domainFilter DomainFilter, zoneIDFilter ZoneIDFilter, zoneTypeFilter ZoneTypeFilter, evaluateTargetHealth, dryRun bool, records []*endpoint.Endpoint) (*AWSProvider, *Route53APIStub) { + return newAWSProviderWithTagFilter(t, domainFilter, zoneIDFilter, zoneTypeFilter, NewZoneTagFilter([]string{}), evaluateTargetHealth, dryRun, records) +} + +func newAWSProviderWithTagFilter(t *testing.T, domainFilter DomainFilter, zoneIDFilter ZoneIDFilter, zoneTypeFilter ZoneTypeFilter, zoneTagFilter ZoneTagFilter, evaluateTargetHealth, dryRun bool, records []*endpoint.Endpoint) (*AWSProvider, *Route53APIStub) { client := NewRoute53APIStub() provider := &AWSProvider{ @@ -1039,6 +1139,7 @@ func newAWSProvider(t *testing.T, domainFilter DomainFilter, zoneIDFilter ZoneID domainFilter: domainFilter, zoneIDFilter: zoneIDFilter, zoneTypeFilter: zoneTypeFilter, + zoneTagFilter: zoneTagFilter, dryRun: false, } @@ -1067,6 +1168,8 @@ func newAWSProvider(t *testing.T, domainFilter DomainFilter, zoneIDFilter ZoneID Config: &route53.HostedZoneConfig{PrivateZone: aws.Bool(false)}, }) + setupZoneTags(provider.client.(*Route53APIStub)) + setupAWSRecords(t, provider, records) provider.dryRun = dryRun @@ -1074,6 +1177,40 @@ func newAWSProvider(t *testing.T, domainFilter DomainFilter, zoneIDFilter ZoneID return provider, client } +func setupZoneTags(client *Route53APIStub) { + addZoneTags(client.zoneTags, "/hostedzone/zone-1.ext-dns-test-2.teapot.zalan.do.", map[string]string{ + "zone-1-tag-1": "tag-1-value", + "domain": "test-2", + "zone": "1", + }) + addZoneTags(client.zoneTags, "/hostedzone/zone-2.ext-dns-test-2.teapot.zalan.do.", map[string]string{ + "zone-2-tag-1": "tag-1-value", + "domain": "test-2", + "zone": "2", + }) + addZoneTags(client.zoneTags, "/hostedzone/zone-3.ext-dns-test-2.teapot.zalan.do.", map[string]string{ + "zone-3-tag-1": "tag-1-value", + "domain": "test-2", + "zone": "3", + }) + addZoneTags(client.zoneTags, "/hostedzone/zone-4.ext-dns-test-2.teapot.zalan.do.", map[string]string{ + "zone-4-tag-1": "tag-1-value", + "domain": "test-3", + "zone": "4", + }) +} + +func addZoneTags(tagMap map[string][]*route53.Tag, zoneID string, tags map[string]string) { + tagList := make([]*route53.Tag, 0, len(tags)) + for k, v := range tags { + tagList = append(tagList, &route53.Tag{ + Key: aws.String(k), + Value: aws.String(v), + }) + } + tagMap[zoneID] = tagList +} + func validateRecords(t *testing.T, records []*route53.ResourceRecordSet, expected []*route53.ResourceRecordSet) { assert.Equal(t, expected, records) } diff --git a/provider/azure.go b/provider/azure.go index 82077139e..3f887e555 100644 --- a/provider/azure.go +++ b/provider/azure.go @@ -17,6 +17,7 @@ limitations under the License. package provider import ( + "context" "fmt" "io/ioutil" "strings" @@ -179,9 +180,9 @@ func (p *AzureProvider) Records() (endpoints []*endpoint.Endpoint, _ error) { return true } name := formatAzureDNSName(*recordSet.Name, *zone.Name) - target := extractAzureTarget(&recordSet) - if target == "" { - log.Errorf("Failed to extract target for '%s' with type '%s'.", name, recordType) + targets := extractAzureTargets(&recordSet) + if len(targets) == 0 { + log.Errorf("Failed to extract targets for '%s' with type '%s'.", name, recordType) return true } var ttl endpoint.TTL @@ -189,7 +190,7 @@ func (p *AzureProvider) Records() (endpoints []*endpoint.Endpoint, _ error) { ttl = endpoint.TTL(*recordSet.TTL) } - ep := endpoint.NewEndpointWithTTL(name, recordType, endpoint.TTL(ttl), target) + ep := endpoint.NewEndpointWithTTL(name, recordType, endpoint.TTL(ttl), targets...) log.Debugf( "Found %s record for '%s' with target '%s'.", ep.RecordType, @@ -209,7 +210,7 @@ func (p *AzureProvider) Records() (endpoints []*endpoint.Endpoint, _ error) { // ApplyChanges applies the given changes. // // Returns nil if the operation was successful or an error if the operation failed. -func (p *AzureProvider) ApplyChanges(changes *plan.Changes) error { +func (p *AzureProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { zones, err := p.zones() if err != nil { return err @@ -414,14 +415,16 @@ func (p *AzureProvider) newRecordSet(endpoint *endpoint.Endpoint) (dns.RecordSet } switch dns.RecordType(endpoint.RecordType) { case dns.A: + aRecords := make([]dns.ARecord, len(endpoint.Targets)) + for i, target := range endpoint.Targets { + aRecords[i] = dns.ARecord{ + Ipv4Address: to.StringPtr(target), + } + } return dns.RecordSet{ RecordSetProperties: &dns.RecordSetProperties{ - TTL: to.Int64Ptr(ttl), - ARecords: &[]dns.ARecord{ - { - Ipv4Address: to.StringPtr(endpoint.Targets[0]), - }, - }, + TTL: to.Int64Ptr(ttl), + ARecords: &aRecords, }, }, nil case dns.CNAME: @@ -459,22 +462,26 @@ func formatAzureDNSName(recordName, zoneName string) string { } // Helper function (shared with text code) -func extractAzureTarget(recordSet *dns.RecordSet) string { +func extractAzureTargets(recordSet *dns.RecordSet) []string { properties := recordSet.RecordSetProperties if properties == nil { - return "" + return []string{} } // Check for A records aRecords := properties.ARecords if aRecords != nil && len(*aRecords) > 0 && (*aRecords)[0].Ipv4Address != nil { - return *(*aRecords)[0].Ipv4Address + targets := make([]string, len(*aRecords)) + for i, aRecord := range *aRecords { + targets[i] = *aRecord.Ipv4Address + } + return targets } // Check for CNAME records cnameRecord := properties.CnameRecord if cnameRecord != nil && cnameRecord.Cname != nil { - return *cnameRecord.Cname + return []string{*cnameRecord.Cname} } // Check for TXT records @@ -482,8 +489,8 @@ func extractAzureTarget(recordSet *dns.RecordSet) string { if txtRecords != nil && len(*txtRecords) > 0 && (*txtRecords)[0].Value != nil { values := (*txtRecords)[0].Value if values != nil && len(*values) > 0 { - return (*values)[0] + return []string{(*values)[0]} } } - return "" + return []string{} } diff --git a/provider/azure_test.go b/provider/azure_test.go index e9da5c286..36d697661 100644 --- a/provider/azure_test.go +++ b/provider/azure_test.go @@ -17,6 +17,7 @@ limitations under the License. package provider import ( + "context" "testing" "github.com/Azure/azure-sdk-for-go/arm/dns" @@ -57,47 +58,52 @@ func (client *mockZonesClient) ListByResourceGroupNextResults(lastResults dns.Zo return dns.ZoneListResult{}, nil } -func aRecordSetPropertiesGetter(value string, ttl int64) *dns.RecordSetProperties { +func aRecordSetPropertiesGetter(values []string, ttl int64) *dns.RecordSetProperties { + aRecords := make([]dns.ARecord, len(values)) + for i, value := range values { + aRecords[i] = dns.ARecord{ + Ipv4Address: to.StringPtr(value), + } + } return &dns.RecordSetProperties{ - TTL: to.Int64Ptr(ttl), - ARecords: &[]dns.ARecord{ - { - Ipv4Address: to.StringPtr(value), - }, - }, + TTL: to.Int64Ptr(ttl), + ARecords: &aRecords, } } -func cNameRecordSetPropertiesGetter(value string, ttl int64) *dns.RecordSetProperties { +func cNameRecordSetPropertiesGetter(values []string, ttl int64) *dns.RecordSetProperties { return &dns.RecordSetProperties{ TTL: to.Int64Ptr(ttl), CnameRecord: &dns.CnameRecord{ - Cname: to.StringPtr(value), + Cname: to.StringPtr(values[0]), }, } } -func txtRecordSetPropertiesGetter(value string, ttl int64) *dns.RecordSetProperties { +func txtRecordSetPropertiesGetter(values []string, ttl int64) *dns.RecordSetProperties { return &dns.RecordSetProperties{ TTL: to.Int64Ptr(ttl), TxtRecords: &[]dns.TxtRecord{ { - Value: &[]string{value}, + Value: &[]string{values[0]}, }, }, } } -func othersRecordSetPropertiesGetter(value string, ttl int64) *dns.RecordSetProperties { +func othersRecordSetPropertiesGetter(values []string, ttl int64) *dns.RecordSetProperties { return &dns.RecordSetProperties{ TTL: to.Int64Ptr(ttl), } } -func createMockRecordSet(name, recordType, value string) dns.RecordSet { - return createMockRecordSetWithTTL(name, recordType, value, 0) +func createMockRecordSet(name, recordType string, values ...string) dns.RecordSet { + return createMockRecordSetMultiWithTTL(name, recordType, 0, values...) } func createMockRecordSetWithTTL(name, recordType, value string, ttl int64) dns.RecordSet { - var getterFunc func(value string, ttl int64) *dns.RecordSetProperties + return createMockRecordSetMultiWithTTL(name, recordType, ttl, value) +} +func createMockRecordSetMultiWithTTL(name, recordType string, ttl int64, values ...string) dns.RecordSet { + var getterFunc func(values []string, ttl int64) *dns.RecordSetProperties switch recordType { case endpoint.RecordTypeA: @@ -112,7 +118,7 @@ func createMockRecordSetWithTTL(name, recordType, value string, ttl int64) dns.R return dns.RecordSet{ Name: to.StringPtr(name), Type: to.StringPtr("Microsoft.Network/dnszones/" + recordType), - RecordSetProperties: getterFunc(value, ttl), + RecordSetProperties: getterFunc(values, ttl), } } @@ -148,7 +154,7 @@ func (client *mockRecordsClient) CreateOrUpdate(resourceGroupName string, zoneNa formatAzureDNSName(relativeRecordSetName, zoneName), string(recordType), ttl, - extractAzureTarget(¶meters), + extractAzureTargets(¶meters)..., ), ) return parameters, nil @@ -209,6 +215,46 @@ func TestAzureRecord(t *testing.T) { } +func TestAzureMultiRecord(t *testing.T) { + zonesClient := mockZonesClient{ + mockZoneListResult: &dns.ZoneListResult{ + Value: &[]dns.Zone{ + createMockZone("example.com", "/dnszones/example.com"), + }, + }, + } + + recordsClient := mockRecordsClient{ + mockRecordSet: &[]dns.RecordSet{ + createMockRecordSet("@", "NS", "ns1-03.azure-dns.com."), + createMockRecordSet("@", "SOA", "Email: azuredns-hostmaster.microsoft.com"), + createMockRecordSet("@", endpoint.RecordTypeA, "123.123.123.122", "234.234.234.233"), + createMockRecordSet("@", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default"), + createMockRecordSetMultiWithTTL("nginx", endpoint.RecordTypeA, 3600, "123.123.123.123", "234.234.234.234"), + createMockRecordSetWithTTL("nginx", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default", recordTTL), + createMockRecordSetWithTTL("hack", endpoint.RecordTypeCNAME, "hack.azurewebsites.net", 10), + }, + } + + provider := newAzureProvider(NewDomainFilter([]string{"example.com"}), NewZoneIDFilter([]string{""}), true, "k8s", &zonesClient, &recordsClient) + + actual, err := provider.Records() + + if err != nil { + t.Fatal(err) + } + expected := []*endpoint.Endpoint{ + endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "123.123.123.122", "234.234.234.233"), + endpoint.NewEndpoint("example.com", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default"), + endpoint.NewEndpointWithTTL("nginx.example.com", endpoint.RecordTypeA, 3600, "123.123.123.123", "234.234.234.234"), + endpoint.NewEndpointWithTTL("nginx.example.com", endpoint.RecordTypeTXT, recordTTL, "heritage=external-dns,external-dns/owner=default"), + endpoint.NewEndpointWithTTL("hack.example.com", endpoint.RecordTypeCNAME, 10, "hack.azurewebsites.net"), + } + + validateAzureEndpoints(t, actual, expected) + +} + func TestAzureApplyChanges(t *testing.T) { recordsClient := mockRecordsClient{} @@ -224,7 +270,7 @@ func TestAzureApplyChanges(t *testing.T) { validateAzureEndpoints(t, recordsClient.updatedEndpoints, []*endpoint.Endpoint{ endpoint.NewEndpointWithTTL("example.com", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "1.2.3.4"), endpoint.NewEndpointWithTTL("example.com", endpoint.RecordTypeTXT, endpoint.TTL(recordTTL), "tag"), - endpoint.NewEndpointWithTTL("foo.example.com", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "1.2.3.4"), + endpoint.NewEndpointWithTTL("foo.example.com", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "1.2.3.4", "1.2.3.5"), endpoint.NewEndpointWithTTL("foo.example.com", endpoint.RecordTypeTXT, endpoint.TTL(recordTTL), "tag"), endpoint.NewEndpointWithTTL("bar.example.com", endpoint.RecordTypeCNAME, endpoint.TTL(recordTTL), "other.com"), endpoint.NewEndpointWithTTL("bar.example.com", endpoint.RecordTypeTXT, endpoint.TTL(recordTTL), "tag"), @@ -265,7 +311,7 @@ func testAzureApplyChangesInternal(t *testing.T, dryRun bool, client RecordsClie createRecords := []*endpoint.Endpoint{ endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "1.2.3.4"), endpoint.NewEndpoint("example.com", endpoint.RecordTypeTXT, "tag"), - endpoint.NewEndpoint("foo.example.com", endpoint.RecordTypeA, "1.2.3.4"), + endpoint.NewEndpoint("foo.example.com", endpoint.RecordTypeA, "1.2.3.5", "1.2.3.4"), endpoint.NewEndpoint("foo.example.com", endpoint.RecordTypeTXT, "tag"), endpoint.NewEndpoint("bar.example.com", endpoint.RecordTypeCNAME, "other.com"), endpoint.NewEndpoint("bar.example.com", endpoint.RecordTypeTXT, "tag"), @@ -299,7 +345,7 @@ func testAzureApplyChangesInternal(t *testing.T, dryRun bool, client RecordsClie Delete: deleteRecords, } - if err := provider.ApplyChanges(changes); err != nil { + if err := provider.ApplyChanges(context.Background(), changes); err != nil { t.Fatal(err) } } diff --git a/provider/cloudflare.go b/provider/cloudflare.go index 75e24f414..85ff411c6 100644 --- a/provider/cloudflare.go +++ b/provider/cloudflare.go @@ -17,8 +17,11 @@ limitations under the License. package provider import ( + "context" "fmt" "os" + "sort" + "strconv" "strings" cloudflare "github.com/cloudflare/cloudflare-go" @@ -26,6 +29,7 @@ import ( "github.com/kubernetes-incubator/external-dns/endpoint" "github.com/kubernetes-incubator/external-dns/plan" + "github.com/kubernetes-incubator/external-dns/source" ) const ( @@ -53,6 +57,7 @@ type cloudFlareDNS interface { UserDetails() (cloudflare.User, error) ZoneIDByName(zoneName string) (string, error) ListZones(zoneID ...string) ([]cloudflare.Zone, error) + ListZonesContext(ctx context.Context, opts ...cloudflare.ReqOption) (cloudflare.ZonesResponse, error) DNSRecords(zoneID string, rr cloudflare.DNSRecord) ([]cloudflare.DNSRecord, error) CreateDNSRecord(zoneID string, rr cloudflare.DNSRecord) (*cloudflare.DNSRecordResponse, error) DeleteDNSRecord(zoneID, recordID string) error @@ -89,24 +94,29 @@ func (z zoneService) DeleteDNSRecord(zoneID, recordID string) error { return z.service.DeleteDNSRecord(zoneID, recordID) } +func (z zoneService) ListZonesContext(ctx context.Context, opts ...cloudflare.ReqOption) (cloudflare.ZonesResponse, error) { + return z.service.ListZonesContext(ctx, opts...) +} + // CloudFlareProvider is an implementation of Provider for CloudFlare DNS. type CloudFlareProvider struct { Client cloudFlareDNS // only consider hosted zones managing domains ending in this suffix - domainFilter DomainFilter - zoneIDFilter ZoneIDFilter - proxied bool - DryRun bool + domainFilter DomainFilter + zoneIDFilter ZoneIDFilter + proxiedByDefault bool + DryRun bool + PaginationOptions cloudflare.PaginationOptions } // cloudFlareChange differentiates between ChangActions type cloudFlareChange struct { Action string - ResourceRecordSet cloudflare.DNSRecord + ResourceRecordSet []cloudflare.DNSRecord } // NewCloudFlareProvider initializes a new CloudFlare DNS based Provider. -func NewCloudFlareProvider(domainFilter DomainFilter, zoneIDFilter ZoneIDFilter, proxied bool, dryRun bool) (*CloudFlareProvider, error) { +func NewCloudFlareProvider(domainFilter DomainFilter, zoneIDFilter ZoneIDFilter, zonesPerPage int, proxiedByDefault bool, dryRun bool) (*CloudFlareProvider, error) { // initialize via API email and API key and returns new API object config, err := cloudflare.New(os.Getenv("CF_API_KEY"), os.Getenv("CF_API_EMAIL")) if err != nil { @@ -114,11 +124,15 @@ func NewCloudFlareProvider(domainFilter DomainFilter, zoneIDFilter ZoneIDFilter, } provider := &CloudFlareProvider{ //Client: config, - Client: zoneService{config}, - domainFilter: domainFilter, - zoneIDFilter: zoneIDFilter, - proxied: proxied, - DryRun: dryRun, + Client: zoneService{config}, + domainFilter: domainFilter, + zoneIDFilter: zoneIDFilter, + proxiedByDefault: proxiedByDefault, + DryRun: dryRun, + PaginationOptions: cloudflare.PaginationOptions{ + PerPage: zonesPerPage, + Page: 1, + }, } return provider, nil } @@ -126,24 +140,30 @@ func NewCloudFlareProvider(domainFilter DomainFilter, zoneIDFilter ZoneIDFilter, // Zones returns the list of hosted zones. func (p *CloudFlareProvider) Zones() ([]cloudflare.Zone, error) { result := []cloudflare.Zone{} + ctx := context.TODO() + p.PaginationOptions.Page = 1 - zones, err := p.Client.ListZones() - if err != nil { - return nil, err - } - - for _, zone := range zones { - if !p.domainFilter.Match(zone.Name) { - continue + for { + zonesResponse, err := p.Client.ListZonesContext(ctx, cloudflare.WithPagination(p.PaginationOptions)) + if err != nil { + return nil, err } - if !p.zoneIDFilter.Match(zone.ID) { - continue + for _, zone := range zonesResponse.Result { + if !p.domainFilter.Match(zone.Name) { + continue + } + + if !p.zoneIDFilter.Match(zone.ID) { + continue + } + result = append(result, zone) } - - result = append(result, zone) + if p.PaginationOptions.Page == zonesResponse.ResultInfo.TotalPages { + break + } + p.PaginationOptions.Page++ } - return result, nil } @@ -163,7 +183,7 @@ func (p *CloudFlareProvider) Records() ([]*endpoint.Endpoint, error) { for _, r := range records { if supportedRecordType(r.Type) { - endpoints = append(endpoints, endpoint.NewEndpointWithTTL(r.Name, r.Type, endpoint.TTL(r.TTL), r.Content)) + endpoints = append(endpoints, endpoint.NewEndpointWithTTL(r.Name, r.Type, endpoint.TTL(r.TTL), r.Content).WithProviderSpecific(source.CloudflareProxiedKey, strconv.FormatBool(r.Proxied))) } } } @@ -172,12 +192,14 @@ func (p *CloudFlareProvider) Records() ([]*endpoint.Endpoint, error) { } // ApplyChanges applies a given set of changes in a given zone. -func (p *CloudFlareProvider) ApplyChanges(changes *plan.Changes) error { +func (p *CloudFlareProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { + proxiedByDefault := p.proxiedByDefault + combinedChanges := make([]*cloudFlareChange, 0, len(changes.Create)+len(changes.UpdateNew)+len(changes.Delete)) - combinedChanges = append(combinedChanges, newCloudFlareChanges(cloudFlareCreate, changes.Create, p.proxied)...) - combinedChanges = append(combinedChanges, newCloudFlareChanges(cloudFlareUpdate, changes.UpdateNew, p.proxied)...) - combinedChanges = append(combinedChanges, newCloudFlareChanges(cloudFlareDelete, changes.Delete, p.proxied)...) + combinedChanges = append(combinedChanges, newCloudFlareChanges(cloudFlareCreate, changes.Create, proxiedByDefault)...) + combinedChanges = append(combinedChanges, newCloudFlareChanges(cloudFlareUpdate, changes.UpdateNew, proxiedByDefault)...) + combinedChanges = append(combinedChanges, newCloudFlareChanges(cloudFlareDelete, changes.Delete, proxiedByDefault)...) return p.submitChanges(combinedChanges) } @@ -203,11 +225,12 @@ func (p *CloudFlareProvider) submitChanges(changes []*cloudFlareChange) error { } for _, change := range changes { logFields := log.Fields{ - "record": change.ResourceRecordSet.Name, - "type": change.ResourceRecordSet.Type, - "ttl": change.ResourceRecordSet.TTL, - "action": change.Action, - "zone": zoneID, + "record": change.ResourceRecordSet[0].Name, + "type": change.ResourceRecordSet[0].Type, + "ttl": change.ResourceRecordSet[0].TTL, + "targets": len(change.ResourceRecordSet), + "action": change.Action, + "zone": zoneID, } log.WithFields(logFields).Info("Changing record.") @@ -215,22 +238,25 @@ func (p *CloudFlareProvider) submitChanges(changes []*cloudFlareChange) error { if p.DryRun { continue } - recordID := p.getRecordID(records, change.ResourceRecordSet) - switch change.Action { - case cloudFlareCreate: - _, err := p.Client.CreateDNSRecord(zoneID, change.ResourceRecordSet) - if err != nil { - log.WithFields(logFields).Errorf("failed to create record: %v", err) + + recordIDs := p.getRecordIDs(records, change.ResourceRecordSet[0]) + + // to simplify bookkeeping for multiple records, an update is executed as delete+create + if change.Action == cloudFlareDelete || change.Action == cloudFlareUpdate { + for _, recordID := range recordIDs { + err := p.Client.DeleteDNSRecord(zoneID, recordID) + if err != nil { + log.WithFields(logFields).Errorf("failed to delete record: %v", err) + } } - case cloudFlareDelete: - err := p.Client.DeleteDNSRecord(zoneID, recordID) - if err != nil { - log.WithFields(logFields).Errorf("failed to delete record: %v", err) - } - case cloudFlareUpdate: - err := p.Client.UpdateDNSRecord(zoneID, recordID, change.ResourceRecordSet) - if err != nil { - log.WithFields(logFields).Errorf("failed to update record: %v", err) + } + + if change.Action == cloudFlareCreate || change.Action == cloudFlareUpdate { + for _, record := range change.ResourceRecordSet { + _, err := p.Client.CreateDNSRecord(zoneID, record) + if err != nil { + log.WithFields(logFields).Errorf("failed to create record: %v", err) + } } } } @@ -249,9 +275,9 @@ func (p *CloudFlareProvider) changesByZone(zones []cloudflare.Zone, changeSet [] } for _, c := range changeSet { - zoneID, _ := zoneNameIDMapper.FindZone(c.ResourceRecordSet.Name) + zoneID, _ := zoneNameIDMapper.FindZone(c.ResourceRecordSet[0].Name) if zoneID == "" { - log.Debugf("Skipping record %s because no hosted zone matching record DNS Name was detected ", c.ResourceRecordSet.Name) + log.Debugf("Skipping record %s because no hosted zone matching record DNS Name was detected ", c.ResourceRecordSet[0].Name) continue } changes[zoneID] = append(changes[zoneID], c) @@ -260,43 +286,71 @@ func (p *CloudFlareProvider) changesByZone(zones []cloudflare.Zone, changeSet [] return changes } -func (p *CloudFlareProvider) getRecordID(records []cloudflare.DNSRecord, record cloudflare.DNSRecord) string { +func (p *CloudFlareProvider) getRecordIDs(records []cloudflare.DNSRecord, record cloudflare.DNSRecord) []string { + recordIDs := make([]string, 0) for _, zoneRecord := range records { if zoneRecord.Name == record.Name && zoneRecord.Type == record.Type { - return zoneRecord.ID + recordIDs = append(recordIDs, zoneRecord.ID) } } - return "" + sort.Strings(recordIDs) + return recordIDs } // newCloudFlareChanges returns a collection of Changes based on the given records and action. -func newCloudFlareChanges(action string, endpoints []*endpoint.Endpoint, proxied bool) []*cloudFlareChange { +func newCloudFlareChanges(action string, endpoints []*endpoint.Endpoint, proxiedByDefault bool) []*cloudFlareChange { changes := make([]*cloudFlareChange, 0, len(endpoints)) for _, endpoint := range endpoints { - changes = append(changes, newCloudFlareChange(action, endpoint, proxied)) + changes = append(changes, newCloudFlareChange(action, endpoint, proxiedByDefault)) } return changes } -func newCloudFlareChange(action string, endpoint *endpoint.Endpoint, proxied bool) *cloudFlareChange { +func newCloudFlareChange(action string, endpoint *endpoint.Endpoint, proxiedByDefault bool) *cloudFlareChange { ttl := defaultCloudFlareRecordTTL - if proxied && (cloudFlareTypeNotSupported[endpoint.RecordType] || strings.Contains(endpoint.DNSName, "*")) { - proxied = false - } + proxied := shouldBeProxied(endpoint, proxiedByDefault) + if endpoint.RecordTTL.IsConfigured() { ttl = int(endpoint.RecordTTL) } - return &cloudFlareChange{ - Action: action, - ResourceRecordSet: cloudflare.DNSRecord{ + resourceRecordSet := make([]cloudflare.DNSRecord, len(endpoint.Targets)) + + for i := range endpoint.Targets { + resourceRecordSet[i] = cloudflare.DNSRecord{ Name: endpoint.DNSName, TTL: ttl, Proxied: proxied, Type: endpoint.RecordType, - Content: endpoint.Targets[0], - }, + Content: endpoint.Targets[i], + } + } + + return &cloudFlareChange{ + Action: action, + ResourceRecordSet: resourceRecordSet, } } + +func shouldBeProxied(endpoint *endpoint.Endpoint, proxiedByDefault bool) bool { + proxied := proxiedByDefault + + for _, v := range endpoint.ProviderSpecific { + if v.Name == source.CloudflareProxiedKey { + b, err := strconv.ParseBool(v.Value) + if err != nil { + log.Errorf("Failed to parse annotation [%s]: %v", source.CloudflareProxiedKey, err) + } else { + proxied = b + } + break + } + } + + if cloudFlareTypeNotSupported[endpoint.RecordType] || strings.Contains(endpoint.DNSName, "*") { + proxied = false + } + return proxied +} diff --git a/provider/cloudflare_test.go b/provider/cloudflare_test.go index cfa54ca21..73e32d59a 100644 --- a/provider/cloudflare_test.go +++ b/provider/cloudflare_test.go @@ -17,15 +17,14 @@ limitations under the License. package provider import ( + "context" "fmt" "os" "testing" + cloudflare "github.com/cloudflare/cloudflare-go" "github.com/kubernetes-incubator/external-dns/endpoint" "github.com/kubernetes-incubator/external-dns/plan" - - cloudflare "github.com/cloudflare/cloudflare-go" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -66,6 +65,18 @@ func (m *mockCloudFlareClient) ListZones(zoneID ...string) ([]cloudflare.Zone, e return []cloudflare.Zone{{ID: "1234567890", Name: "ext-dns-test.zalando.to."}, {ID: "1234567891", Name: "foo.com."}}, nil } +func (m *mockCloudFlareClient) ListZonesContext(ctx context.Context, opts ...cloudflare.ReqOption) (cloudflare.ZonesResponse, error) { + return cloudflare.ZonesResponse{ + Result: []cloudflare.Zone{ + {ID: "1234567890", Name: "ext-dns-test.zalando.to."}, + {ID: "1234567891", Name: "foo.com."}}, + ResultInfo: cloudflare.ResultInfo{ + Page: 1, + TotalPages: 1, + }, + }, nil +} + type mockCloudFlareUserDetailsFail struct{} func (m *mockCloudFlareUserDetailsFail) CreateDNSRecord(zoneID string, rr cloudflare.DNSRecord) (*cloudflare.DNSRecordResponse, error) { @@ -96,6 +107,10 @@ func (m *mockCloudFlareUserDetailsFail) ListZones(zoneID ...string) ([]cloudflar return []cloudflare.Zone{{Name: "ext-dns-test.zalando.to."}}, nil } +func (m *mockCloudFlareUserDetailsFail) ListZonesContext(ctx context.Context, opts ...cloudflare.ReqOption) (cloudflare.ZonesResponse, error) { + return cloudflare.ZonesResponse{}, nil +} + type mockCloudFlareCreateZoneFail struct{} func (m *mockCloudFlareCreateZoneFail) CreateDNSRecord(zoneID string, rr cloudflare.DNSRecord) (*cloudflare.DNSRecordResponse, error) { @@ -155,6 +170,17 @@ func (m *mockCloudFlareDNSRecordsFail) ListZones(zoneID ...string) ([]cloudflare return []cloudflare.Zone{{Name: "ext-dns-test.zalando.to."}}, nil } +func (m *mockCloudFlareDNSRecordsFail) ListZonesContext(ctx context.Context, opts ...cloudflare.ReqOption) (cloudflare.ZonesResponse, error) { + return cloudflare.ZonesResponse{ + Result: []cloudflare.Zone{ + {ID: "1234567890", Name: "ext-dns-test.zalando.to."}, + {ID: "1234567891", Name: "foo.com."}}, + ResultInfo: cloudflare.ResultInfo{ + TotalPages: 1, + }, + }, nil +} + type mockCloudFlareZoneIDByNameFail struct{} func (m *mockCloudFlareZoneIDByNameFail) CreateDNSRecord(zoneID string, rr cloudflare.DNSRecord) (*cloudflare.DNSRecordResponse, error) { @@ -245,6 +271,10 @@ func (m *mockCloudFlareListZonesFail) ListZones(zoneID ...string) ([]cloudflare. return []cloudflare.Zone{{}}, fmt.Errorf("no zones available") } +func (m *mockCloudFlareListZonesFail) ListZonesContext(ctx context.Context, opts ...cloudflare.ReqOption) (cloudflare.ZonesResponse, error) { + return cloudflare.ZonesResponse{}, fmt.Errorf("no zones available") +} + type mockCloudFlareCreateRecordsFail struct{} func (m *mockCloudFlareCreateRecordsFail) CreateDNSRecord(zoneID string, rr cloudflare.DNSRecord) (*cloudflare.DNSRecordResponse, error) { @@ -275,6 +305,10 @@ func (m *mockCloudFlareCreateRecordsFail) ListZones(zoneID ...string) ([]cloudfl return []cloudflare.Zone{{}}, fmt.Errorf("no zones available") } +func (m *mockCloudFlareCreateRecordsFail) ListZonesContext(ctx context.Context, opts ...cloudflare.ReqOption) (cloudflare.ZonesResponse, error) { + return cloudflare.ZonesResponse{}, nil +} + type mockCloudFlareDeleteRecordsFail struct{} func (m *mockCloudFlareDeleteRecordsFail) CreateDNSRecord(zoneID string, rr cloudflare.DNSRecord) (*cloudflare.DNSRecordResponse, error) { @@ -305,6 +339,10 @@ func (m *mockCloudFlareDeleteRecordsFail) ListZones(zoneID ...string) ([]cloudfl return []cloudflare.Zone{{Name: "ext-dns-test.zalando.to."}}, nil } +func (m *mockCloudFlareDeleteRecordsFail) ListZonesContext(ctx context.Context, opts ...cloudflare.ReqOption) (cloudflare.ZonesResponse, error) { + return cloudflare.ZonesResponse{}, nil +} + type mockCloudFlareUpdateRecordsFail struct{} func (m *mockCloudFlareUpdateRecordsFail) CreateDNSRecord(zoneID string, rr cloudflare.DNSRecord) (*cloudflare.DNSRecordResponse, error) { @@ -335,6 +373,10 @@ func (m *mockCloudFlareUpdateRecordsFail) ListZones(zoneID ...string) ([]cloudfl return []cloudflare.Zone{{Name: "ext-dns-test.zalando.to."}}, nil } +func (m *mockCloudFlareUpdateRecordsFail) ListZonesContext(ctx context.Context, opts ...cloudflare.ReqOption) (cloudflare.ZonesResponse, error) { + return cloudflare.ZonesResponse{}, nil +} + func TestNewCloudFlareChanges(t *testing.T) { expect := []struct { Name string @@ -357,7 +399,7 @@ func TestNewCloudFlareChanges(t *testing.T) { for i, change := range changes { assert.Equal( t, - change.ResourceRecordSet.TTL, + change.ResourceRecordSet[0].TTL, expect[i].TTL, expect[i].Name) } @@ -365,7 +407,37 @@ func TestNewCloudFlareChanges(t *testing.T) { func TestNewCloudFlareChangeNoProxied(t *testing.T) { change := newCloudFlareChange(cloudFlareCreate, &endpoint.Endpoint{DNSName: "new", RecordType: "A", Targets: endpoint.Targets{"target"}}, false) - assert.False(t, change.ResourceRecordSet.Proxied) + assert.False(t, change.ResourceRecordSet[0].Proxied) +} + +func TestNewCloudFlareProxiedAnnotationTrue(t *testing.T) { + change := newCloudFlareChange(cloudFlareCreate, &endpoint.Endpoint{DNSName: "new", RecordType: "A", Targets: endpoint.Targets{"target"}, ProviderSpecific: endpoint.ProviderSpecific{ + endpoint.ProviderSpecificProperty{ + Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied", + Value: "true", + }, + }}, false) + assert.True(t, change.ResourceRecordSet[0].Proxied) +} + +func TestNewCloudFlareProxiedAnnotationFalse(t *testing.T) { + change := newCloudFlareChange(cloudFlareCreate, &endpoint.Endpoint{DNSName: "new", RecordType: "A", Targets: endpoint.Targets{"target"}, ProviderSpecific: endpoint.ProviderSpecific{ + endpoint.ProviderSpecificProperty{ + Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied", + Value: "false", + }, + }}, true) + assert.False(t, change.ResourceRecordSet[0].Proxied) +} + +func TestNewCloudFlareProxiedAnnotationIllegalValue(t *testing.T) { + change := newCloudFlareChange(cloudFlareCreate, &endpoint.Endpoint{DNSName: "new", RecordType: "A", Targets: endpoint.Targets{"target"}, ProviderSpecific: endpoint.ProviderSpecific{ + endpoint.ProviderSpecificProperty{ + Name: "external-dns.alpha.kubernetes.io/cloudflare-proxied", + Value: "asdaslkjndaslkdjals", + }, + }}, false) + assert.False(t, change.ResourceRecordSet[0].Proxied) } func TestNewCloudFlareChangeProxiable(t *testing.T) { @@ -387,14 +459,14 @@ func TestNewCloudFlareChangeProxiable(t *testing.T) { change := newCloudFlareChange(cloudFlareCreate, &endpoint.Endpoint{DNSName: "new", RecordType: cloudFlareType.recordType, Targets: endpoint.Targets{"target"}}, true) if cloudFlareType.proxiable { - assert.True(t, change.ResourceRecordSet.Proxied) + assert.True(t, change.ResourceRecordSet[0].Proxied) } else { - assert.False(t, change.ResourceRecordSet.Proxied) + assert.False(t, change.ResourceRecordSet[0].Proxied) } } change := newCloudFlareChange(cloudFlareCreate, &endpoint.Endpoint{DNSName: "*.foo", RecordType: "A", Targets: endpoint.Targets{"target"}}, true) - assert.False(t, change.ResourceRecordSet.Proxied) + assert.False(t, change.ResourceRecordSet[0].Proxied) } func TestCloudFlareZones(t *testing.T) { @@ -439,13 +511,23 @@ func TestRecords(t *testing.T) { func TestNewCloudFlareProvider(t *testing.T) { _ = os.Setenv("CF_API_KEY", "xxxxxxxxxxxxxxxxx") _ = os.Setenv("CF_API_EMAIL", "test@test.com") - _, err := NewCloudFlareProvider(NewDomainFilter([]string{"ext-dns-test.zalando.to."}), NewZoneIDFilter([]string{""}), false, true) + _, err := NewCloudFlareProvider( + NewDomainFilter([]string{"ext-dns-test.zalando.to."}), + NewZoneIDFilter([]string{""}), + 1, + false, + true) if err != nil { t.Errorf("should not fail, %s", err) } _ = os.Unsetenv("CF_API_KEY") _ = os.Unsetenv("CF_API_EMAIL") - _, err = NewCloudFlareProvider(NewDomainFilter([]string{"ext-dns-test.zalando.to."}), NewZoneIDFilter([]string{""}), false, true) + _, err = NewCloudFlareProvider( + NewDomainFilter([]string{"ext-dns-test.zalando.to."}), + NewZoneIDFilter([]string{""}), + 50, + false, + true) if err == nil { t.Errorf("expected to fail") } @@ -460,7 +542,7 @@ func TestApplyChanges(t *testing.T) { changes.Delete = []*endpoint.Endpoint{{DNSName: "foobar.ext-dns-test.zalando.to.", Targets: endpoint.Targets{"target"}}} changes.UpdateOld = []*endpoint.Endpoint{{DNSName: "foobar.ext-dns-test.zalando.to.", Targets: endpoint.Targets{"target-old"}}} changes.UpdateNew = []*endpoint.Endpoint{{DNSName: "foobar.ext-dns-test.zalando.to.", Targets: endpoint.Targets{"target-new"}}} - err := provider.ApplyChanges(changes) + err := provider.ApplyChanges(context.Background(), changes) if err != nil { t.Errorf("should not fail, %s", err) } @@ -471,7 +553,7 @@ func TestApplyChanges(t *testing.T) { changes.UpdateOld = []*endpoint.Endpoint{} changes.UpdateNew = []*endpoint.Endpoint{} - err = provider.ApplyChanges(changes) + err = provider.ApplyChanges(context.Background(), changes) if err != nil { t.Errorf("should not fail, %s", err) } @@ -492,14 +574,18 @@ func TestCloudFlareGetRecordID(t *testing.T) { }, } - assert.Equal(t, "", p.getRecordID(records, cloudflare.DNSRecord{ + assert.Len(t, p.getRecordIDs(records, cloudflare.DNSRecord{ Name: "foo.com", Type: endpoint.RecordTypeA, - })) - assert.Equal(t, "2", p.getRecordID(records, cloudflare.DNSRecord{ + }), 0) + assert.Len(t, p.getRecordIDs(records, cloudflare.DNSRecord{ Name: "bar.de", Type: endpoint.RecordTypeA, - })) + }), 1) + assert.Equal(t, "2", p.getRecordIDs(records, cloudflare.DNSRecord{ + Name: "bar.de", + Type: endpoint.RecordTypeA, + })[0]) } func validateCloudFlareZones(t *testing.T, zones []cloudflare.Zone, expected []cloudflare.Zone) { diff --git a/provider/coredns.go b/provider/coredns.go index 38403ae61..bd09f40a1 100644 --- a/provider/coredns.go +++ b/provider/coredns.go @@ -153,7 +153,7 @@ func (c etcdClient) DeleteService(key string) error { ctx, cancel := context.WithTimeout(c.ctx, etcdTimeout) defer cancel() - _, err := c.client.Delete(ctx, key) + _, err := c.client.Delete(ctx, key, etcdcv3.WithPrefix()) return err } @@ -298,7 +298,7 @@ func (p coreDNSProvider) Records() ([]*endpoint.Endpoint, error) { } // ApplyChanges stores changes back to etcd converting them to CoreDNS format and aggregating A/CNAME and TXT records -func (p coreDNSProvider) ApplyChanges(changes *plan.Changes) error { +func (p coreDNSProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { grouped := map[string][]*endpoint.Endpoint{} for _, ep := range changes.Create { grouped[ep.DNSName] = append(grouped[ep.DNSName], ep) @@ -317,22 +317,26 @@ func (p coreDNSProvider) ApplyChanges(changes *plan.Changes) error { if ep.RecordType == endpoint.RecordTypeTXT { continue } - prefix := ep.Labels[randomPrefixLabel] - if prefix == "" { - prefix = fmt.Sprintf("%08x", rand.Int31()) + + for _, target := range ep.Targets { + prefix := ep.Labels[randomPrefixLabel] + if prefix == "" { + prefix = fmt.Sprintf("%08x", rand.Int31()) + } + + service := Service{ + Host: target, + Text: ep.Labels["originalText"], + Key: etcdKeyFor(prefix + "." + dnsName), + TargetStrip: strings.Count(prefix, ".") + 1, + TTL: uint32(ep.RecordTTL), + } + services = append(services, service) } - service := Service{ - Host: ep.Targets[0], - Text: ep.Labels["originalText"], - Key: etcdKeyFor(prefix + "." + dnsName), - TargetStrip: strings.Count(prefix, ".") + 1, - TTL: uint32(ep.RecordTTL), - } - services = append(services, service) } index := 0 for _, ep := range group { - if ep.RecordType != "TXT" { + if ep.RecordType != endpoint.RecordTypeTXT { continue } if index >= len(services) { diff --git a/provider/coredns_test.go b/provider/coredns_test.go index 147711743..698207a0d 100644 --- a/provider/coredns_test.go +++ b/provider/coredns_test.go @@ -17,6 +17,7 @@ limitations under the License. package provider import ( + "context" "strings" "testing" @@ -227,7 +228,7 @@ func TestCoreDNSApplyChanges(t *testing.T) { endpoint.NewEndpoint("domain2.local", endpoint.RecordTypeCNAME, "site.local"), }, } - coredns.ApplyChanges(changes1) + coredns.ApplyChanges(context.Background(), changes1) expectedServices1 := map[string]*Service{ "/skydns/local/domain1": {Host: "5.5.5.5", Text: "string1"}, @@ -285,7 +286,7 @@ func applyServiceChanges(provider coreDNSProvider, changes *plan.Changes) { } } } - provider.ApplyChanges(changes) + provider.ApplyChanges(context.Background(), changes) } func validateServices(services, expectedServices map[string]*Service, t *testing.T, step int) { diff --git a/provider/designate.go b/provider/designate.go index 48e8ce8a9..50ac157b7 100644 --- a/provider/designate.go +++ b/provider/designate.go @@ -17,6 +17,7 @@ limitations under the License. package provider import ( + "context" "fmt" "net" "net/http" @@ -379,7 +380,7 @@ func addEndpoint(ep *endpoint.Endpoint, recordSets map[string]*recordSet, delete } // ApplyChanges applies a given set of changes in a given zone. -func (p designateProvider) ApplyChanges(changes *plan.Changes) error { +func (p designateProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { managedZones, err := p.getZones() if err != nil { return err diff --git a/provider/designate_test.go b/provider/designate_test.go index baba6ee35..3753ed303 100644 --- a/provider/designate_test.go +++ b/provider/designate_test.go @@ -17,6 +17,7 @@ limitations under the License. package provider import ( + "context" "encoding/pem" "fmt" "io/ioutil" @@ -110,7 +111,9 @@ func (c fakeDesignateClient) UpdateRecordSet(zoneID, recordSetID string, opts re if rs == nil { return fmt.Errorf("unknown record-set %s", recordSetID) } - rs.Description = opts.Description + if opts.Description != nil { + rs.Description = *opts.Description + } rs.TTL = opts.TTL rs.Records = opts.Records return nil @@ -405,7 +408,7 @@ func testDesignateCreateRecords(t *testing.T, client *fakeDesignateClient) []*re expectedCopy := make([]*recordsets.RecordSet, len(expected)) copy(expectedCopy, expected) - err := client.ToProvider().ApplyChanges(&plan.Changes{Create: endpoints}) + err := client.ToProvider().ApplyChanges(context.Background(), &plan.Changes{Create: endpoints}) if err != nil { t.Fatal(err) } @@ -493,7 +496,7 @@ func testDesignateUpdateRecords(t *testing.T, client *fakeDesignateClient) []*re expected[2].Records = []string{"10.3.3.1"} expected[3].Records = []string{"10.2.1.1", "10.3.3.2"} - err := client.ToProvider().ApplyChanges(&plan.Changes{UpdateOld: updatesOld, UpdateNew: updatesNew}) + err := client.ToProvider().ApplyChanges(context.Background(), &plan.Changes{UpdateOld: updatesOld, UpdateNew: updatesNew}) if err != nil { t.Fatal(err) } @@ -551,7 +554,7 @@ func testDesignateDeleteRecords(t *testing.T, client *fakeDesignateClient) { expected[3].Records = []string{"10.3.3.2"} expected = expected[1:] - err := client.ToProvider().ApplyChanges(&plan.Changes{Delete: deletes}) + err := client.ToProvider().ApplyChanges(context.Background(), &plan.Changes{Delete: deletes}) if err != nil { t.Fatal(err) } diff --git a/provider/digital_ocean.go b/provider/digital_ocean.go index 590fa5e4b..00daf60fd 100644 --- a/provider/digital_ocean.go +++ b/provider/digital_ocean.go @@ -17,6 +17,7 @@ limitations under the License. package provider import ( + goctx "context" "fmt" "os" "strings" @@ -261,7 +262,7 @@ func (p *DigitalOceanProvider) submitChanges(changes []*DigitalOceanChange) erro } // ApplyChanges applies a given set of changes in a given zone. -func (p *DigitalOceanProvider) ApplyChanges(changes *plan.Changes) error { +func (p *DigitalOceanProvider) ApplyChanges(ctx goctx.Context, changes *plan.Changes) error { combinedChanges := make([]*DigitalOceanChange, 0, len(changes.Create)+len(changes.UpdateNew)+len(changes.Delete)) combinedChanges = append(combinedChanges, newDigitalOceanChanges(DigitalOceanCreate, changes.Create)...) diff --git a/provider/digital_ocean_test.go b/provider/digital_ocean_test.go index 1ce4a6dac..ab28da215 100644 --- a/provider/digital_ocean_test.go +++ b/provider/digital_ocean_test.go @@ -438,7 +438,7 @@ func TestDigitalOceanApplyChanges(t *testing.T) { changes.Delete = []*endpoint.Endpoint{{DNSName: "foobar.ext-dns-test.bar.com", Targets: endpoint.Targets{"target"}}} changes.UpdateOld = []*endpoint.Endpoint{{DNSName: "foobar.ext-dns-test.bar.de", Targets: endpoint.Targets{"target-old"}}} changes.UpdateNew = []*endpoint.Endpoint{{DNSName: "foobar.ext-dns-test.foo.com", Targets: endpoint.Targets{"target-new"}, RecordType: "CNAME", RecordTTL: 100}} - err := provider.ApplyChanges(changes) + err := provider.ApplyChanges(context.Background(), changes) if err != nil { t.Errorf("should not fail, %s", err) } diff --git a/provider/dnsimple.go b/provider/dnsimple.go index d7c7c7606..2bccfe435 100644 --- a/provider/dnsimple.go +++ b/provider/dnsimple.go @@ -17,6 +17,7 @@ limitations under the License. package provider import ( + "context" "fmt" "os" "strconv" @@ -176,7 +177,13 @@ func (p *dnsimpleProvider) Records() (endpoints []*endpoint.Endpoint, _ error) { default: continue } - endpoints = append(endpoints, endpoint.NewEndpointWithTTL(record.Name+"."+record.ZoneID, record.Type, endpoint.TTL(record.TTL), record.Content)) + // Apex records have an empty string for their name. + // Consider this when creating the endpoint dnsName + dnsName := fmt.Sprintf("%s.%s", record.Name, record.ZoneID) + if record.Name == "" { + dnsName = record.ZoneID + } + endpoints = append(endpoints, endpoint.NewEndpointWithTTL(dnsName, record.Type, endpoint.TTL(record.TTL), record.Content)) } page++ if page > records.Pagination.TotalPages { @@ -234,7 +241,12 @@ func (p *dnsimpleProvider) submitChanges(changes []*dnsimpleChange) error { log.Infof("Changing records: %s %v in zone: %s", change.Action, change.ResourceRecordSet, zone.Name) - change.ResourceRecordSet.Name = strings.TrimSuffix(change.ResourceRecordSet.Name, "."+zone.Name) + if change.ResourceRecordSet.Name == zone.Name { + change.ResourceRecordSet.Name = "" // Apex records have an empty name + } else { + change.ResourceRecordSet.Name = strings.TrimSuffix(change.ResourceRecordSet.Name, fmt.Sprintf(".%s", zone.Name)) + } + if !p.dryRun { switch change.Action { case dnsimpleCreate: @@ -321,7 +333,7 @@ func (p *dnsimpleProvider) UpdateRecords(endpoints []*endpoint.Endpoint) error { } // ApplyChanges applies a given set of changes -func (p *dnsimpleProvider) ApplyChanges(changes *plan.Changes) error { +func (p *dnsimpleProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { combinedChanges := make([]*dnsimpleChange, 0, len(changes.Create)+len(changes.UpdateNew)+len(changes.Delete)) combinedChanges = append(combinedChanges, newDnsimpleChanges(dnsimpleCreate, changes.Create)...) diff --git a/provider/dnsimple_test.go b/provider/dnsimple_test.go index 78cf3658c..1f30da028 100644 --- a/provider/dnsimple_test.go +++ b/provider/dnsimple_test.go @@ -17,6 +17,7 @@ limitations under the License. package provider import ( + "context" "fmt" "os" "testing" @@ -84,8 +85,18 @@ func TestDnsimpleServices(t *testing.T) { Priority: 0, Type: "CNAME", } + fourthRecord := dnsimple.ZoneRecord{ + ID: 4, + ZoneID: "example.com", + ParentID: 0, + Name: "", // Apex domain A record + Content: "127.0.0.1", + TTL: 3600, + Priority: 0, + Type: "A", + } - records := []dnsimple.ZoneRecord{firstRecord, secondRecord, thirdRecord} + records := []dnsimple.ZoneRecord{firstRecord, secondRecord, thirdRecord, fourthRecord} dnsimpleListRecordsResponse = dnsimple.ZoneRecordsResponse{ Response: dnsimple.Response{Pagination: &dnsimple.Pagination{}}, Data: records, @@ -115,7 +126,6 @@ func TestDnsimpleServices(t *testing.T) { mockDNS.On("CreateRecord", "1", record.ZoneID, simpleRecord).Return(&dnsimple.ZoneRecordResponse{}, nil) mockDNS.On("DeleteRecord", "1", record.ZoneID, record.ID).Return(&dnsimple.ZoneRecordResponse{}, nil) mockDNS.On("UpdateRecord", "1", record.ZoneID, record.ID, simpleRecord).Return(&dnsimple.ZoneRecordResponse{}, nil) - mockDNS.On("UpdateRecord", "1", record.ZoneID, record.ID, simpleRecord).Return(&dnsimple.ZoneRecordResponse{}, nil) } mockProvider = dnsimpleProvider{client: mockDNS} @@ -136,7 +146,7 @@ func testDnsimpleProviderZones(t *testing.T) { validateDnsimpleZones(t, result, dnsimpleListZonesResponse.Data) mockProvider.accountID = "2" - result, err = mockProvider.Zones() + _, err = mockProvider.Zones() assert.NotNil(t, err) } @@ -147,7 +157,7 @@ func testDnsimpleProviderRecords(t *testing.T) { assert.Equal(t, len(dnsimpleListRecordsResponse.Data), len(result)) mockProvider.accountID = "2" - result, err = mockProvider.Records() + _, err = mockProvider.Records() assert.NotNil(t, err) } func testDnsimpleProviderApplyChanges(t *testing.T) { @@ -157,10 +167,13 @@ func testDnsimpleProviderApplyChanges(t *testing.T) { {DNSName: "custom-ttl.example.com", RecordTTL: 60, Targets: endpoint.Targets{"target"}, RecordType: endpoint.RecordTypeCNAME}, } changes.Delete = []*endpoint.Endpoint{{DNSName: "example-beta.example.com", Targets: endpoint.Targets{"127.0.0.1"}, RecordType: endpoint.RecordTypeA}} - changes.UpdateNew = []*endpoint.Endpoint{{DNSName: "example.example.com", Targets: endpoint.Targets{"target"}, RecordType: endpoint.RecordTypeCNAME}} + changes.UpdateNew = []*endpoint.Endpoint{ + {DNSName: "example.example.com", Targets: endpoint.Targets{"target"}, RecordType: endpoint.RecordTypeCNAME}, + {DNSName: "example.com", Targets: endpoint.Targets{"127.0.0.1"}, RecordType: endpoint.RecordTypeA}, + } mockProvider.accountID = "1" - err := mockProvider.ApplyChanges(changes) + err := mockProvider.ApplyChanges(context.Background(), changes) if err != nil { t.Errorf("Failed to apply changes: %v", err) } @@ -173,7 +186,7 @@ func testDnsimpleProviderApplyChangesSkipsUnknown(t *testing.T) { } mockProvider.accountID = "1" - err := mockProvider.ApplyChanges(changes) + err := mockProvider.ApplyChanges(context.Background(), changes) if err != nil { t.Errorf("Failed to ignore unknown zones: %v", err) } diff --git a/provider/dyn.go b/provider/dyn.go index 36eefdbfb..52f47ac34 100644 --- a/provider/dyn.go +++ b/provider/dyn.go @@ -17,6 +17,7 @@ limitations under the License. package provider import ( + "context" "fmt" "os" "strconv" @@ -26,6 +27,7 @@ import ( log "github.com/sirupsen/logrus" "github.com/nesv/go-dynect/dynect" + "github.com/sanyu/dynectsoap/dynectsoap" "github.com/kubernetes-incubator/external-dns/endpoint" "github.com/kubernetes-incubator/external-dns/plan" @@ -34,9 +36,6 @@ import ( const ( // 10 minutes default timeout if not configured using flags dynDefaultTTL = 600 - // can store 20000 entries globally, that's about 4MB of memory - // may be made configurable in the future but 20K records seems like enough for a few zones - cacheMaxSize = 20000 // when rate limit is hit retry up to 5 times after sleep 1m between retries dynMaxRetriesOnErrRateLimited = 5 @@ -51,50 +50,10 @@ const ( restAPIPrefix = "/REST/" ) -// A simple non-thread-safe cache with TTL. The TTL of the records is used here to -// This cache is used to save on requests to DynAPI -type cache struct { - contents map[string]*entry -} - -type entry struct { - expires int64 - ep *endpoint.Endpoint -} - -func (c *cache) Put(link string, ep *endpoint.Endpoint) { - // flush the whole cache on overflow - if len(c.contents) >= cacheMaxSize { - log.Debugf("Flushing cache") - c.contents = make(map[string]*entry) - } - - c.contents[link] = &entry{ - ep: ep, - expires: unixNow() + int64(ep.RecordTTL), - } -} - func unixNow() int64 { return int64(time.Now().Unix()) } -func (c *cache) Get(link string) *endpoint.Endpoint { - result, ok := c.contents[link] - if !ok { - return nil - } - - now := unixNow() - - if result.expires < now { - delete(c.contents, link) - return nil - } - - return result.ep -} - // DynConfig hold connection parameters to dyn.com and internal state type DynConfig struct { DomainFilter DomainFilter @@ -145,7 +104,6 @@ func (snap *ZoneSnapshot) StoreRecordsForSerial(zone string, serial int, records // DynProvider is the actual interface impl. type dynProviderState struct { DynConfig - Cache *cache LastLoginErrorTime int64 ZoneSnapshot *ZoneSnapshot @@ -186,9 +144,6 @@ type ZonePublishResponse struct { func NewDynProvider(config DynConfig) (Provider, error) { return &dynProviderState{ DynConfig: config, - Cache: &cache{ - contents: make(map[string]*entry), - }, ZoneSnapshot: &ZoneSnapshot{ endpoints: map[string][]*endpoint.Endpoint{}, serials: map[string]int{}, @@ -277,27 +232,6 @@ func merge(updateOld, updateNew []*endpoint.Endpoint) []*endpoint.Endpoint { return result } -// extractTarget populates the correct field given a record type. -// See dynect.DataBlock comments for details. Empty response means nothing -// was populated - basically an error -func extractTarget(recType string, data *dynect.DataBlock) string { - result := "" - if recType == endpoint.RecordTypeA { - result = data.Address - } - - if recType == endpoint.RecordTypeCNAME { - result = data.CName - result = strings.TrimSuffix(result, ".") - } - - if recType == endpoint.RecordTypeTXT { - result = data.TxtData - } - - return result -} - func apiRetryLoop(f func() error) error { var err error for i := 0; i < dynMaxRetriesOnErrRateLimited; i++ { @@ -315,40 +249,48 @@ func apiRetryLoop(f func() error) error { return err } -// recordLinkToEndpoint makes an Endpoint given a resource link optinally making a remote call if a cached entry is expired -func (d *dynProviderState) recordLinkToEndpoint(client *dynect.Client, recordLink string) (*endpoint.Endpoint, error) { - result := d.Cache.Get(recordLink) - if result != nil { - log.Infof("Using cached endpoint for %s: %+v", recordLink, result) - return result, nil +func (d *dynProviderState) allRecordsToEndpoints(records *dynectsoap.GetAllRecordsResponseType) []*endpoint.Endpoint { + result := []*endpoint.Endpoint{} + //Convert each record to an endpoint + + //Process A Records + for _, rec := range records.Data.A_records { + ep := &endpoint.Endpoint{ + DNSName: rec.Fqdn, + RecordTTL: endpoint.TTL(rec.Ttl), + RecordType: rec.Record_type, + Targets: endpoint.Targets{rec.Rdata.Address}, + } + log.Debugf("A record: %v", *ep) + result = append(result, ep) } - rec := dynect.RecordResponse{} - - err := apiRetryLoop(func() error { - return client.Do("GET", recordLink, nil, &rec) - }) - - if err != nil { - return nil, err + //Process CNAME Records + for _, rec := range records.Data.Cname_records { + ep := &endpoint.Endpoint{ + DNSName: rec.Fqdn, + RecordTTL: endpoint.TTL(rec.Ttl), + RecordType: rec.Record_type, + Targets: endpoint.Targets{strings.TrimSuffix(rec.Rdata.Cname, ".")}, + } + log.Debugf("CNAME record: %v", *ep) + result = append(result, ep) } - // ignore all records but the types supported by external- - target := extractTarget(rec.Data.RecordType, &rec.Data.RData) - if target == "" { - return nil, nil + //Process TXT Records + for _, rec := range records.Data.Txt_records { + ep := &endpoint.Endpoint{ + DNSName: rec.Fqdn, + RecordTTL: endpoint.TTL(rec.Ttl), + RecordType: rec.Record_type, + Targets: endpoint.Targets{rec.Rdata.Txtdata}, + } + log.Debugf("TXT record: %v", *ep) + result = append(result, ep) } - result = &endpoint.Endpoint{ - DNSName: rec.Data.FQDN, - RecordTTL: endpoint.TTL(rec.Data.TTL), - RecordType: rec.Data.RecordType, - Targets: endpoint.Targets{target}, - } + return result - log.Debugf("Fetched new endpoint for %s: %+v", recordLink, result) - d.Cache.Put(recordLink, result) - return result, nil } func errorOrValue(err error, value interface{}) interface{} { @@ -387,6 +329,72 @@ func (d *dynProviderState) fetchZoneSerial(client *dynect.Client, zone string) ( return resp.Data.Serial, nil } +//Use SOAP to fetch all records with a single call +func (d *dynProviderState) fetchAllRecordsInZone(zone string) (*dynectsoap.GetAllRecordsResponseType, error) { + var err error + client := dynectsoap.NewClient("https://api2.dynect.net/SOAP/") + service := dynectsoap.NewDynect(client) + + sessionRequest := dynectsoap.SessionLoginRequestType{ + Customer_name: d.CustomerName, + User_name: d.Username, + Password: d.Password, + Fault_incompat: 0, + } + resp := dynectsoap.SessionLoginResponseType{} + err = apiRetryLoop(func() error { + return service.Do(&sessionRequest, &resp) + }) + + if err != nil { + return nil, err + } + + token := resp.Data.Token + + logoutRequest := dynectsoap.SessionLogoutRequestType{ + Token: token, + Fault_incompat: 0, + } + logoutResponse := dynectsoap.SessionLogoutResponseType{} + defer service.Do(&logoutRequest, &logoutResponse) + + req := dynectsoap.GetAllRecordsRequestType{ + Token: token, + Zone: zone, + Fault_incompat: 0, + } + records := dynectsoap.GetAllRecordsResponseType{} + err = apiRetryLoop(func() error { + return service.Do(&req, &records) + }) + + if err != nil { + return nil, err + } + log.Debugf("Got all Records, status is %s", records.Status) + + if strings.ToLower(records.Status) == "incomplete" { + jobRequest := dynectsoap.GetJobRequestType{ + Token: token, + Job_id: records.Job_id, + Fault_incompat: 0, + } + + jobResults := dynectsoap.GetJobResponseType{} + err = apiRetryLoop(func() error { + return service.GetJobRetry(&jobRequest, &jobResults) + }) + if err != nil { + return nil, err + } + return jobResults.Data.(*dynectsoap.GetAllRecordsResponseType), nil + } + + return &records, nil + +} + // fetchAllRecordLinksInZone list all records in a zone with a single call. Records not matched by the // DomainFilter are ignored. The response is a list of links that can be fed to dynect.Client.Do() // directly @@ -611,22 +619,14 @@ func (d *dynProviderState) Records() ([]*endpoint.Endpoint, error) { continue } - recordLinks, err := d.fetchAllRecordLinksInZone(client, zone) + //Fetch All Records + records, err := d.fetchAllRecordsInZone(zone) if err != nil { return nil, err } + relevantRecords = d.allRecordsToEndpoints(records) - log.Infof("Found %d relevant records found in zone %s: %+v", len(recordLinks), zone, recordLinks) - for _, link := range recordLinks { - ep, err := d.recordLinkToEndpoint(client, link) - if err != nil { - return nil, err - } - - if ep != nil { - relevantRecords = append(relevantRecords, ep) - } - } + log.Debugf("Relevant records %+v", relevantRecords) d.ZoneSnapshot.StoreRecordsForSerial(zone, serial, relevantRecords) log.Infof("Stored %d records for %s@%d", len(relevantRecords), zone, serial) @@ -638,7 +638,7 @@ func (d *dynProviderState) Records() ([]*endpoint.Endpoint, error) { // this method does C + 2*Z requests: C=total number of changes, Z = number of // affected zones (1 login + 1 commit) -func (d *dynProviderState) ApplyChanges(changes *plan.Changes) error { +func (d *dynProviderState) ApplyChanges(ctx context.Context, changes *plan.Changes) error { log.Debugf("Processing chages: %+v", changes) if d.DryRun { diff --git a/provider/dyn_test.go b/provider/dyn_test.go index 4beda817a..f2c2fa6d6 100644 --- a/provider/dyn_test.go +++ b/provider/dyn_test.go @@ -20,7 +20,6 @@ import ( "errors" "fmt" "testing" - "time" "github.com/nesv/go-dynect/dynect" "github.com/stretchr/testify/assert" @@ -169,22 +168,6 @@ func TestDynMerge_NoUpdateIfTTLUnchanged(t *testing.T) { assert.Equal(t, 0, len(merged)) } -func TestDyn_extractTarget(t *testing.T) { - tests := []struct { - recordType string - block *dynect.DataBlock - target string - }{ - {"A", &dynect.DataBlock{Address: "address"}, "address"}, - {"CNAME", &dynect.DataBlock{CName: "name."}, "name"}, // note trailing dot is trimmed for CNAMEs - {"TXT", &dynect.DataBlock{TxtData: "text."}, "text."}, - } - - for _, tc := range tests { - assert.Equal(t, tc.target, extractTarget(tc.recordType, tc.block)) - } -} - func TestDyn_endpointToRecord(t *testing.T) { tests := []struct { ep *endpoint.Endpoint @@ -264,42 +247,6 @@ func TestDyn_fixMissingTTL(t *testing.T) { assert.Equal(t, "1992", fixMissingTTL(endpoint.TTL(111), 1992)) } -func TestDyn_cachePut(t *testing.T) { - c := cache{ - contents: make(map[string]*entry), - } - - c.Put("link", &endpoint.Endpoint{ - DNSName: "name", - Targets: endpoint.Targets{"target"}, - RecordTTL: endpoint.TTL(10000), - RecordType: "A", - }) - - found := c.Get("link") - assert.NotNil(t, found) -} - -func TestDyn_cachePutExpired(t *testing.T) { - c := cache{ - contents: make(map[string]*entry), - } - - c.Put("link", &endpoint.Endpoint{ - DNSName: "name", - Targets: endpoint.Targets{"target"}, - RecordTTL: endpoint.TTL(0), - RecordType: "A", - }) - - time.Sleep(2 * time.Second) - - found := c.Get("link") - assert.Nil(t, found) - - assert.Nil(t, c.Get("no-such-records")) -} - func TestDyn_Snapshot(t *testing.T) { snap := ZoneSnapshot{ serials: map[string]int{}, diff --git a/provider/exoscale.go b/provider/exoscale.go index 4c909c1b3..be9fd8316 100644 --- a/provider/exoscale.go +++ b/provider/exoscale.go @@ -17,6 +17,7 @@ limitations under the License. package provider import ( + "context" "strings" "github.com/exoscale/egoscale" @@ -81,7 +82,7 @@ func (ep *ExoscaleProvider) getZones() (map[int64]string, error) { } // ApplyChanges simply modifies DNS via exoscale API -func (ep *ExoscaleProvider) ApplyChanges(changes *plan.Changes) error { +func (ep *ExoscaleProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { ep.OnApplyChanges(changes) if ep.dryRun { diff --git a/provider/exoscale_test.go b/provider/exoscale_test.go index 4c0c5bcbd..639040ffa 100644 --- a/provider/exoscale_test.go +++ b/provider/exoscale_test.go @@ -17,6 +17,7 @@ limitations under the License. package provider import ( + "context" "strings" "testing" @@ -173,7 +174,7 @@ func TestExoscaleApplyChanges(t *testing.T) { createExoscale = make([]createRecordExoscale, 0) deleteExoscale = make([]deleteRecordExoscale, 0) - provider.ApplyChanges(plan) + provider.ApplyChanges(context.Background(), plan) assert.Equal(t, 1, len(createExoscale)) assert.Equal(t, "foo.com", createExoscale[0].name) diff --git a/provider/google.go b/provider/google.go index 0474a7446..67033fc72 100644 --- a/provider/google.go +++ b/provider/google.go @@ -17,6 +17,7 @@ limitations under the License. package provider import ( + goctx "context" "fmt" "strings" @@ -143,9 +144,9 @@ func NewGoogleProvider(project string, domainFilter DomainFilter, zoneIDFilter Z provider := &GoogleProvider{ project: project, + dryRun: dryRun, domainFilter: domainFilter, zoneIDFilter: zoneIDFilter, - dryRun: dryRun, resourceRecordSetsClient: resourceRecordSetsService{dnsClient.ResourceRecordSets}, managedZonesClient: managedZonesService{dnsClient.ManagedZones}, changesClient: changesService{dnsClient.Changes}, @@ -247,7 +248,7 @@ func (p *GoogleProvider) DeleteRecords(endpoints []*endpoint.Endpoint) error { } // ApplyChanges applies a given set of changes in a given zone. -func (p *GoogleProvider) ApplyChanges(changes *plan.Changes) error { +func (p *GoogleProvider) ApplyChanges(ctx goctx.Context, changes *plan.Changes) error { change := &dns.Change{} change.Additions = append(change.Additions, p.newFilteredRecords(changes.Create)...) diff --git a/provider/google_test.go b/provider/google_test.go index a58d2520b..f4f15b5ab 100644 --- a/provider/google_test.go +++ b/provider/google_test.go @@ -387,7 +387,7 @@ func TestGoogleApplyChanges(t *testing.T) { Delete: deleteRecords, } - require.NoError(t, provider.ApplyChanges(changes)) + require.NoError(t, provider.ApplyChanges(context.Background(), changes)) records, err := provider.Records() require.NoError(t, err) @@ -444,7 +444,7 @@ func TestGoogleApplyChangesDryRun(t *testing.T) { Delete: deleteRecords, } - require.NoError(t, provider.ApplyChanges(changes)) + require.NoError(t, provider.ApplyChanges(context.Background(), changes)) records, err := provider.Records() require.NoError(t, err) @@ -454,7 +454,7 @@ func TestGoogleApplyChangesDryRun(t *testing.T) { func TestGoogleApplyChangesEmpty(t *testing.T) { provider := newGoogleProvider(t, NewDomainFilter([]string{"ext-dns-test-2.gcp.zalan.do."}), NewZoneIDFilter([]string{""}), false, []*endpoint.Endpoint{}) - assert.NoError(t, provider.ApplyChanges(&plan.Changes{})) + assert.NoError(t, provider.ApplyChanges(context.Background(), &plan.Changes{})) } func TestNewFilteredRecords(t *testing.T) { @@ -565,9 +565,9 @@ func validateChangeRecord(t *testing.T, record *dns.ResourceRecordSet, expected func newGoogleProvider(t *testing.T, domainFilter DomainFilter, zoneIDFilter ZoneIDFilter, dryRun bool, records []*endpoint.Endpoint) *GoogleProvider { provider := &GoogleProvider{ project: "zalando-external-dns-test", + dryRun: false, domainFilter: domainFilter, zoneIDFilter: zoneIDFilter, - dryRun: false, resourceRecordSetsClient: &mockResourceRecordSetsClient{}, managedZonesClient: &mockManagedZonesClient{}, changesClient: &mockChangesClient{}, diff --git a/provider/infoblox.go b/provider/infoblox.go index 71c89eefe..667b9f92c 100644 --- a/provider/infoblox.go +++ b/provider/infoblox.go @@ -17,6 +17,7 @@ limitations under the License. package provider import ( + "context" "fmt" "os" "strconv" @@ -39,6 +40,7 @@ type InfobloxConfig struct { Version string SSLVerify bool DryRun bool + View string } // InfobloxProvider implements the DNS provider for Infoblox. @@ -46,6 +48,7 @@ type InfobloxProvider struct { client ibclient.IBConnector domainFilter DomainFilter zoneIDFilter ZoneIDFilter + view string dryRun bool } @@ -87,6 +90,7 @@ func NewInfobloxProvider(infobloxConfig InfobloxConfig) (*InfobloxProvider, erro domainFilter: infobloxConfig.DomainFilter, zoneIDFilter: infobloxConfig.ZoneIDFilter, dryRun: infobloxConfig.DryRun, + view: infobloxConfig.View, } return provider, nil @@ -105,6 +109,7 @@ func (p *InfobloxProvider) Records() (endpoints []*endpoint.Endpoint, err error) objA := ibclient.NewRecordA( ibclient.RecordA{ Zone: zone.Fqdn, + View: p.view, }, ) err = p.client.GetObject(objA, "", &resA) @@ -120,6 +125,7 @@ func (p *InfobloxProvider) Records() (endpoints []*endpoint.Endpoint, err error) objH := ibclient.NewHostRecord( ibclient.HostRecord{ Zone: zone.Fqdn, + View: p.view, }, ) err = p.client.GetObject(objH, "", &resH) @@ -136,6 +142,7 @@ func (p *InfobloxProvider) Records() (endpoints []*endpoint.Endpoint, err error) objC := ibclient.NewRecordCNAME( ibclient.RecordCNAME{ Zone: zone.Fqdn, + View: p.view, }, ) err = p.client.GetObject(objC, "", &resC) @@ -150,6 +157,7 @@ func (p *InfobloxProvider) Records() (endpoints []*endpoint.Endpoint, err error) objT := ibclient.NewRecordTXT( ibclient.RecordTXT{ Zone: zone.Fqdn, + View: p.view, }, ) err = p.client.GetObject(objT, "", &resT) @@ -170,7 +178,7 @@ func (p *InfobloxProvider) Records() (endpoints []*endpoint.Endpoint, err error) } // ApplyChanges applies the given changes. -func (p *InfobloxProvider) ApplyChanges(changes *plan.Changes) error { +func (p *InfobloxProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { zones, err := p.zones() if err != nil { return err @@ -215,7 +223,7 @@ func (p *InfobloxProvider) mapChanges(zones []ibclient.ZoneAuth, changes *plan.C mapChange := func(changeMap infobloxChangeMap, change *endpoint.Endpoint) { zone := p.findZone(zones, change.DNSName) if zone == nil { - logrus.Infof("Ignoring changes to '%s' because a suitable Infoblox DNS zone was not found.", change.DNSName) + logrus.Debugf("Ignoring changes to '%s' because a suitable Infoblox DNS zone was not found.", change.DNSName) return } // Ensure the record type is suitable @@ -261,6 +269,7 @@ func (p *InfobloxProvider) recordSet(ep *endpoint.Endpoint, getObject bool) (rec ibclient.RecordA{ Name: ep.DNSName, Ipv4Addr: ep.Targets[0], + View: p.view, }, ) if getObject { @@ -279,6 +288,7 @@ func (p *InfobloxProvider) recordSet(ep *endpoint.Endpoint, getObject bool) (rec ibclient.RecordCNAME{ Name: ep.DNSName, Canonical: ep.Targets[0], + View: p.view, }, ) if getObject { @@ -302,6 +312,7 @@ func (p *InfobloxProvider) recordSet(ep *endpoint.Endpoint, getObject bool) (rec ibclient.RecordTXT{ Name: ep.DNSName, Text: ep.Targets[0], + View: p.view, }, ) if getObject { diff --git a/provider/infoblox_test.go b/provider/infoblox_test.go index 6ae80eb92..dc3bd9865 100644 --- a/provider/infoblox_test.go +++ b/provider/infoblox_test.go @@ -17,6 +17,7 @@ limitations under the License. package provider import ( + "context" "encoding/base64" "fmt" "regexp" @@ -469,7 +470,7 @@ func testInfobloxApplyChangesInternal(t *testing.T, dryRun bool, client ibclient Delete: deleteRecords, } - if err := provider.ApplyChanges(changes); err != nil { + if err := provider.ApplyChanges(context.Background(), changes); err != nil { t.Fatal(err) } } diff --git a/provider/inmemory.go b/provider/inmemory.go index 1cf6c5662..790f4c470 100644 --- a/provider/inmemory.go +++ b/provider/inmemory.go @@ -17,6 +17,7 @@ limitations under the License. package provider import ( + "context" "errors" "strings" @@ -45,7 +46,7 @@ type InMemoryProvider struct { domain DomainFilter client *inMemoryClient filter *filter - OnApplyChanges func(changes *plan.Changes) + OnApplyChanges func(ctx context.Context, changes *plan.Changes) OnRecords func() } @@ -55,7 +56,7 @@ type InMemoryOption func(*InMemoryProvider) // InMemoryWithLogging injects logging when ApplyChanges is called func InMemoryWithLogging() InMemoryOption { return func(p *InMemoryProvider) { - p.OnApplyChanges = func(changes *plan.Changes) { + p.OnApplyChanges = func(ctx context.Context, changes *plan.Changes) { for _, v := range changes.Create { log.Infof("CREATE: %v", v) } @@ -94,7 +95,7 @@ func InMemoryInitZones(zones []string) InMemoryOption { func NewInMemoryProvider(opts ...InMemoryOption) *InMemoryProvider { im := &InMemoryProvider{ filter: &filter{}, - OnApplyChanges: func(changes *plan.Changes) {}, + OnApplyChanges: func(ctx context.Context, changes *plan.Changes) {}, OnRecords: func() {}, domain: NewDomainFilter([]string{""}), client: newInMemoryClient(), @@ -142,8 +143,8 @@ func (im *InMemoryProvider) Records() ([]*endpoint.Endpoint, error) { // create record - record should not exist // update/delete record - record should exist // create/update/delete lists should not have overlapping records -func (im *InMemoryProvider) ApplyChanges(changes *plan.Changes) error { - defer im.OnApplyChanges(changes) +func (im *InMemoryProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { + defer im.OnApplyChanges(ctx, changes) perZoneChanges := map[string]*plan.Changes{} @@ -188,7 +189,7 @@ func (im *InMemoryProvider) ApplyChanges(changes *plan.Changes) error { UpdateOld: convertToInMemoryRecord(perZoneChanges[zoneID].UpdateOld), Delete: convertToInMemoryRecord(perZoneChanges[zoneID].Delete), } - err := im.client.ApplyChanges(zoneID, change) + err := im.client.ApplyChanges(ctx, zoneID, change) if err != nil { return err } @@ -293,7 +294,7 @@ func (c *inMemoryClient) CreateZone(zone string) error { return nil } -func (c *inMemoryClient) ApplyChanges(zoneID string, changes *inMemoryChange) error { +func (c *inMemoryClient) ApplyChanges(ctx context.Context, zoneID string, changes *inMemoryChange) error { if err := c.validateChangeBatch(zoneID, changes); err != nil { return err } diff --git a/provider/inmemory_test.go b/provider/inmemory_test.go index 4e6190838..0d61a9480 100644 --- a/provider/inmemory_test.go +++ b/provider/inmemory_test.go @@ -17,6 +17,7 @@ limitations under the License. package provider import ( + "context" "testing" "github.com/kubernetes-incubator/external-dns/endpoint" @@ -773,7 +774,7 @@ func testInMemoryApplyChanges(t *testing.T) { c.zones = getInitData() im.client = c - err := im.ApplyChanges(ti.changes) + err := im.ApplyChanges(context.Background(), ti.changes) if ti.expectError { assert.Error(t, err) } else { diff --git a/provider/linode.go b/provider/linode.go index 7000f1961..05076133e 100644 --- a/provider/linode.go +++ b/provider/linode.go @@ -263,7 +263,7 @@ func getPriority() *int { } // ApplyChanges applies a given set of changes in a given zone. -func (p *LinodeProvider) ApplyChanges(changes *plan.Changes) error { +func (p *LinodeProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { recordsByZoneID := make(map[string][]*linodego.DomainRecord) zones, err := p.fetchZones() diff --git a/provider/linode_test.go b/provider/linode_test.go index 974f8f770..9abf1950c 100644 --- a/provider/linode_test.go +++ b/provider/linode_test.go @@ -131,7 +131,7 @@ func TestLinodeConvertRecordType(t *testing.T) { require.NoError(t, err) assert.Equal(t, linodego.RecordTypeSRV, record) - record, err = convertRecordType("INVALID") + _, err = convertRecordType("INVALID") require.Error(t, err) } @@ -353,7 +353,7 @@ func TestLinodeApplyChanges(t *testing.T) { }, ).Return(&linodego.DomainRecord{}, nil).Once() - err := provider.ApplyChanges(&plan.Changes{ + err := provider.ApplyChanges(context.Background(), &plan.Changes{ Create: []*endpoint.Endpoint{{ DNSName: "create.bar.io", RecordType: "A", @@ -428,7 +428,7 @@ func TestLinodeApplyChangesTargetAdded(t *testing.T) { }, ).Return(&linodego.DomainRecord{}, nil).Once() - err := provider.ApplyChanges(&plan.Changes{ + err := provider.ApplyChanges(context.Background(), &plan.Changes{ // From 1 target to 2 UpdateNew: []*endpoint.Endpoint{{ DNSName: "example.com", @@ -484,7 +484,7 @@ func TestLinodeApplyChangesTargetRemoved(t *testing.T) { 11, ).Return(nil).Once() - err := provider.ApplyChanges(&plan.Changes{ + err := provider.ApplyChanges(context.Background(), &plan.Changes{ // From 2 targets to 1 UpdateNew: []*endpoint.Endpoint{{ DNSName: "example.com", @@ -521,7 +521,7 @@ func TestLinodeApplyChangesNoChanges(t *testing.T) { mock.Anything, ).Return([]*linodego.DomainRecord{{ID: 11, Name: "", Type: "A", Target: "targetA"}}, nil).Once() - err := provider.ApplyChanges(&plan.Changes{}) + err := provider.ApplyChanges(context.Background(), &plan.Changes{}) require.NoError(t, err) mockDomainClient.AssertExpectations(t) diff --git a/provider/ns1.go b/provider/ns1.go new file mode 100644 index 000000000..988481d38 --- /dev/null +++ b/provider/ns1.go @@ -0,0 +1,319 @@ +/* +Copyright 2017 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 provider + +import ( + "context" + "crypto/tls" + "fmt" + "net/http" + "os" + "strings" + + log "github.com/sirupsen/logrus" + + api "gopkg.in/ns1/ns1-go.v2/rest" + "gopkg.in/ns1/ns1-go.v2/rest/model/dns" + + "github.com/kubernetes-incubator/external-dns/endpoint" + "github.com/kubernetes-incubator/external-dns/plan" +) + +const ( + // ns1Create is a ChangeAction enum value + ns1Create = "CREATE" + // ns1Delete is a ChangeAction enum value + ns1Delete = "DELETE" + // ns1Update is a ChangeAction enum value + ns1Update = "UPDATE" + // ns1DefaultTTL is the default ttl for ttls that are not set + ns1DefaultTTL = 10 +) + +// NS1DomainClient is a subset of the NS1 API the the provider uses, to ease testing +type NS1DomainClient interface { + CreateRecord(r *dns.Record) (*http.Response, error) + DeleteRecord(zone string, domain string, t string) (*http.Response, error) + UpdateRecord(r *dns.Record) (*http.Response, error) + GetZone(zone string) (*dns.Zone, *http.Response, error) + ListZones() ([]*dns.Zone, *http.Response, error) +} + +// NS1DomainService wraps the API and fulfills the NS1DomainClient interface +type NS1DomainService struct { + service *api.Client +} + +// CreateRecord wraps the Create method of the API's Record service +func (n NS1DomainService) CreateRecord(r *dns.Record) (*http.Response, error) { + return n.service.Records.Create(r) +} + +// DeleteRecord wraps the Delete method of the API's Record service +func (n NS1DomainService) DeleteRecord(zone string, domain string, t string) (*http.Response, error) { + return n.service.Records.Delete(zone, domain, t) +} + +// UpdateRecord wraps the Update method of the API's Record service +func (n NS1DomainService) UpdateRecord(r *dns.Record) (*http.Response, error) { + return n.service.Records.Update(r) +} + +// GetZone wraps the Get method of the API's Zones service +func (n NS1DomainService) GetZone(zone string) (*dns.Zone, *http.Response, error) { + return n.service.Zones.Get(zone) +} + +// ListZones wraps the List method of the API's Zones service +func (n NS1DomainService) ListZones() ([]*dns.Zone, *http.Response, error) { + return n.service.Zones.List() +} + +// NS1Config passes cli args to the NS1Provider +type NS1Config struct { + DomainFilter DomainFilter + ZoneIDFilter ZoneIDFilter + NS1Endpoint string + NS1IgnoreSSL bool + DryRun bool +} + +// NS1Provider is the NS1 provider +type NS1Provider struct { + client NS1DomainClient + domainFilter DomainFilter + zoneIDFilter ZoneIDFilter + dryRun bool +} + +// NewNS1Provider creates a new NS1 Provider +func NewNS1Provider(config NS1Config) (*NS1Provider, error) { + return newNS1ProviderWithHTTPClient(config, http.DefaultClient) +} + +func newNS1ProviderWithHTTPClient(config NS1Config, client *http.Client) (*NS1Provider, error) { + token, ok := os.LookupEnv("NS1_APIKEY") + if !ok { + return nil, fmt.Errorf("NS1_APIKEY environment variable is not set") + } + clientArgs := []func(*api.Client){api.SetAPIKey(token)} + if config.NS1Endpoint != "" { + log.Infof("ns1-endpoint flag is set, targeting endpoint at %s", config.NS1Endpoint) + clientArgs = append(clientArgs, api.SetEndpoint(config.NS1Endpoint)) + } + + if config.NS1IgnoreSSL == true { + log.Info("ns1-ignoressl flag is True, skipping SSL verification") + defaultTransport := http.DefaultTransport.(*http.Transport) + tr := &http.Transport{ + Proxy: defaultTransport.Proxy, + DialContext: defaultTransport.DialContext, + MaxIdleConns: defaultTransport.MaxIdleConns, + IdleConnTimeout: defaultTransport.IdleConnTimeout, + ExpectContinueTimeout: defaultTransport.ExpectContinueTimeout, + TLSHandshakeTimeout: defaultTransport.TLSHandshakeTimeout, + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + client.Transport = tr + } + + apiClient := api.NewClient(client, clientArgs...) + + provider := &NS1Provider{ + client: NS1DomainService{apiClient}, + domainFilter: config.DomainFilter, + zoneIDFilter: config.ZoneIDFilter, + } + return provider, nil +} + +// Records returns the endpoints this provider knows about +func (p *NS1Provider) Records() ([]*endpoint.Endpoint, error) { + zones, err := p.zonesFiltered() + if err != nil { + return nil, err + } + + var endpoints []*endpoint.Endpoint + + for _, zone := range zones { + + // TODO handle Header Codes + zoneData, _, err := p.client.GetZone(zone.String()) + if err != nil { + return nil, err + } + + for _, record := range zoneData.Records { + if supportedRecordType(record.Type) { + endpoints = append(endpoints, endpoint.NewEndpointWithTTL( + record.Domain, + record.Type, + endpoint.TTL(record.TTL), + record.ShortAns..., + ), + ) + } + } + } + + return endpoints, nil +} + +// ns1BuildRecord returns a dns.Record for a change set +func ns1BuildRecord(zoneName string, change *ns1Change) *dns.Record { + record := dns.NewRecord(zoneName, change.Endpoint.DNSName, change.Endpoint.RecordType) + for _, v := range change.Endpoint.Targets { + record.AddAnswer(dns.NewAnswer(strings.Split(v, " "))) + } + // set detault ttl + var ttl = ns1DefaultTTL + if change.Endpoint.RecordTTL.IsConfigured() { + ttl = int(change.Endpoint.RecordTTL) + } + record.TTL = ttl + + return record +} + +// ns1SubmitChanges takes an array of changes and sends them to NS1 +func (p *NS1Provider) ns1SubmitChanges(changes []*ns1Change) error { + // return early if there is nothing to change + if len(changes) == 0 { + return nil + } + + zones, err := p.zonesFiltered() + if err != nil { + return err + } + + // separate into per-zone change sets to be passed to the API. + changesByZone := ns1ChangesByZone(zones, changes) + for zoneName, changes := range changesByZone { + for _, change := range changes { + record := ns1BuildRecord(zoneName, change) + logFields := log.Fields{ + "record": record.Domain, + "type": record.Type, + "ttl": record.TTL, + "action": change.Action, + "zone": zoneName, + } + + log.WithFields(logFields).Info("Changing record.") + + if p.dryRun { + continue + } + + switch change.Action { + case ns1Create: + _, err := p.client.CreateRecord(record) + if err != nil { + return err + } + case ns1Delete: + _, err := p.client.DeleteRecord(zoneName, record.Domain, record.Type) + if err != nil { + return err + } + case ns1Update: + _, err := p.client.UpdateRecord(record) + if err != nil { + return err + } + } + } + } + return nil +} + +// Zones returns the list of hosted zones. +func (p *NS1Provider) zonesFiltered() ([]*dns.Zone, error) { + // TODO handle Header Codes + zones, _, err := p.client.ListZones() + if err != nil { + return nil, err + } + + toReturn := []*dns.Zone{} + + for _, z := range zones { + if p.domainFilter.Match(z.Zone) && p.zoneIDFilter.Match(z.ID) { + toReturn = append(toReturn, z) + log.Debugf("Matched %s", z.Zone) + } else { + log.Debugf("Filtered %s", z.Zone) + } + } + + return toReturn, nil +} + +// ns1Change differentiates between ChangeActions +type ns1Change struct { + Action string + Endpoint *endpoint.Endpoint +} + +// ApplyChanges applies a given set of changes in a given zone. +func (p *NS1Provider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { + combinedChanges := make([]*ns1Change, 0, len(changes.Create)+len(changes.UpdateNew)+len(changes.Delete)) + + combinedChanges = append(combinedChanges, newNS1Changes(ns1Create, changes.Create)...) + combinedChanges = append(combinedChanges, newNS1Changes(ns1Update, changes.UpdateNew)...) + combinedChanges = append(combinedChanges, newNS1Changes(ns1Delete, changes.Delete)...) + + return p.ns1SubmitChanges(combinedChanges) +} + +// newNS1Changes returns a collection of Changes based on the given records and action. +func newNS1Changes(action string, endpoints []*endpoint.Endpoint) []*ns1Change { + changes := make([]*ns1Change, 0, len(endpoints)) + + for _, endpoint := range endpoints { + changes = append(changes, &ns1Change{ + Action: action, + Endpoint: endpoint, + }, + ) + } + + return changes +} + +// ns1ChangesByZone separates a multi-zone change into a single change per zone. +func ns1ChangesByZone(zones []*dns.Zone, changeSets []*ns1Change) map[string][]*ns1Change { + changes := make(map[string][]*ns1Change) + zoneNameIDMapper := zoneIDName{} + for _, z := range zones { + zoneNameIDMapper.Add(z.Zone, z.Zone) + changes[z.Zone] = []*ns1Change{} + } + + for _, c := range changeSets { + zone, _ := zoneNameIDMapper.FindZone(c.Endpoint.DNSName) + if zone == "" { + log.Debugf("Skipping record %s because no hosted zone matching record DNS Name was detected ", c.Endpoint.DNSName) + continue + } + changes[zone] = append(changes[zone], c) + } + + return changes +} diff --git a/provider/ns1_test.go b/provider/ns1_test.go new file mode 100644 index 000000000..6fc5ea823 --- /dev/null +++ b/provider/ns1_test.go @@ -0,0 +1,307 @@ +/* +Copyright 2017 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 provider + +import ( + "context" + "fmt" + "net/http" + "os" + "testing" + + "github.com/kubernetes-incubator/external-dns/endpoint" + "github.com/kubernetes-incubator/external-dns/plan" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + api "gopkg.in/ns1/ns1-go.v2/rest" + "gopkg.in/ns1/ns1-go.v2/rest/model/dns" +) + +type MockNS1DomainClient struct { + mock.Mock +} + +func (m *MockNS1DomainClient) CreateRecord(r *dns.Record) (*http.Response, error) { + return nil, nil +} + +func (m *MockNS1DomainClient) DeleteRecord(zone string, domain string, t string) (*http.Response, error) { + return nil, nil +} + +func (m *MockNS1DomainClient) UpdateRecord(r *dns.Record) (*http.Response, error) { + return nil, nil +} + +func (m *MockNS1DomainClient) GetZone(zone string) (*dns.Zone, *http.Response, error) { + r := &dns.ZoneRecord{ + Domain: "test.foo.com", + ShortAns: []string{"2.2.2.2"}, + TTL: 3600, + Type: "A", + ID: "123456789abcdefghijklmno", + } + z := &dns.Zone{ + Zone: "foo.com", + Records: []*dns.ZoneRecord{r}, + TTL: 3600, + ID: "12345678910111213141516a", + } + + if zone == "foo.com" { + return z, nil, nil + } + return nil, nil, nil +} + +func (m *MockNS1DomainClient) ListZones() ([]*dns.Zone, *http.Response, error) { + zones := []*dns.Zone{ + {Zone: "foo.com", ID: "12345678910111213141516a"}, + {Zone: "bar.com", ID: "12345678910111213141516b"}, + } + return zones, nil, nil +} + +type MockNS1GetZoneFail struct{} + +func (m *MockNS1GetZoneFail) CreateRecord(r *dns.Record) (*http.Response, error) { + return nil, nil +} + +func (m *MockNS1GetZoneFail) DeleteRecord(zone string, domain string, t string) (*http.Response, error) { + return nil, nil +} + +func (m *MockNS1GetZoneFail) UpdateRecord(r *dns.Record) (*http.Response, error) { + return nil, nil +} + +func (m *MockNS1GetZoneFail) GetZone(zone string) (*dns.Zone, *http.Response, error) { + return nil, nil, api.ErrZoneMissing +} + +func (m *MockNS1GetZoneFail) ListZones() ([]*dns.Zone, *http.Response, error) { + zones := []*dns.Zone{ + {Zone: "foo.com", ID: "12345678910111213141516a"}, + {Zone: "bar.com", ID: "12345678910111213141516b"}, + } + return zones, nil, nil +} + +type MockNS1ListZonesFail struct{} + +func (m *MockNS1ListZonesFail) CreateRecord(r *dns.Record) (*http.Response, error) { + return nil, nil +} + +func (m *MockNS1ListZonesFail) DeleteRecord(zone string, domain string, t string) (*http.Response, error) { + return nil, nil +} + +func (m *MockNS1ListZonesFail) UpdateRecord(r *dns.Record) (*http.Response, error) { + return nil, nil +} + +func (m *MockNS1ListZonesFail) GetZone(zone string) (*dns.Zone, *http.Response, error) { + return &dns.Zone{}, nil, nil +} + +func (m *MockNS1ListZonesFail) ListZones() ([]*dns.Zone, *http.Response, error) { + return nil, nil, fmt.Errorf("no zones available") +} + +func TestNS1Records(t *testing.T) { + provider := &NS1Provider{ + client: &MockNS1DomainClient{}, + domainFilter: NewDomainFilter([]string{"foo.com."}), + zoneIDFilter: NewZoneIDFilter([]string{""}), + } + records, err := provider.Records() + require.NoError(t, err) + assert.Equal(t, 1, len(records)) + + provider.client = &MockNS1GetZoneFail{} + _, err = provider.Records() + require.Error(t, err) + + provider.client = &MockNS1ListZonesFail{} + _, err = provider.Records() + require.Error(t, err) +} + +func TestNewNS1Provider(t *testing.T) { + _ = os.Setenv("NS1_APIKEY", "xxxxxxxxxxxxxxxxx") + testNS1Config := NS1Config{ + DomainFilter: NewDomainFilter([]string{"foo.com."}), + ZoneIDFilter: NewZoneIDFilter([]string{""}), + DryRun: false, + } + _, err := NewNS1Provider(testNS1Config) + require.NoError(t, err) + + _ = os.Unsetenv("NS1_APIKEY") + _, err = NewNS1Provider(testNS1Config) + require.Error(t, err) +} + +func TestNS1Zones(t *testing.T) { + provider := &NS1Provider{ + client: &MockNS1DomainClient{}, + domainFilter: NewDomainFilter([]string{"foo.com."}), + zoneIDFilter: NewZoneIDFilter([]string{""}), + } + + zones, err := provider.zonesFiltered() + require.NoError(t, err) + + validateNS1Zones(t, zones, []*dns.Zone{ + {Zone: "foo.com"}, + }) +} + +func validateNS1Zones(t *testing.T, zones []*dns.Zone, expected []*dns.Zone) { + require.Len(t, zones, len(expected)) + + for i, zone := range zones { + assert.Equal(t, expected[i].Zone, zone.Zone) + } +} + +func TestNS1BuildRecord(t *testing.T) { + change := &ns1Change{ + Action: ns1Create, + Endpoint: &endpoint.Endpoint{ + DNSName: "new", + Targets: endpoint.Targets{"target"}, + RecordType: "A", + }, + } + record := ns1BuildRecord("foo.com", change) + assert.Equal(t, "foo.com", record.Zone) + assert.Equal(t, "new.foo.com", record.Domain) + assert.Equal(t, ns1DefaultTTL, record.TTL) + + changeWithTTL := &ns1Change{ + Action: ns1Create, + Endpoint: &endpoint.Endpoint{ + DNSName: "new-b", + Targets: endpoint.Targets{"target"}, + RecordType: "A", + RecordTTL: 100, + }, + } + record = ns1BuildRecord("foo.com", changeWithTTL) + assert.Equal(t, "foo.com", record.Zone) + assert.Equal(t, "new-b.foo.com", record.Domain) + assert.Equal(t, 100, record.TTL) +} + +func TestNS1ApplyChanges(t *testing.T) { + changes := &plan.Changes{} + provider := &NS1Provider{ + client: &MockNS1DomainClient{}, + } + changes.Create = []*endpoint.Endpoint{ + {DNSName: "new.foo.com", Targets: endpoint.Targets{"target"}}, + {DNSName: "new.subdomain.bar.com", Targets: endpoint.Targets{"target"}}, + } + changes.Delete = []*endpoint.Endpoint{{DNSName: "test.foo.com", Targets: endpoint.Targets{"target"}}} + changes.UpdateNew = []*endpoint.Endpoint{{DNSName: "test.foo.com", Targets: endpoint.Targets{"target-new"}}} + err := provider.ApplyChanges(context.Background(), changes) + require.NoError(t, err) + + // empty changes + changes.Create = []*endpoint.Endpoint{} + changes.Delete = []*endpoint.Endpoint{} + changes.UpdateNew = []*endpoint.Endpoint{} + err = provider.ApplyChanges(context.Background(), changes) + require.NoError(t, err) +} + +func TestNewNS1Changes(t *testing.T) { + endpoints := []*endpoint.Endpoint{ + { + DNSName: "testa.foo.com", + Targets: endpoint.Targets{"target-old"}, + RecordType: "A", + }, + { + DNSName: "testba.bar.com", + Targets: endpoint.Targets{"target-new"}, + RecordType: "A", + }, + } + expected := []*ns1Change{ + { + Action: "ns1Create", + Endpoint: endpoints[0], + }, + { + Action: "ns1Create", + Endpoint: endpoints[1], + }, + } + changes := newNS1Changes("ns1Create", endpoints) + require.Len(t, changes, len(expected)) + assert.Equal(t, expected, changes) +} + +func TestNewNS1ChangesByZone(t *testing.T) { + provider := &NS1Provider{ + client: &MockNS1DomainClient{}, + } + zones, _ := provider.zonesFiltered() + changeSets := []*ns1Change{ + { + Action: "ns1Create", + Endpoint: &endpoint.Endpoint{ + DNSName: "new.foo.com", + Targets: endpoint.Targets{"target"}, + RecordType: "A", + }, + }, + { + Action: "ns1Create", + Endpoint: &endpoint.Endpoint{ + DNSName: "unrelated.bar.com", + Targets: endpoint.Targets{"target"}, + RecordType: "A", + }, + }, + { + Action: "ns1Delete", + Endpoint: &endpoint.Endpoint{ + DNSName: "test.foo.com", + Targets: endpoint.Targets{"target"}, + RecordType: "A", + }, + }, + { + Action: "ns1Update", + Endpoint: &endpoint.Endpoint{ + DNSName: "test.foo.com", + Targets: endpoint.Targets{"target-new"}, + RecordType: "A", + }, + }, + } + + changes := ns1ChangesByZone(zones, changeSets) + assert.Len(t, changes["bar.com"], 1) + assert.Len(t, changes["foo.com"], 3) +} diff --git a/provider/oci.go b/provider/oci.go index aa0c03411..cb4f6c23b 100644 --- a/provider/oci.go +++ b/provider/oci.go @@ -201,7 +201,7 @@ func (p *OCIProvider) Records() ([]*endpoint.Endpoint, error) { } // ApplyChanges applies a given set of changes to a given zone. -func (p *OCIProvider) ApplyChanges(changes *plan.Changes) error { +func (p *OCIProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { log.Debugf("Processing chages: %+v", changes) ops := []dns.RecordOperation{} @@ -217,7 +217,6 @@ func (p *OCIProvider) ApplyChanges(changes *plan.Changes) error { return nil } - ctx := context.Background() zones, err := p.zones(ctx) if err != nil { return errors.Wrap(err, "fetching zones") diff --git a/provider/oci_test.go b/provider/oci_test.go index 89056812c..a4a476d96 100644 --- a/provider/oci_test.go +++ b/provider/oci_test.go @@ -829,7 +829,7 @@ func TestOCIApplyChanges(t *testing.T) { NewZoneIDFilter([]string{""}), tc.dryRun, ) - err := provider.ApplyChanges(tc.changes) + err := provider.ApplyChanges(context.Background(), tc.changes) require.Equal(t, tc.err, err) endpoints, err := provider.Records() require.NoError(t, err) diff --git a/provider/pdns.go b/provider/pdns.go index 8b33a4c96..4622971f9 100644 --- a/provider/pdns.go +++ b/provider/pdns.go @@ -30,11 +30,12 @@ import ( log "github.com/sirupsen/logrus" "crypto/tls" + "net" + pgo "github.com/ffledgling/pdns-go" "github.com/kubernetes-incubator/external-dns/endpoint" "github.com/kubernetes-incubator/external-dns/pkg/tlsutils" "github.com/kubernetes-incubator/external-dns/plan" - "net" ) type pdnsChangeType string @@ -175,7 +176,7 @@ func (c *PDNSAPIClient) PartitionZones(zones []pgo.Zone) (filteredZones []pgo.Zo } } } else { - residualZones = zones + filteredZones = zones } return filteredZones, residualZones } @@ -442,7 +443,7 @@ func (p *PDNSProvider) Records() (endpoints []*endpoint.Endpoint, _ error) { // ApplyChanges takes a list of changes (endpoints) and updates the PDNS server // by sending the correct HTTP PATCH requests to a matching zone -func (p *PDNSProvider) ApplyChanges(changes *plan.Changes) error { +func (p *PDNSProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { startTime := time.Now() diff --git a/provider/pdns_test.go b/provider/pdns_test.go index c7c5b592c..7caf48a04 100644 --- a/provider/pdns_test.go +++ b/provider/pdns_test.go @@ -17,7 +17,9 @@ limitations under the License. package provider import ( + "context" "errors" + //"fmt" "net/http" "strings" @@ -475,6 +477,44 @@ var ( }, }, } + + DomainFilterListSingle = DomainFilter{ + filters: []string{ + "example.com", + }, + } + + DomainFilterListMultiple = DomainFilter{ + filters: []string{ + "example.com", + "mock.com", + }, + } + + DomainFilterListEmpty = DomainFilter{ + filters: []string{}, + } + + DomainFilterEmptyClient = &PDNSAPIClient{ + dryRun: false, + authCtx: context.WithValue(context.TODO(), pgo.ContextAPIKey, pgo.APIKey{Key: "TEST-API-KEY"}), + client: pgo.NewAPIClient(pgo.NewConfiguration()), + domainFilter: DomainFilterListEmpty, + } + + DomainFilterSingleClient = &PDNSAPIClient{ + dryRun: false, + authCtx: context.WithValue(context.TODO(), pgo.ContextAPIKey, pgo.APIKey{Key: "TEST-API-KEY"}), + client: pgo.NewAPIClient(pgo.NewConfiguration()), + domainFilter: DomainFilterListSingle, + } + + DomainFilterMultipleClient = &PDNSAPIClient{ + dryRun: false, + authCtx: context.WithValue(context.TODO(), pgo.ContextAPIKey, pgo.APIKey{Key: "TEST-API-KEY"}), + client: pgo.NewAPIClient(pgo.NewConfiguration()), + domainFilter: DomainFilterListMultiple, + } ) /******************************************************************************/ @@ -766,13 +806,13 @@ func (suite *NewPDNSProviderTestSuite) TestPDNSRecords() { p = &PDNSProvider{ client: &PDNSAPIClientStubListZoneFailure{}, } - eps, err = p.Records() + _, err = p.Records() assert.NotNil(suite.T(), err) p = &PDNSProvider{ client: &PDNSAPIClientStubListZonesFailure{}, } - eps, err = p.Records() + _, err = p.Records() assert.NotNil(suite.T(), err) } @@ -912,6 +952,51 @@ func (suite *NewPDNSProviderTestSuite) TestPDNSmutateRecords() { } +func (suite *NewPDNSProviderTestSuite) TestPDNSClientPartitionZones() { + zoneList := []pgo.Zone{ + ZoneEmpty, + ZoneEmpty2, + } + + partitionResultFilteredEmptyFilter := []pgo.Zone{ + ZoneEmpty, + ZoneEmpty2, + } + + partitionResultResidualEmptyFilter := ([]pgo.Zone)(nil) + + partitionResultFilteredSingleFilter := []pgo.Zone{ + ZoneEmpty, + } + + partitionResultResidualSingleFilter := []pgo.Zone{ + ZoneEmpty2, + } + + partitionResultFilteredMultipleFilter := []pgo.Zone{ + ZoneEmpty, + } + + partitionResultResidualMultipleFilter := []pgo.Zone{ + ZoneEmpty2, + } + + // Check filtered, residual zones when no domain filter specified + filteredZones, residualZones := DomainFilterEmptyClient.PartitionZones(zoneList) + assert.Equal(suite.T(), partitionResultFilteredEmptyFilter, filteredZones) + assert.Equal(suite.T(), partitionResultResidualEmptyFilter, residualZones) + + // Check filtered, residual zones when a single domain filter specified + filteredZones, residualZones = DomainFilterSingleClient.PartitionZones(zoneList) + assert.Equal(suite.T(), partitionResultFilteredSingleFilter, filteredZones) + assert.Equal(suite.T(), partitionResultResidualSingleFilter, residualZones) + + // Check filtered, residual zones when a multiple domain filter specified + filteredZones, residualZones = DomainFilterMultipleClient.PartitionZones(zoneList) + assert.Equal(suite.T(), partitionResultFilteredMultipleFilter, filteredZones) + assert.Equal(suite.T(), partitionResultResidualMultipleFilter, residualZones) +} + func TestNewPDNSProviderTestSuite(t *testing.T) { suite.Run(t, new(NewPDNSProviderTestSuite)) } diff --git a/provider/provider.go b/provider/provider.go index 23b54e1e2..7c6f1a61e 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -17,6 +17,7 @@ limitations under the License. package provider import ( + "context" "net" "strings" @@ -27,9 +28,20 @@ import ( // Provider defines the interface DNS providers should implement. type Provider interface { Records() ([]*endpoint.Endpoint, error) - ApplyChanges(changes *plan.Changes) error + ApplyChanges(ctx context.Context, changes *plan.Changes) error } +type contextKey struct { + name string +} + +func (k *contextKey) String() string { return "provider context value " + k.name } + +// RecordsContextKey is a context key. It can be used during ApplyChanges +// to access previously cached records. The associated value will be of +// type []*endpoint.Endpoint. +var RecordsContextKey = &contextKey{"records"} + // ensureTrailingDot ensures that the hostname receives a trailing dot if it hasn't already. func ensureTrailingDot(hostname string) string { if net.ParseIP(hostname) != nil { diff --git a/provider/rcode0.go b/provider/rcode0.go new file mode 100644 index 000000000..d9a0adcdb --- /dev/null +++ b/provider/rcode0.go @@ -0,0 +1,338 @@ +/* +Copyright 2019 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 provider + +import ( + "context" + "fmt" + "net/url" + "os" + "strings" + + "github.com/kubernetes-incubator/external-dns/endpoint" + "github.com/kubernetes-incubator/external-dns/plan" + rc0 "github.com/nic-at/rc0go" + log "github.com/sirupsen/logrus" +) + +// RcodeZeroProvider implements the DNS provider for RcodeZero Anycast DNS. +type RcodeZeroProvider struct { + Client *rc0.Client + + DomainFilter DomainFilter + DryRun bool + TXTEncrypt bool + Key []byte +} + +// NewRcodeZeroProvider creates a new RcodeZero Anycast DNS provider. +// +// Returns the provider or an error if a provider could not be created. +func NewRcodeZeroProvider(domainFilter DomainFilter, dryRun bool, txtEnc bool) (*RcodeZeroProvider, error) { + + client, err := rc0.NewClient(os.Getenv("RC0_API_KEY")) + + if err != nil { + return nil, err + } + + value := os.Getenv("RC0_BASE_URL") + if len(value) != 0 { + client.BaseURL, err = url.Parse(os.Getenv("RC0_BASE_URL")) + } + + if err != nil { + return nil, fmt.Errorf("failed to initialize rcodezero provider: %v", err) + } + + provider := &RcodeZeroProvider{ + Client: client, + DomainFilter: domainFilter, + DryRun: dryRun, + TXTEncrypt: txtEnc, + } + + if txtEnc { + provider.Key = []byte(os.Getenv("RC0_ENC_KEY")) + } + + return provider, nil +} + +// Zones returns filtered zones if filter is set +func (p *RcodeZeroProvider) Zones() ([]*rc0.Zone, error) { + + var result []*rc0.Zone + + zones, err := p.fetchZones() + if err != nil { + return nil, err + } + + for _, zone := range zones { + if p.DomainFilter.Match(zone.Domain) { + result = append(result, zone) + } + } + + return result, nil +} + +// Records returns resource records +// +// Decrypts TXT records if TXT-Encrypt flag is set and key is provided +func (p *RcodeZeroProvider) Records() ([]*endpoint.Endpoint, error) { + + zones, err := p.Zones() + if err != nil { + return nil, err + } + + var endpoints []*endpoint.Endpoint + + for _, zone := range zones { + + rrset, err := p.fetchRecords(zone.Domain) + + if err != nil { + return nil, err + } + + for _, r := range rrset { + + if supportedRecordType(r.Type) { + + if p.TXTEncrypt && (p.Key != nil) && strings.EqualFold(r.Type, "TXT") { + p.Client.RRSet.DecryptTXT(p.Key, r) + } + + if len(r.Records) > 1 { + + for _, _r := range r.Records { + if !_r.Disabled { + endpoints = append(endpoints, endpoint.NewEndpointWithTTL(r.Name, r.Type, endpoint.TTL(r.TTL), _r.Content)) + } + } + + } else { + if !r.Records[0].Disabled { + endpoints = append(endpoints, endpoint.NewEndpointWithTTL(r.Name, r.Type, endpoint.TTL(r.TTL), r.Records[0].Content)) + } + } + + } + } + } + + return endpoints, nil +} + +// ApplyChanges applies a given set of changes in a given zone. +func (p *RcodeZeroProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { + + combinedChanges := make([]*rc0.RRSetChange, 0, len(changes.Create)+len(changes.UpdateNew)+len(changes.Delete)) + + combinedChanges = append(combinedChanges, p.NewRcodezeroChanges(rc0.ChangeTypeADD, changes.Create)...) + combinedChanges = append(combinedChanges, p.NewRcodezeroChanges(rc0.ChangeTypeUPDATE, changes.UpdateNew)...) + combinedChanges = append(combinedChanges, p.NewRcodezeroChanges(rc0.ChangeTypeDELETE, changes.Delete)...) + + return p.submitChanges(combinedChanges) +} + +// Helper function +func rcodezeroChangesByZone(zones []*rc0.Zone, changeSet []*rc0.RRSetChange) map[string][]*rc0.RRSetChange { + + changes := make(map[string][]*rc0.RRSetChange) + zoneNameIDMapper := zoneIDName{} + for _, z := range zones { + zoneNameIDMapper.Add(z.Domain, z.Domain) + changes[z.Domain] = []*rc0.RRSetChange{} + } + + for _, c := range changeSet { + zone, _ := zoneNameIDMapper.FindZone(c.Name) + if zone == "" { + log.Debugf("Skipping record %s because no hosted zone matching record DNS Name was detected ", c.Name) + continue + } + changes[zone] = append(changes[zone], c) + } + + return changes +} + +// Helper function +func (p *RcodeZeroProvider) fetchRecords(zoneName string) ([]*rc0.RRType, error) { + + var allRecords []*rc0.RRType + + listOptions := rc0.NewListOptions() + + for { + records, page, err := p.Client.RRSet.List(zoneName, listOptions) + + if err != nil { + return nil, err + } + + allRecords = append(allRecords, records...) + + if page == nil || (page.CurrentPage == page.LastPage) { + break + } + + listOptions.SetPageNumber(page.CurrentPage + 1) + } + + return allRecords, nil +} + +// Helper function +func (p *RcodeZeroProvider) fetchZones() ([]*rc0.Zone, error) { + + var allZones []*rc0.Zone + + listOptions := rc0.NewListOptions() + + for { + zones, page, err := p.Client.Zones.List(listOptions) + if err != nil { + return nil, err + } + allZones = append(allZones, zones...) + + if page == nil || page.IsLastPage() { + break + } + + listOptions.SetPageNumber(page.CurrentPage + 1) + } + + return allZones, nil +} + +// Helper function to submit changes. +// +// Changes are submitted by change type. +func (p *RcodeZeroProvider) submitChanges(changes []*rc0.RRSetChange) error { + + if len(changes) == 0 { + return nil + } + + zones, err := p.Zones() + if err != nil { + return err + } + + // separate into per-zone change sets to be passed to the API. + changesByZone := rcodezeroChangesByZone(zones, changes) + + for zoneName, changes := range changesByZone { + + for _, change := range changes { + + logFields := log.Fields{ + "record": change.Name, + "content": change.Records[0].Content, + "type": change.Type, + "action": change.ChangeType, + "zone": zoneName, + } + + log.WithFields(logFields).Info("Changing record.") + + if p.DryRun { + continue + } + + // to avoid accidentally adding extra dot if already present + change.Name = strings.TrimSuffix(change.Name, ".") + "." + + switch change.ChangeType { + case rc0.ChangeTypeADD: + sr, err := p.Client.RRSet.Create(zoneName, []*rc0.RRSetChange{change}) + + if err != nil { + return err + } + + if sr.HasError() { + return fmt.Errorf("adding new RR resulted in an error: %v", sr.Message) + } + + case rc0.ChangeTypeUPDATE: + sr, err := p.Client.RRSet.Edit(zoneName, []*rc0.RRSetChange{change}) + + if err != nil { + return err + } + + if sr.HasError() { + return fmt.Errorf("updating existing RR resulted in an error: %v", sr.Message) + } + + case rc0.ChangeTypeDELETE: + sr, err := p.Client.RRSet.Delete(zoneName, []*rc0.RRSetChange{change}) + + if err != nil { + return err + } + + if sr.HasError() { + return fmt.Errorf("deleting existing RR resulted in an error: %v", sr.Message) + } + + default: + return fmt.Errorf("unsupported changeType submitted: %v", change.ChangeType) + } + } + } + return nil +} + +// NewRcodezeroChanges returns a RcodeZero specific array with rrset change objects. +func (p *RcodeZeroProvider) NewRcodezeroChanges(action string, endpoints []*endpoint.Endpoint) []*rc0.RRSetChange { + + changes := make([]*rc0.RRSetChange, 0, len(endpoints)) + + for _, _endpoint := range endpoints { + changes = append(changes, p.NewRcodezeroChange(action, _endpoint)) + } + + return changes +} + +// NewRcodezeroChange returns a RcodeZero specific rrset change object. +func (p *RcodeZeroProvider) NewRcodezeroChange(action string, endpoint *endpoint.Endpoint) *rc0.RRSetChange { + + change := &rc0.RRSetChange{ + Type: endpoint.RecordType, + ChangeType: action, + Name: endpoint.DNSName, + Records: []*rc0.Record{{ + Disabled: false, + Content: endpoint.Targets[0], + }}, + } + + if p.TXTEncrypt && (p.Key != nil) && strings.EqualFold(endpoint.RecordType, "TXT") { + p.Client.RRSet.EncryptTXT(p.Key, change) + } + + return change +} diff --git a/provider/rcode0_test.go b/provider/rcode0_test.go new file mode 100644 index 000000000..904d2a8e6 --- /dev/null +++ b/provider/rcode0_test.go @@ -0,0 +1,422 @@ +/* +Copyright 2019 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 provider + +import ( + "context" + "fmt" + "os" + "testing" + + "github.com/kubernetes-incubator/external-dns/endpoint" + "github.com/kubernetes-incubator/external-dns/plan" + rc0 "github.com/nic-at/rc0go" + "github.com/stretchr/testify/require" +) + +const ( + testZoneOne = "testzone1.at" + testZoneTwo = "testzone2.at" + + rrsetChangesUnsupportedChangeType = 0 +) + +type mockRcodeZeroClient rc0.Client + +type mockZoneManagementService struct { + TestNilZonesReturned bool + TestErrorReturned bool +} + +type mockRRSetService struct { + TestErrorReturned bool +} + +func (m *mockRcodeZeroClient) resetMockServices() { + m.Zones = &mockZoneManagementService{} + m.RRSet = &mockRRSetService{} +} + +func (m *mockZoneManagementService) resetTestConditions() { + m.TestNilZonesReturned = false + m.TestErrorReturned = false +} + +func (m *mockRRSetService) resetTestConditions() { + m.TestErrorReturned = false +} + +func TestRcodeZeroProvider_Records(t *testing.T) { + + mockRRSetService := &mockRRSetService{} + mockZoneManagementService := &mockZoneManagementService{} + + provider := &RcodeZeroProvider{ + Client: (*rc0.Client)(&mockRcodeZeroClient{ + Zones: mockZoneManagementService, + RRSet: mockRRSetService, + }), + } + + endpoints, err := provider.Records() // should return 6 rrs + + if err != nil { + t.Errorf("should not fail, %s", err) + } + require.Equal(t, 6, len(endpoints)) + + mockRRSetService.TestErrorReturned = true + + _, err = provider.Records() + if err == nil { + t.Errorf("expected to fail, %s", err) + } + +} + +func TestRcodeZeroProvider_ApplyChanges(t *testing.T) { + + mockRRSetService := &mockRRSetService{} + mockZoneManagementService := &mockZoneManagementService{} + + provider := &RcodeZeroProvider{ + Client: (*rc0.Client)(&mockRcodeZeroClient{ + Zones: mockZoneManagementService, + RRSet: mockRRSetService, + }), + DomainFilter: NewDomainFilter([]string{testZoneOne}), + } + + changes := mockChanges() + + err := provider.ApplyChanges(context.Background(), changes) + + if err != nil { + t.Errorf("should not fail, %s", err) + } + +} + +func TestRcodeZeroProvider_NewRcodezeroChanges(t *testing.T) { + + provider := &RcodeZeroProvider{} + + changes := mockChanges() + + createChanges := provider.NewRcodezeroChanges(testZoneOne, changes.Create) + require.Equal(t, 4, len(createChanges)) + + deleteChanges := provider.NewRcodezeroChanges(testZoneOne, changes.Delete) + require.Equal(t, 1, len(deleteChanges)) + + updateOldChanges := provider.NewRcodezeroChanges(testZoneOne, changes.UpdateOld) + require.Equal(t, 1, len(updateOldChanges)) + + updateNewChanges := provider.NewRcodezeroChanges(testZoneOne, changes.UpdateNew) + require.Equal(t, 1, len(updateNewChanges)) +} + +func TestRcodeZeroProvider_NewRcodezeroChange(t *testing.T) { + + _endpoint := &endpoint.Endpoint{ + RecordType: "A", + DNSName: "app." + testZoneOne, + RecordTTL: 300, + Targets: endpoint.Targets{"target"}, + } + + provider := &RcodeZeroProvider{} + + rrsetChange := provider.NewRcodezeroChange(testZoneOne, _endpoint) + + require.Equal(t, _endpoint.RecordType, rrsetChange.Type) + require.Equal(t, _endpoint.DNSName, rrsetChange.Name) + require.Equal(t, _endpoint.Targets[0], rrsetChange.Records[0].Content) + //require.Equal(t, endpoint.RecordTTL, rrsetChange.TTL) + +} + +func Test_submitChanges(t *testing.T) { + + mockRRSetService := &mockRRSetService{} + mockZoneManagementService := &mockZoneManagementService{} + + provider := &RcodeZeroProvider{ + Client: (*rc0.Client)(&mockRcodeZeroClient{ + Zones: mockZoneManagementService, + RRSet: mockRRSetService, + }), + DomainFilter: NewDomainFilter([]string{testZoneOne}), + } + + changes := mockRRSetChanges(rrsetChangesUnsupportedChangeType) + + err := provider.submitChanges(changes) + + if err == nil { + t.Errorf("expected to fail, %s", err) + } + +} + +func mockRRSetChanges(condition int) []*rc0.RRSetChange { + + switch condition { + case rrsetChangesUnsupportedChangeType: + return []*rc0.RRSetChange{ + { + Name: testZoneOne, + Type: "A", + ChangeType: "UNSUPPORTED", + Records: []*rc0.Record{{Content: "fail"}}, + }, + } + default: + return nil + } +} + +func mockChanges() *plan.Changes { + + changes := &plan.Changes{} + + changes.Create = []*endpoint.Endpoint{ + {DNSName: "new.ext-dns-test." + testZoneOne, Targets: endpoint.Targets{"target"}, RecordType: "A"}, + {DNSName: "new.ext-dns-test-with-ttl." + testZoneOne, Targets: endpoint.Targets{"target"}, RecordType: "A", RecordTTL: 100}, + {DNSName: "new.ext-dns-test.unexpected.com", Targets: endpoint.Targets{"target"}, RecordType: "AAAA"}, + {DNSName: testZoneOne, Targets: endpoint.Targets{"target"}, RecordType: "CNAME"}, + } + changes.Delete = []*endpoint.Endpoint{{DNSName: "foobar.ext-dns-test." + testZoneOne, Targets: endpoint.Targets{"target"}}} + changes.UpdateOld = []*endpoint.Endpoint{{DNSName: "foobar.ext-dns-test." + testZoneOne, Targets: endpoint.Targets{"target-old"}}} + changes.UpdateNew = []*endpoint.Endpoint{{DNSName: "foobar.ext-dns-test." + testZoneOne, Targets: endpoint.Targets{"target-new"}, RecordType: "CNAME", RecordTTL: 100}} + + return changes +} + +func TestRcodeZeroProvider_Zones(t *testing.T) { + + mockRRSetService := &mockRRSetService{} + mockZoneManagementService := &mockZoneManagementService{} + + provider := &RcodeZeroProvider{ + Client: (*rc0.Client)(&mockRcodeZeroClient{ + Zones: mockZoneManagementService, + RRSet: mockRRSetService, + }), + } + + mockZoneManagementService.TestNilZonesReturned = true + + zones, err := provider.Zones() + + if err != nil { + t.Fatal(err) + } + require.Equal(t, 0, len(zones)) + mockZoneManagementService.resetTestConditions() + + mockZoneManagementService.TestErrorReturned = true + + _, err = provider.Zones() + if err == nil { + t.Errorf("expected to fail, %s", err) + } + +} + +func TestNewRcodeZeroProvider(t *testing.T) { + + _ = os.Setenv("RC0_API_KEY", "123") + p, err := NewRcodeZeroProvider(NewDomainFilter([]string{"ext-dns-test." + testZoneOne + "."}), true, true) + + if err != nil { + t.Errorf("should not fail, %s", err) + } + + require.Equal(t, true, p.DryRun) + require.Equal(t, true, p.TXTEncrypt) + require.Equal(t, true, p.DomainFilter.IsConfigured()) + require.Equal(t, false, p.DomainFilter.Match("ext-dns-test."+testZoneTwo+".")) // filter is set, so it should match only provided domains + + p, err = NewRcodeZeroProvider(DomainFilter{}, false, false) + + if err != nil { + t.Errorf("should not fail, %s", err) + } + + require.Equal(t, false, p.DryRun) + require.Equal(t, false, p.DomainFilter.IsConfigured()) + require.Equal(t, true, p.DomainFilter.Match("ext-dns-test."+testZoneOne+".")) // filter is not set, so it should match any + + _ = os.Unsetenv("RC0_API_KEY") + _, err = NewRcodeZeroProvider(DomainFilter{}, false, false) + + if err == nil { + t.Errorf("expected to fail") + } + +} + +/* mocking mockRRSetServiceInterface */ + +func (m *mockRRSetService) List(zone string, options *rc0.ListOptions) ([]*rc0.RRType, *rc0.Page, error) { + + if m.TestErrorReturned { + return nil, nil, fmt.Errorf("operation RRSet.List failed") + } + + return mockRRSet(zone), nil, nil +} + +func mockRRSet(zone string) []*rc0.RRType { + return []*rc0.RRType{ + { + Name: "app." + zone + ".", + Type: "TXT", + TTL: 300, + Records: []*rc0.Record{ + { + Content: "\"heritage=external-dns,external-dns/owner=default,external-dns/resource=ingress/default/app\"", + Disabled: false, + }, + }, + }, + { + Name: "app." + zone + ".", + Type: "A", + TTL: 300, + Records: []*rc0.Record{ + { + Content: "127.0.0.1", + Disabled: false, + }, + }, + }, + { + Name: "www." + zone + ".", + Type: "A", + TTL: 300, + Records: []*rc0.Record{ + { + Content: "127.0.0.1", + Disabled: false, + }, + }, + }, + { + Name: zone + ".", + Type: "SOA", + TTL: 3600, + Records: []*rc0.Record{ + { + Content: "sec1.rcode0.net. rcodezero-soa.ipcom.at. 2019011616 10800 3600 604800 3600", + Disabled: false, + }, + }, + }, + { + Name: zone + ".", + Type: "NS", + TTL: 3600, + Records: []*rc0.Record{ + { + Content: "sec2.rcode0.net.", + Disabled: false, + }, + { + Content: "sec1.rcode0.net.", + Disabled: false, + }, + }, + }, + } +} + +func (m *mockRRSetService) Create(zone string, rrsetCreate []*rc0.RRSetChange) (*rc0.StatusResponse, error) { + + return &rc0.StatusResponse{Status: "ok", Message: "pass"}, nil + +} +func (m *mockRRSetService) Edit(zone string, rrsetEdit []*rc0.RRSetChange) (*rc0.StatusResponse, error) { + + return &rc0.StatusResponse{Status: "ok", Message: "pass"}, nil +} +func (m *mockRRSetService) Delete(zone string, rrsetDelete []*rc0.RRSetChange) (*rc0.StatusResponse, error) { + + return &rc0.StatusResponse{Status: "ok", Message: "pass"}, nil +} +func (m *mockRRSetService) SubmitChangeSet(zone string, changeSet []*rc0.RRSetChange) (*rc0.StatusResponse, error) { + + return &rc0.StatusResponse{Status: "ok", Message: "pass"}, nil +} + +func (m *mockRRSetService) EncryptTXT(key []byte, rrType *rc0.RRSetChange) {} + +func (m *mockRRSetService) DecryptTXT(key []byte, rrType *rc0.RRType) {} + +/* mocking ZoneManagementServiceInterface */ + +func (m *mockZoneManagementService) List(options *rc0.ListOptions) ([]*rc0.Zone, *rc0.Page, error) { + + if m.TestNilZonesReturned { + return nil, nil, nil + } + + if m.TestErrorReturned { + return nil, nil, fmt.Errorf("operation Zone.List failed") + } + + zones := []*rc0.Zone{ + { + Domain: testZoneOne, + Type: "SLAVE", + // "dnssec": "yes", @todo: add this + // "created": "2018-04-09T09:27:31Z", @todo: add this + LastCheck: "", + Serial: 20180411, + Masters: []string{ + "193.0.2.2", + "2001:db8::2", + }, + }, + { + Domain: testZoneTwo, + Type: "MASTER", + // "dnssec": "no", @todo: add this + // "created": "2019-01-15T13:20:10Z", @todo: add this + LastCheck: "", + Serial: 2019011616, + Masters: []string{ + "", + }, + }, + } + + return zones, nil, nil +} + +func (m *mockZoneManagementService) Get(zone string) (*rc0.Zone, error) { return nil, nil } +func (m *mockZoneManagementService) Create(zoneCreate *rc0.ZoneCreate) (*rc0.StatusResponse, error) { + return nil, nil +} +func (m *mockZoneManagementService) Edit(zone string, zoneEdit *rc0.ZoneEdit) (*rc0.StatusResponse, error) { + return nil, nil +} +func (m *mockZoneManagementService) Delete(zone string) (*rc0.StatusResponse, error) { return nil, nil } +func (m *mockZoneManagementService) Transfer(zone string) (*rc0.StatusResponse, error) { + return nil, nil +} diff --git a/provider/rfc2136.go b/provider/rfc2136.go index 7f9856bad..8d90d2b10 100644 --- a/provider/rfc2136.go +++ b/provider/rfc2136.go @@ -17,6 +17,7 @@ limitations under the License. package provider import ( + "context" "fmt" "net" "strconv" @@ -43,7 +44,7 @@ type rfc2136Provider struct { // only consider hosted zones managing domains ending in this suffix domainFilter DomainFilter dryRun bool - actions rfc1236Actions + actions rfc2136Actions } var ( @@ -56,15 +57,15 @@ var ( } ) -type rfc1236Actions interface { +type rfc2136Actions interface { SendMessage(msg *dns.Msg) error IncomeTransfer(m *dns.Msg, a string) (env chan *dns.Envelope, err error) } // NewRfc2136Provider is a factory function for OpenStack rfc2136 providers -func NewRfc2136Provider(host string, port int, zoneName string, insecure bool, keyName string, secret string, secretAlg string, axfr bool, domainFilter DomainFilter, dryRun bool, actions rfc1236Actions) (Provider, error) { +func NewRfc2136Provider(host string, port int, zoneName string, insecure bool, keyName string, secret string, secretAlg string, axfr bool, domainFilter DomainFilter, dryRun bool, actions rfc2136Actions) (Provider, error) { secretAlgChecked, ok := tsigAlgs[secretAlg] - if !ok { + if !ok && !insecure { return nil, errors.Errorf("%s is not supported TSIG algorithm", secretAlg) } @@ -161,7 +162,7 @@ func (r rfc2136Provider) IncomeTransfer(m *dns.Msg, a string) (env chan *dns.Env func (r rfc2136Provider) List() ([]dns.RR, error) { if !r.axfr { - log.Info("axfr is disabled") + log.Debug("axfr is disabled") return make([]dns.RR, 0), nil } @@ -195,7 +196,7 @@ func (r rfc2136Provider) List() ([]dns.RR, error) { } // ApplyChanges applies a given set of changes in a given zone. -func (r rfc2136Provider) ApplyChanges(changes *plan.Changes) error { +func (r rfc2136Provider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { log.Debugf("ApplyChanges") for _, ep := range changes.Create { @@ -240,25 +241,26 @@ func (r rfc2136Provider) UpdateRecord(ep *endpoint.Endpoint) error { func (r rfc2136Provider) AddRecord(ep *endpoint.Endpoint) error { log.Debugf("AddRecord.ep=%s", ep) + for _, target := range ep.Targets { + newRR := fmt.Sprintf("%s %d %s %s", ep.DNSName, ep.RecordTTL, ep.RecordType, target) + log.Debugf("Adding RR: %s", newRR) - newRR := fmt.Sprintf("%s %d %s %s", ep.DNSName, ep.RecordTTL, ep.RecordType, ep.Targets) - log.Debugf("Adding RR: %s", newRR) + rr, err := dns.NewRR(newRR) + if err != nil { + return fmt.Errorf("failed to build RR: %v", err) + } - rr, err := dns.NewRR(newRR) - if err != nil { - return fmt.Errorf("failed to build RR: %v", err) - } + rrs := make([]dns.RR, 1) + rrs[0] = rr - rrs := make([]dns.RR, 1) - rrs[0] = rr + m := new(dns.Msg) + m.SetUpdate(r.zoneName) + m.Insert(rrs) - m := new(dns.Msg) - m.SetUpdate(r.zoneName) - m.Insert(rrs) - - err = r.actions.SendMessage(m) - if err != nil { - return fmt.Errorf("RFC2136 query failed: %v", err) + err = r.actions.SendMessage(m) + if err != nil { + return fmt.Errorf("RFC2136 query failed: %v", err) + } } return nil @@ -268,7 +270,7 @@ func (r rfc2136Provider) RemoveRecord(ep *endpoint.Endpoint) error { log.Debugf("RemoveRecord.ep=%s", ep) newRR := fmt.Sprintf("%s 0 %s 0.0.0.0", ep.DNSName, ep.RecordType) - log.Debugf("Adding RR: %s", newRR) + log.Debugf("Removing RR: %s", newRR) rr, err := dns.NewRR(newRR) if err != nil { @@ -293,6 +295,7 @@ func (r rfc2136Provider) RemoveRecord(ep *endpoint.Endpoint) error { func (r rfc2136Provider) SendMessage(msg *dns.Msg) error { if r.dryRun { log.Debugf("SendMessage.skipped") + return nil } log.Debugf("SendMessage") diff --git a/provider/rfc2136_test.go b/provider/rfc2136_test.go index 63d10abb2..061fed497 100644 --- a/provider/rfc2136_test.go +++ b/provider/rfc2136_test.go @@ -17,6 +17,7 @@ limitations under the License. package provider import ( + "context" "strings" "testing" @@ -93,7 +94,7 @@ func createRfc2136StubProvider(stub *rfc2136Stub) (Provider, error) { return NewRfc2136Provider("", 0, "", false, "key", "secret", "hmac-sha512", true, DomainFilter{}, false, stub) } -func TestRfc1236GetRecords(t *testing.T) { +func TestRfc2136GetRecords(t *testing.T) { stub := newStub() err := stub.setOutput([]string{ "v4.barfoo.com 3600 TXT test1", @@ -149,7 +150,7 @@ func TestRfc2136ApplyChanges(t *testing.T) { }, } - err = provider.ApplyChanges(p) + err = provider.ApplyChanges(context.Background(), p) assert.NoError(t, err) assert.Equal(t, 2, len(stub.createMsgs)) diff --git a/provider/transip.go b/provider/transip.go new file mode 100644 index 000000000..1fff1daa2 --- /dev/null +++ b/provider/transip.go @@ -0,0 +1,374 @@ +package provider + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/kubernetes-incubator/external-dns/endpoint" + "github.com/kubernetes-incubator/external-dns/plan" + log "github.com/sirupsen/logrus" + "github.com/transip/gotransip" + transip "github.com/transip/gotransip/domain" +) + +const ( + // 60 seconds is the current minimal TTL for TransIP and will replace unconfigured + // TTL's for Endpoints + transipMinimalValidTTL = 60 +) + +// TransIPProvider is an implementation of Provider for TransIP. +type TransIPProvider struct { + client gotransip.SOAPClient + domainFilter DomainFilter + dryRun bool +} + +// NewTransIPProvider initializes a new TransIP Provider. +func NewTransIPProvider(accountName, privateKeyFile string, domainFilter DomainFilter, dryRun bool) (*TransIPProvider, error) { + // check given arguments + if accountName == "" { + return nil, errors.New("required --transip-account not set") + } + + if privateKeyFile == "" { + return nil, errors.New("required --transip-keyfile not set") + } + + var apiMode gotransip.APIMode + if dryRun { + apiMode = gotransip.APIModeReadOnly + } else { + apiMode = gotransip.APIModeReadWrite + } + + // create new TransIP API client + c, err := gotransip.NewSOAPClient(gotransip.ClientConfig{ + AccountName: accountName, + PrivateKeyPath: privateKeyFile, + Mode: apiMode, + }) + if err != nil { + return nil, fmt.Errorf("could not setup TransIP API client: %s", err.Error()) + } + + // return tipCloud struct + return &TransIPProvider{ + client: c, + domainFilter: domainFilter, + dryRun: dryRun, + }, nil +} + +// ApplyChanges applies a given set of changes in a given zone. +func (p *TransIPProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { + // build zonefinder with all our zones so we can use FindZone + // and a mapping of zones and their domain name + zones, err := p.fetchZones() + if err != nil { + return err + } + + zoneNameMapper := zoneIDName{} + zonesByName := make(map[string]transip.Domain) + updatedZones := make(map[string]bool) + for _, zone := range zones { + // TransIP API doesn't expose a unique identifier for zones, other than than + // the domain name itself + zoneNameMapper.Add(zone.Name, zone.Name) + zonesByName[zone.Name] = zone + } + + // first see if we need to delete anything + for _, ep := range changes.Delete { + log.WithFields(log.Fields{"record": ep.DNSName, "type": ep.RecordType}).Info("endpoint has to go") + + zone, err := p.zoneForZoneName(ep.DNSName, zoneNameMapper, zonesByName) + if err != nil { + log.Errorf("could not find zone for %s: %s", ep.DNSName, err.Error()) + continue + } + + log.Debugf("removing records for %s", zone.Name) + + // remove current records from DNS entry set + entries := p.removeEndpointFromEntries(ep, zone) + + // update zone in zone map + zone.DNSEntries = entries + zonesByName[zone.Name] = zone + // flag zone for updating + updatedZones[zone.Name] = true + } + + for _, ep := range changes.Create { + log.WithFields(log.Fields{"record": ep.DNSName, "type": ep.RecordType}).Info("endpoint is missing") + + zone, err := p.zoneForZoneName(ep.DNSName, zoneNameMapper, zonesByName) + if err != nil { + log.Errorf("could not find zone for %s: %s", ep.DNSName, err.Error()) + continue + } + + log.Debugf("creating records for %s", zone.Name) + + // add new entries to set + zone.DNSEntries = p.addEndpointToEntries(ep, zone, zone.DNSEntries) + + // update zone in zone map + zonesByName[zone.Name] = zone + // flag zone for updating + updatedZones[zone.Name] = true + log.WithFields(log.Fields{"zone": zone.Name}).Debug("flagging for update") + } + + for _, ep := range changes.UpdateNew { + log.WithFields(log.Fields{"record": ep.DNSName, "type": ep.RecordType}).Debug("needs updating") + + zone, err := p.zoneForZoneName(ep.DNSName, zoneNameMapper, zonesByName) + if err != nil { + log.WithFields(log.Fields{"record": ep.DNSName}).Warn(err.Error()) + continue + } + + // updating the records is basically finding all matching records according + // to the name and the type, removing them from the set and add the new + // records + log.WithFields(log.Fields{ + "zone": zone.Name, + "dnsname": ep.DNSName, + "recordtype": ep.RecordType, + }).Debug("removing matching entries") + + // remove current records from DNS entry set + entries := p.removeEndpointFromEntries(ep, zone) + + // add new entries to set + entries = p.addEndpointToEntries(ep, zone, entries) + + // check to see if actually anything changed in the DNSEntry set + if p.dnsEntriesAreEqual(entries, zone.DNSEntries) { + log.WithFields(log.Fields{"zone": zone.Name}).Debug("not updating identical entries") + continue + } + + // update zone in zone map + zone.DNSEntries = entries + zonesByName[zone.Name] = zone + // flag zone for updating + updatedZones[zone.Name] = true + + log.WithFields(log.Fields{"zone": zone.Name}).Debug("flagging for update") + } + + // go over all updated zones and set new DNSEntry set + for uz := range updatedZones { + zone, ok := zonesByName[uz] + if !ok { + log.WithFields(log.Fields{"zone": uz}).Debug("updated zone no longer found") + continue + } + + if p.dryRun { + log.WithFields(log.Fields{"zone": zone.Name}).Info("not updating in dry-run mode") + continue + } + + log.WithFields(log.Fields{"zone": zone.Name}).Info("updating DNS entries") + if err := transip.SetDNSEntries(p.client, zone.Name, zone.DNSEntries); err != nil { + log.WithFields(log.Fields{"zone": zone.Name, "error": err.Error()}).Warn("failed to update") + } + } + + return nil +} + +// fetchZones returns a list of all domains within the account +func (p *TransIPProvider) fetchZones() ([]transip.Domain, error) { + domainNames, err := transip.GetDomainNames(p.client) + if err != nil { + return nil, err + } + + domains, err := transip.BatchGetInfo(p.client, domainNames) + if err != nil { + return nil, err + } + + var zones []transip.Domain + for _, d := range domains { + if !p.domainFilter.Match(d.Name) { + continue + } + + zones = append(zones, d) + } + + return zones, nil +} + +// Zones returns the list of hosted zones. +func (p *TransIPProvider) Zones() ([]transip.Domain, error) { + zones, err := p.fetchZones() + if err != nil { + return nil, err + } + + return zones, nil +} + +// Records returns the list of records in a given zone. +func (p *TransIPProvider) Records() ([]*endpoint.Endpoint, error) { + zones, err := p.Zones() + if err != nil { + return nil, err + } + + var endpoints []*endpoint.Endpoint + var name string + // go over all zones and their DNS entries and create endpoints for them + for _, zone := range zones { + for _, r := range zone.DNSEntries { + if !supportedRecordType(string(r.Type)) { + continue + } + + name = p.endpointNameForRecord(r, zone) + endpoints = append(endpoints, endpoint.NewEndpointWithTTL(name, string(r.Type), endpoint.TTL(r.TTL), r.Content)) + } + } + + return endpoints, nil +} + +// endpointNameForRecord returns "www.example.org" for DNSEntry with Name "www" and +// Doman with Name "example.org" +func (p *TransIPProvider) endpointNameForRecord(r transip.DNSEntry, d transip.Domain) string { + // root name is identified by "@" and should be translated to domain name for + // the endpoint entry. + if r.Name == "@" { + return d.Name + } + + return fmt.Sprintf("%s.%s", r.Name, d.Name) +} + +// recordNameForEndpoint returns "www" for Endpoint with DNSName "www.example.org" +// and Domain with Name "example.org" +func (p *TransIPProvider) recordNameForEndpoint(ep *endpoint.Endpoint, d transip.Domain) string { + // root name is identified by "@" and should be translated to domain name for + // the endpoint entry. + if ep.DNSName == d.Name { + return "@" + } + + return strings.TrimSuffix(ep.DNSName, "."+d.Name) +} + +// getMinimalValidTTL returns max between given Endpoint's RecordTTL and +// transipMinimalValidTTL +func (p *TransIPProvider) getMinimalValidTTL(ep *endpoint.Endpoint) int64 { + // TTL cannot be lower than transipMinimalValidTTL + if ep.RecordTTL < transipMinimalValidTTL { + return transipMinimalValidTTL + } + + return int64(ep.RecordTTL) +} + +// dnsEntriesAreEqual compares the entries in 2 sets and returns true if the +// content of the entries is equal +func (p *TransIPProvider) dnsEntriesAreEqual(a, b transip.DNSEntries) bool { + if len(a) != len(b) { + return false + } + + match := 0 + for _, aa := range a { + for _, bb := range b { + if aa.Content != bb.Content { + continue + } + + if aa.Name != bb.Name { + continue + } + + if aa.TTL != bb.TTL { + continue + } + + if aa.Type != bb.Type { + continue + } + + match += 1 + } + } + + return (len(a) == match) +} + +// removeEndpointFromEntries removes DNS entries from zone's set that match the +// type and name from given endpoint and returns the resulting DNS entry set +func (p *TransIPProvider) removeEndpointFromEntries(ep *endpoint.Endpoint, zone transip.Domain) transip.DNSEntries { + // create new entry set + entries := transip.DNSEntries{} + // go over each DNS entry to see if it is a match + for _, e := range zone.DNSEntries { + // if we have match, don't copy it to the new entry set + if p.endpointNameForRecord(e, zone) == ep.DNSName && string(e.Type) == ep.RecordType { + log.WithFields(log.Fields{ + "name": e.Name, + "content": e.Content, + "type": e.Type, + }).Debug("found match") + continue + } + + entries = append(entries, e) + } + + return entries +} + +// addEndpointToEntries creates DNS entries for given endpoint and returns +// resulting DNS entry set +func (p *TransIPProvider) addEndpointToEntries(ep *endpoint.Endpoint, zone transip.Domain, entries transip.DNSEntries) transip.DNSEntries { + ttl := p.getMinimalValidTTL(ep) + for _, target := range ep.Targets { + log.WithFields(log.Fields{ + "zone": zone.Name, + "dnsname": ep.DNSName, + "recordtype": ep.RecordType, + "ttl": ttl, + "target": target, + }).Debugf("adding new record") + entries = append(entries, transip.DNSEntry{ + Name: p.recordNameForEndpoint(ep, zone), + TTL: ttl, + Type: transip.DNSEntryType(ep.RecordType), + Content: target, + }) + } + + return entries +} + +// zoneForZoneName returns the zone mapped to given name or error if zone could +// not be found +func (p *TransIPProvider) zoneForZoneName(name string, m zoneIDName, z map[string]transip.Domain) (transip.Domain, error) { + _, zoneName := m.FindZone(name) + if zoneName == "" { + return transip.Domain{}, fmt.Errorf("could not find zoneName for %s", name) + } + + zone, ok := z[zoneName] + if !ok { + return zone, fmt.Errorf("could not find zone for %s", zoneName) + } + + return zone, nil +} diff --git a/provider/transip_test.go b/provider/transip_test.go new file mode 100644 index 000000000..b195a246d --- /dev/null +++ b/provider/transip_test.go @@ -0,0 +1,215 @@ +package provider + +import ( + "testing" + + "github.com/kubernetes-incubator/external-dns/endpoint" + "github.com/stretchr/testify/assert" + transip "github.com/transip/gotransip/domain" +) + +func TestTransIPDnsEntriesAreEqual(t *testing.T) { + p := TransIPProvider{} + // test with equal set + a := transip.DNSEntries{ + transip.DNSEntry{ + Name: "www.example.org", + Type: transip.DNSEntryTypeCNAME, + TTL: 3600, + Content: "www.example.com", + }, + transip.DNSEntry{ + Name: "www.example.com", + Type: transip.DNSEntryTypeA, + TTL: 3600, + Content: "192.168.0.1", + }, + } + + b := transip.DNSEntries{ + transip.DNSEntry{ + Name: "www.example.com", + Type: transip.DNSEntryTypeA, + TTL: 3600, + Content: "192.168.0.1", + }, + transip.DNSEntry{ + Name: "www.example.org", + Type: transip.DNSEntryTypeCNAME, + TTL: 3600, + Content: "www.example.com", + }, + } + + assert.Equal(t, true, p.dnsEntriesAreEqual(a, b)) + + // change type on one of b's records + b[1].Type = transip.DNSEntryTypeNS + assert.Equal(t, false, p.dnsEntriesAreEqual(a, b)) + b[1].Type = transip.DNSEntryTypeCNAME + + // change ttl on one of b's records + b[1].TTL = 1800 + assert.Equal(t, false, p.dnsEntriesAreEqual(a, b)) + b[1].TTL = 3600 + + // change name on one of b's records + b[1].Name = "example.org" + assert.Equal(t, false, p.dnsEntriesAreEqual(a, b)) + + // remove last entry of b + b = b[:1] + assert.Equal(t, false, p.dnsEntriesAreEqual(a, b)) +} + +func TestTransIPGetMinimalValidTTL(t *testing.T) { + p := TransIPProvider{} + // test with 'unconfigured' TTL + ep := &endpoint.Endpoint{} + assert.Equal(t, int64(transipMinimalValidTTL), p.getMinimalValidTTL(ep)) + + // test with lower than minimal ttl + ep.RecordTTL = (transipMinimalValidTTL - 1) + assert.Equal(t, int64(transipMinimalValidTTL), p.getMinimalValidTTL(ep)) + + // test with higher than minimal ttl + ep.RecordTTL = (transipMinimalValidTTL + 1) + assert.Equal(t, int64(transipMinimalValidTTL+1), p.getMinimalValidTTL(ep)) +} + +func TestTransIPRecordNameForEndpoint(t *testing.T) { + p := TransIPProvider{} + ep := &endpoint.Endpoint{ + DNSName: "example.org", + } + d := transip.Domain{ + Name: "example.org", + } + + assert.Equal(t, "@", p.recordNameForEndpoint(ep, d)) + + ep.DNSName = "www.example.org" + assert.Equal(t, "www", p.recordNameForEndpoint(ep, d)) +} + +func TestTransIPEndpointNameForRecord(t *testing.T) { + p := TransIPProvider{} + r := transip.DNSEntry{ + Name: "@", + } + d := transip.Domain{ + Name: "example.org", + } + + assert.Equal(t, d.Name, p.endpointNameForRecord(r, d)) + + r.Name = "www" + assert.Equal(t, "www.example.org", p.endpointNameForRecord(r, d)) +} + +func TestTransIPAddEndpointToEntries(t *testing.T) { + p := TransIPProvider{} + + // prepare endpoint + ep := &endpoint.Endpoint{ + DNSName: "www.example.org", + RecordType: "A", + RecordTTL: 1800, + Targets: []string{ + "192.168.0.1", + "192.168.0.2", + }, + } + + // prepare zone with DNS entry set + zone := transip.Domain{ + Name: "example.org", + // 2 matching A records + DNSEntries: transip.DNSEntries{ + // 1 non-matching A record + transip.DNSEntry{ + Name: "mail", + Type: transip.DNSEntryTypeA, + Content: "192.168.0.1", + TTL: 3600, + }, + // 1 non-matching MX record + transip.DNSEntry{ + Name: "@", + Type: transip.DNSEntryTypeMX, + Content: "mail.example.org", + TTL: 3600, + }, + }, + } + + // add endpoint to zone's entries + result := p.addEndpointToEntries(ep, zone, zone.DNSEntries) + + assert.Equal(t, 4, len(result)) + assert.Equal(t, "mail", result[0].Name) + assert.Equal(t, transip.DNSEntryTypeA, result[0].Type) + assert.Equal(t, "@", result[1].Name) + assert.Equal(t, transip.DNSEntryTypeMX, result[1].Type) + assert.Equal(t, "www", result[2].Name) + assert.Equal(t, transip.DNSEntryTypeA, result[2].Type) + assert.Equal(t, "192.168.0.1", result[2].Content) + assert.Equal(t, int64(1800), result[2].TTL) + assert.Equal(t, "www", result[3].Name) + assert.Equal(t, transip.DNSEntryTypeA, result[3].Type) + assert.Equal(t, "192.168.0.2", result[3].Content) + assert.Equal(t, int64(1800), result[3].TTL) +} + +func TestTransIPRemoveEndpointFromEntries(t *testing.T) { + p := TransIPProvider{} + + // prepare endpoint + ep := &endpoint.Endpoint{ + DNSName: "www.example.org", + RecordType: "A", + } + + // prepare zone with DNS entry set + zone := transip.Domain{ + Name: "example.org", + // 2 matching A records + DNSEntries: transip.DNSEntries{ + transip.DNSEntry{ + Name: "www", + Type: transip.DNSEntryTypeA, + Content: "192.168.0.1", + TTL: 3600, + }, + transip.DNSEntry{ + Name: "www", + Type: transip.DNSEntryTypeA, + Content: "192.168.0.2", + TTL: 3600, + }, + // 1 non-matching A record + transip.DNSEntry{ + Name: "mail", + Type: transip.DNSEntryTypeA, + Content: "192.168.0.1", + TTL: 3600, + }, + // 1 non-matching MX record + transip.DNSEntry{ + Name: "@", + Type: transip.DNSEntryTypeMX, + Content: "mail.example.org", + TTL: 3600, + }, + }, + } + + // remove endpoint from zone's entries + result := p.removeEndpointFromEntries(ep, zone) + + assert.Equal(t, 2, len(result)) + assert.Equal(t, "mail", result[0].Name) + assert.Equal(t, transip.DNSEntryTypeA, result[0].Type) + assert.Equal(t, "@", result[1].Name) + assert.Equal(t, transip.DNSEntryTypeMX, result[1].Type) +} diff --git a/provider/zone_tag_filter.go b/provider/zone_tag_filter.go new file mode 100644 index 000000000..c40ab06e9 --- /dev/null +++ b/provider/zone_tag_filter.go @@ -0,0 +1,57 @@ +/* +Copyright 2017 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 provider + +import ( + "strings" +) + +// ZoneTagFilter holds a list of zone tags to filter by +type ZoneTagFilter struct { + zoneTags []string +} + +// NewZoneTagFilter returns a new ZoneTagFilter given a list of zone tags +func NewZoneTagFilter(tags []string) ZoneTagFilter { + if len(tags) == 1 && len(tags[0]) == 0 { + tags = []string{} + } + return ZoneTagFilter{zoneTags: tags} +} + +// Match checks whether a zone's set of tags matches the provided tag values +func (f ZoneTagFilter) Match(tagsMap map[string]string) bool { + for _, tagFilter := range f.zoneTags { + filterParts := strings.SplitN(tagFilter, "=", 2) + switch len(filterParts) { + case 1: + if _, hasTag := tagsMap[filterParts[0]]; !hasTag { + return false + } + case 2: + if value, hasTag := tagsMap[filterParts[0]]; !hasTag || value != filterParts[1] { + return false + } + } + } + return true +} + +// IsEmpty returns true if there are no tags for the filter +func (f ZoneTagFilter) IsEmpty() bool { + return len(f.zoneTags) == 0 +} diff --git a/provider/zone_tag_filter_test.go b/provider/zone_tag_filter_test.go new file mode 100644 index 000000000..9574e68eb --- /dev/null +++ b/provider/zone_tag_filter_test.go @@ -0,0 +1,62 @@ +/* +Copyright 2017 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 provider + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestZoneTagFilterMatch(t *testing.T) { + for _, tc := range []struct { + name string + zoneTagFilter []string + zoneTags map[string]string + matches bool + }{ + { + "single tag no match", []string{"tag1=value1"}, map[string]string{"tag0": "value0"}, false, + }, + { + "single tag matches", []string{"tag1=value1"}, map[string]string{"tag1": "value1"}, true, + }, + { + "multiple tags no value match", []string{"tag1=value1"}, map[string]string{"tag0": "value0", "tag1": "value2"}, false, + }, + { + "multiple tags matches", []string{"tag1=value1"}, map[string]string{"tag0": "value0", "tag1": "value1"}, true, + }, + { + "tag name no match", []string{"tag1"}, map[string]string{"tag0": "value0"}, false, + }, + { + "tag name matches", []string{"tag1"}, map[string]string{"tag1": "value1"}, true, + }, + { + "multiple filter no match", []string{"tag1=value1", "tag2=value2"}, map[string]string{"tag1": "value1"}, false, + }, + { + "multiple filter matches", []string{"tag1=value1", "tag2=value2"}, map[string]string{"tag2": "value2", "tag1": "value1", "tag3": "value3"}, true, + }, + } { + zoneTagFilter := NewZoneTagFilter(tc.zoneTagFilter) + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.matches, zoneTagFilter.Match(tc.zoneTags)) + }) + } +} diff --git a/registry/aws_sd_registry.go b/registry/aws_sd_registry.go index 52c4b4271..64cd95b34 100644 --- a/registry/aws_sd_registry.go +++ b/registry/aws_sd_registry.go @@ -17,6 +17,7 @@ limitations under the License. package registry import ( + "context" "errors" "github.com/kubernetes-incubator/external-dns/endpoint" @@ -64,7 +65,7 @@ func (sdr *AWSSDRegistry) Records() ([]*endpoint.Endpoint, error) { // ApplyChanges filters out records not owned the External-DNS, additionally it adds the required label // inserted in the AWS SD instance as a CreateID field -func (sdr *AWSSDRegistry) ApplyChanges(changes *plan.Changes) error { +func (sdr *AWSSDRegistry) ApplyChanges(ctx context.Context, changes *plan.Changes) error { filteredChanges := &plan.Changes{ Create: changes.Create, UpdateNew: filterOwnedRecords(sdr.ownerID, changes.UpdateNew), @@ -77,7 +78,7 @@ func (sdr *AWSSDRegistry) ApplyChanges(changes *plan.Changes) error { sdr.updateLabels(filteredChanges.UpdateOld) sdr.updateLabels(filteredChanges.Delete) - return sdr.provider.ApplyChanges(filteredChanges) + return sdr.provider.ApplyChanges(ctx, filteredChanges) } func (sdr *AWSSDRegistry) updateLabels(endpoints []*endpoint.Endpoint) { diff --git a/registry/aws_sd_registry_test.go b/registry/aws_sd_registry_test.go index 938fec5c6..6ebda2e01 100644 --- a/registry/aws_sd_registry_test.go +++ b/registry/aws_sd_registry_test.go @@ -17,6 +17,7 @@ limitations under the License. package registry import ( + "context" "testing" "github.com/kubernetes-incubator/external-dns/endpoint" @@ -35,7 +36,7 @@ func (p *inMemoryProvider) Records() ([]*endpoint.Endpoint, error) { return p.endpoints, nil } -func (p *inMemoryProvider) ApplyChanges(changes *plan.Changes) error { +func (p *inMemoryProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { p.onApplyChanges(changes) return nil } @@ -151,7 +152,7 @@ func TestAWSSDRegistry_Records_ApplyChanges(t *testing.T) { r, err := NewAWSSDRegistry(p, "owner") require.NoError(t, err) - err = r.ApplyChanges(changes) + err = r.ApplyChanges(context.Background(), changes) require.NoError(t, err) } diff --git a/registry/noop.go b/registry/noop.go index aadc801a5..701f01c4e 100644 --- a/registry/noop.go +++ b/registry/noop.go @@ -17,6 +17,8 @@ limitations under the License. package registry import ( + "context" + "github.com/kubernetes-incubator/external-dns/endpoint" "github.com/kubernetes-incubator/external-dns/plan" "github.com/kubernetes-incubator/external-dns/provider" @@ -40,6 +42,6 @@ func (im *NoopRegistry) Records() ([]*endpoint.Endpoint, error) { } // ApplyChanges propagates changes to the dns provider -func (im *NoopRegistry) ApplyChanges(changes *plan.Changes) error { - return im.provider.ApplyChanges(changes) +func (im *NoopRegistry) ApplyChanges(ctx context.Context, changes *plan.Changes) error { + return im.provider.ApplyChanges(ctx, changes) } diff --git a/registry/noop_test.go b/registry/noop_test.go index d728fad65..c1688503c 100644 --- a/registry/noop_test.go +++ b/registry/noop_test.go @@ -17,6 +17,7 @@ limitations under the License. package registry import ( + "context" "testing" "github.com/kubernetes-incubator/external-dns/endpoint" @@ -53,7 +54,7 @@ func testNoopRecords(t *testing.T) { RecordType: endpoint.RecordTypeCNAME, }, } - p.ApplyChanges(&plan.Changes{ + p.ApplyChanges(context.Background(), &plan.Changes{ Create: providerRecords, }) @@ -88,13 +89,14 @@ func testNoopApplyChanges(t *testing.T) { }, } - p.ApplyChanges(&plan.Changes{ + ctx := context.Background() + p.ApplyChanges(ctx, &plan.Changes{ Create: providerRecords, }) // wrong changes r, _ := NewNoopRegistry(p) - err := r.ApplyChanges(&plan.Changes{ + err := r.ApplyChanges(ctx, &plan.Changes{ Create: []*endpoint.Endpoint{ { DNSName: "example.org", @@ -106,7 +108,7 @@ func testNoopApplyChanges(t *testing.T) { assert.EqualError(t, err, provider.ErrRecordAlreadyExists.Error()) //correct changes - require.NoError(t, r.ApplyChanges(&plan.Changes{ + require.NoError(t, r.ApplyChanges(ctx, &plan.Changes{ Create: []*endpoint.Endpoint{ { DNSName: "new-record.org", diff --git a/registry/registry.go b/registry/registry.go index 528d4ecff..71e926341 100644 --- a/registry/registry.go +++ b/registry/registry.go @@ -17,6 +17,8 @@ limitations under the License. package registry import ( + "context" + "github.com/kubernetes-incubator/external-dns/endpoint" "github.com/kubernetes-incubator/external-dns/plan" log "github.com/sirupsen/logrus" @@ -28,7 +30,7 @@ import ( // ApplyChanges(changes *plan.Changes) propagates the changes to the DNS Provider API and correspondingly updates ownership depending on type of registry being used type Registry interface { Records() ([]*endpoint.Endpoint, error) - ApplyChanges(changes *plan.Changes) error + ApplyChanges(ctx context.Context, changes *plan.Changes) error } //TODO(ideahitme): consider moving this to Plan diff --git a/registry/txt.go b/registry/txt.go index 528930850..5c5c74663 100644 --- a/registry/txt.go +++ b/registry/txt.go @@ -17,6 +17,7 @@ limitations under the License. package registry import ( + "context" "errors" "time" @@ -117,7 +118,7 @@ func (im *TXTRegistry) Records() ([]*endpoint.Endpoint, error) { // ApplyChanges updates dns provider with the changes // for each created/deleted record it will also take into account TXT records for creation/deletion -func (im *TXTRegistry) ApplyChanges(changes *plan.Changes) error { +func (im *TXTRegistry) ApplyChanges(ctx context.Context, changes *plan.Changes) error { filteredChanges := &plan.Changes{ Create: changes.Create, UpdateNew: filterOwnedRecords(im.ownerID, changes.UpdateNew), @@ -171,7 +172,11 @@ func (im *TXTRegistry) ApplyChanges(changes *plan.Changes) error { } } - return im.provider.ApplyChanges(filteredChanges) + // when caching is enabled, disable the provider from using the cache + if im.cacheInterval > 0 { + ctx = context.WithValue(ctx, provider.RecordsContextKey, nil) + } + return im.provider.ApplyChanges(ctx, filteredChanges) } /** diff --git a/registry/txt_test.go b/registry/txt_test.go index 6ead32165..4489fc864 100644 --- a/registry/txt_test.go +++ b/registry/txt_test.go @@ -17,6 +17,7 @@ limitations under the License. package registry import ( + "context" "reflect" "testing" "time" @@ -68,7 +69,7 @@ func testTXTRegistryRecords(t *testing.T) { func testTXTRegistryRecordsPrefixed(t *testing.T) { p := provider.NewInMemoryProvider() p.CreateZone(testZone) - p.ApplyChanges(&plan.Changes{ + p.ApplyChanges(context.Background(), &plan.Changes{ Create: []*endpoint.Endpoint{ newEndpointWithOwner("foo.test-zone.example.org", "foo.loadbalancer.com", endpoint.RecordTypeCNAME, ""), newEndpointWithOwner("bar.test-zone.example.org", "my-domain.com", endpoint.RecordTypeCNAME, ""), @@ -141,7 +142,7 @@ func testTXTRegistryRecordsPrefixed(t *testing.T) { func testTXTRegistryRecordsNoPrefix(t *testing.T) { p := provider.NewInMemoryProvider() p.CreateZone(testZone) - p.ApplyChanges(&plan.Changes{ + p.ApplyChanges(context.Background(), &plan.Changes{ Create: []*endpoint.Endpoint{ newEndpointWithOwner("foo.test-zone.example.org", "foo.loadbalancer.com", endpoint.RecordTypeCNAME, ""), newEndpointWithOwner("bar.test-zone.example.org", "my-domain.com", endpoint.RecordTypeCNAME, ""), @@ -220,7 +221,12 @@ func testTXTRegistryApplyChanges(t *testing.T) { func testTXTRegistryApplyChangesWithPrefix(t *testing.T) { p := provider.NewInMemoryProvider() p.CreateZone(testZone) - p.ApplyChanges(&plan.Changes{ + ctxEndpoints := []*endpoint.Endpoint{} + ctx := context.WithValue(context.Background(), provider.RecordsContextKey, ctxEndpoints) + p.OnApplyChanges = func(ctx context.Context, got *plan.Changes) { + assert.Equal(t, ctxEndpoints, ctx.Value(provider.RecordsContextKey)) + } + p.ApplyChanges(ctx, &plan.Changes{ Create: []*endpoint.Endpoint{ newEndpointWithOwner("foo.test-zone.example.org", "foo.loadbalancer.com", endpoint.RecordTypeCNAME, ""), newEndpointWithOwner("bar.test-zone.example.org", "my-domain.com", endpoint.RecordTypeCNAME, ""), @@ -267,7 +273,7 @@ func testTXTRegistryApplyChangesWithPrefix(t *testing.T) { newEndpointWithOwner("txt.tar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), }, } - p.OnApplyChanges = func(got *plan.Changes) { + p.OnApplyChanges = func(ctx context.Context, got *plan.Changes) { mExpected := map[string][]*endpoint.Endpoint{ "Create": expected.Create, "UpdateNew": expected.UpdateNew, @@ -281,15 +287,21 @@ func testTXTRegistryApplyChangesWithPrefix(t *testing.T) { "Delete": got.Delete, } assert.True(t, testutils.SamePlanChanges(mGot, mExpected)) + assert.Equal(t, nil, ctx.Value(provider.RecordsContextKey)) } - err := r.ApplyChanges(changes) + err := r.ApplyChanges(ctx, changes) require.NoError(t, err) } func testTXTRegistryApplyChangesNoPrefix(t *testing.T) { p := provider.NewInMemoryProvider() p.CreateZone(testZone) - p.ApplyChanges(&plan.Changes{ + ctxEndpoints := []*endpoint.Endpoint{} + ctx := context.WithValue(context.Background(), provider.RecordsContextKey, ctxEndpoints) + p.OnApplyChanges = func(ctx context.Context, got *plan.Changes) { + assert.Equal(t, ctxEndpoints, ctx.Value(provider.RecordsContextKey)) + } + p.ApplyChanges(ctx, &plan.Changes{ Create: []*endpoint.Endpoint{ newEndpointWithOwner("foo.test-zone.example.org", "foo.loadbalancer.com", endpoint.RecordTypeCNAME, ""), newEndpointWithOwner("bar.test-zone.example.org", "my-domain.com", endpoint.RecordTypeCNAME, ""), @@ -330,7 +342,7 @@ func testTXTRegistryApplyChangesNoPrefix(t *testing.T) { UpdateNew: []*endpoint.Endpoint{}, UpdateOld: []*endpoint.Endpoint{}, } - p.OnApplyChanges = func(got *plan.Changes) { + p.OnApplyChanges = func(ctx context.Context, got *plan.Changes) { mExpected := map[string][]*endpoint.Endpoint{ "Create": expected.Create, "UpdateNew": expected.UpdateNew, @@ -344,8 +356,9 @@ func testTXTRegistryApplyChangesNoPrefix(t *testing.T) { "Delete": got.Delete, } assert.True(t, testutils.SamePlanChanges(mGot, mExpected)) + assert.Equal(t, nil, ctx.Value(provider.RecordsContextKey)) } - err := r.ApplyChanges(changes) + err := r.ApplyChanges(ctx, changes) require.NoError(t, err) } diff --git a/source/cloudfoundry.go b/source/cloudfoundry.go new file mode 100644 index 000000000..8d8270c4c --- /dev/null +++ b/source/cloudfoundry.go @@ -0,0 +1,59 @@ +/* +Copyright 2019 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 source + +import ( + "net/url" + + cfclient "github.com/cloudfoundry-community/go-cfclient" + "github.com/kubernetes-incubator/external-dns/endpoint" +) + +type cloudfoundrySource struct { + client *cfclient.Client + config Config +} + +// NewCloudFoundrySource creates a new cloudfoundrySource with the given config +func NewCloudFoundrySource(cfClient *cfclient.Client) (Source, error) { + return &cloudfoundrySource{ + client: cfClient, + }, nil +} + +// Endpoints returns endpoint objects +func (rs *cloudfoundrySource) Endpoints() ([]*endpoint.Endpoint, error) { + endpoints := []*endpoint.Endpoint{} + + u, err := url.Parse(rs.client.Config.ApiAddress) + if err != nil { + panic(err) + } + + domains, _ := rs.client.ListDomains() + for _, domain := range domains { + q := url.Values{} + q.Set("q", "domain_guid:"+domain.Guid) + routes, _ := rs.client.ListRoutesByQuery(q) + for _, element := range routes { + endpoints = append(endpoints, + endpoint.NewEndpointWithTTL(element.Host+"."+domain.Name, endpoint.RecordTypeCNAME, 300, u.Host)) + } + } + + return endpoints, nil +} diff --git a/source/cloudfoundry_test.go b/source/cloudfoundry_test.go new file mode 100644 index 000000000..59efc8e6e --- /dev/null +++ b/source/cloudfoundry_test.go @@ -0,0 +1,38 @@ +/* +Copyright 2019 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 source + +import ( + "testing" + + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +type RouteSuite struct { + suite.Suite +} + +func TestRouteSource(t *testing.T) { + suite.Run(t, new(RouteSuite)) + t.Run("Interface", testRouteSourceImplementsSource) +} + +// testRouteSourceImplementsSource tests that cloudfoundrySource is a valid Source. +func testRouteSourceImplementsSource(t *testing.T) { + require.Implements(t, (*Source)(nil), new(cloudfoundrySource)) +} diff --git a/source/compatibility.go b/source/compatibility.go index b4398b0d1..e25184d04 100644 --- a/source/compatibility.go +++ b/source/compatibility.go @@ -19,9 +19,8 @@ package source import ( "strings" - "k8s.io/api/core/v1" - "github.com/kubernetes-incubator/external-dns/endpoint" + v1 "k8s.io/api/core/v1" ) const ( diff --git a/source/crd.go b/source/crd.go index 79b308ad4..4c5a2a698 100644 --- a/source/crd.go +++ b/source/crd.go @@ -118,6 +118,11 @@ func (cs *crdSource) Endpoints() ([]*endpoint.Endpoint, error) { for _, dnsEndpoint := range result.Items { endpoints = append(endpoints, dnsEndpoint.Spec.Endpoints...) + + if dnsEndpoint.Status.ObservedGeneration == dnsEndpoint.Generation { + continue + } + dnsEndpoint.Status.ObservedGeneration = dnsEndpoint.Generation // Update the ObservedGeneration _, err = cs.UpdateStatus(&dnsEndpoint) diff --git a/source/crd_test.go b/source/crd_test.go index a069f2114..928a35685 100644 --- a/source/crd_test.go +++ b/source/crd_test.go @@ -18,6 +18,7 @@ package source import ( "bytes" + "encoding/json" "fmt" "io" "io/ioutil" @@ -54,20 +55,21 @@ func objBody(codec runtime.Codec, obj runtime.Object) io.ReadCloser { return ioutil.NopCloser(bytes.NewReader([]byte(runtime.EncodeOrDie(codec, obj)))) } -func startCRDServerToServeTargets(endpoints []*endpoint.Endpoint, apiVersion, kind, namespace, name string) rest.Interface { +func startCRDServerToServeTargets(endpoints []*endpoint.Endpoint, apiVersion, kind, namespace, name string, t *testing.T) rest.Interface { groupVersion, _ := schema.ParseGroupVersion(apiVersion) scheme := runtime.NewScheme() addKnownTypes(scheme, groupVersion) dnsEndpointList := endpoint.DNSEndpointList{} - dnsEndpoint := endpoint.DNSEndpoint{ + dnsEndpoint := &endpoint.DNSEndpoint{ TypeMeta: metav1.TypeMeta{ APIVersion: apiVersion, Kind: kind, }, ObjectMeta: metav1.ObjectMeta{ - Name: name, - Namespace: namespace, + Name: name, + Namespace: namespace, + Generation: 1, }, Spec: endpoint.DNSEndpointSpec{ Endpoints: endpoints, @@ -88,10 +90,18 @@ func startCRDServerToServeTargets(endpoints []*endpoint.Endpoint, apiVersion, ki case p == "/apis/"+apiVersion+"/"+strings.ToLower(kind)+"s" && m == http.MethodGet: fallthrough case p == "/apis/"+apiVersion+"/namespaces/"+namespace+"/"+strings.ToLower(kind)+"s" && m == http.MethodGet: - dnsEndpointList.Items = append(dnsEndpointList.Items, dnsEndpoint) + dnsEndpointList.Items = dnsEndpointList.Items[:0] + dnsEndpointList.Items = append(dnsEndpointList.Items, *dnsEndpoint) return &http.Response{StatusCode: http.StatusOK, Header: defaultHeader(), Body: objBody(codec, &dnsEndpointList)}, nil case strings.HasPrefix(p, "/apis/"+apiVersion+"/namespaces/") && strings.HasSuffix(p, strings.ToLower(kind)+"s") && m == http.MethodGet: return &http.Response{StatusCode: http.StatusOK, Header: defaultHeader(), Body: objBody(codec, &dnsEndpointList)}, nil + case p == "/apis/"+apiVersion+"/namespaces/"+namespace+"/"+strings.ToLower(kind)+"s/"+name+"/status" && m == http.MethodPut: + decoder := json.NewDecoder(req.Body) + + var body endpoint.DNSEndpoint + decoder.Decode(&body) + dnsEndpoint.Status.ObservedGeneration = body.Status.ObservedGeneration + return &http.Response{StatusCode: http.StatusOK, Header: defaultHeader(), Body: objBody(codec, dnsEndpoint)}, nil default: return nil, fmt.Errorf("unexpected request: %#v\n%#v", req.URL, req) } @@ -200,6 +210,8 @@ func testCRDSourceEndpoints(t *testing.T) { apiVersion: "test.k8s.io/v1alpha1", registeredKind: "DNSEndpoint", kind: "DNSEndpoint", + namespace: "foo", + registeredNamespace: "foo", endpoints: []*endpoint.Endpoint{ {DNSName: "abc.example.org", Targets: endpoint.Targets{"1.2.3.4"}, @@ -216,6 +228,8 @@ func testCRDSourceEndpoints(t *testing.T) { apiVersion: "test.k8s.io/v1alpha1", registeredKind: "DNSEndpoint", kind: "DNSEndpoint", + namespace: "foo", + registeredNamespace: "foo", endpoints: []*endpoint.Endpoint{ {DNSName: "abc.example.org", Targets: endpoint.Targets{"1.2.3.4"}, @@ -233,7 +247,7 @@ func testCRDSourceEndpoints(t *testing.T) { }, } { t.Run(ti.title, func(t *testing.T) { - restClient := startCRDServerToServeTargets(ti.endpoints, ti.registeredAPIVersion, ti.registeredKind, ti.registeredNamespace, "") + restClient := startCRDServerToServeTargets(ti.endpoints, ti.registeredAPIVersion, ti.registeredKind, ti.registeredNamespace, "test", t) groupVersion, err := schema.ParseGroupVersion(ti.apiVersion) require.NoError(t, err) @@ -253,8 +267,28 @@ func testCRDSourceEndpoints(t *testing.T) { return } + if err == nil { + validateCRDResource(t, cs, ti.expectError) + } + // Validate received endpoints against expected endpoints. validateEndpoints(t, receivedEndpoints, ti.endpoints) }) } } + +func validateCRDResource(t *testing.T, src Source, expectError bool) { + cs := src.(*crdSource) + result, err := cs.List(&metav1.ListOptions{}) + if expectError { + require.Errorf(t, err, "Received err %v", err) + } else { + require.NoErrorf(t, err, "Received err %v", err) + } + + for _, dnsEndpoint := range result.Items { + if dnsEndpoint.Status.ObservedGeneration != dnsEndpoint.Generation { + require.Errorf(t, err, "Unexpected CRD resource result: ObservedGenerations <%v> is not equal to Generation<%v>", dnsEndpoint.Status.ObservedGeneration, dnsEndpoint.Generation) + } + } +} diff --git a/source/gateway.go b/source/gateway.go index 30aa53b10..751698da1 100644 --- a/source/gateway.go +++ b/source/gateway.go @@ -38,33 +38,35 @@ import ( // The gateway implementation uses the spec.servers.hosts values for the hostnames. // Use targetAnnotationKey to explicitly set Endpoint. type gatewaySource struct { - kubeClient kubernetes.Interface - istioClient istiomodel.ConfigStore - istioNamespace string - istioIngressGatewayName string - namespace string - annotationFilter string - fqdnTemplate *template.Template - combineFQDNAnnotation bool + kubeClient kubernetes.Interface + istioClient istiomodel.ConfigStore + istioIngressGatewayServices []string + namespace string + annotationFilter string + fqdnTemplate *template.Template + combineFQDNAnnotation bool + ignoreHostnameAnnotation bool } // NewIstioGatewaySource creates a new gatewaySource with the given config. func NewIstioGatewaySource( kubeClient kubernetes.Interface, istioClient istiomodel.ConfigStore, - istioIngressGateway string, + istioIngressGatewayServices []string, namespace string, annotationFilter string, fqdnTemplate string, combineFqdnAnnotation bool, + ignoreHostnameAnnotation bool, ) (Source, error) { var ( tmpl *template.Template err error ) - istioNamespace, istioIngressGatewayName, err := parseIngressGateway(istioIngressGateway) - if err != nil { - return nil, err + for _, lbService := range istioIngressGatewayServices { + if _, _, err = parseIngressGateway(lbService); err != nil { + return nil, err + } } if fqdnTemplate != "" { @@ -77,14 +79,14 @@ func NewIstioGatewaySource( } return &gatewaySource{ - kubeClient: kubeClient, - istioClient: istioClient, - istioNamespace: istioNamespace, - istioIngressGatewayName: istioIngressGatewayName, - namespace: namespace, - annotationFilter: annotationFilter, - fqdnTemplate: tmpl, - combineFQDNAnnotation: combineFqdnAnnotation, + kubeClient: kubeClient, + istioClient: istioClient, + istioIngressGatewayServices: istioIngressGatewayServices, + namespace: namespace, + annotationFilter: annotationFilter, + fqdnTemplate: tmpl, + combineFQDNAnnotation: combineFqdnAnnotation, + ignoreHostnameAnnotation: ignoreHostnameAnnotation, }, nil } @@ -166,7 +168,7 @@ func (sc *gatewaySource) endpointsFromTemplate(config *istiomodel.Config) ([]*en targets := getTargetsFromTargetAnnotation(config.Annotations) if len(targets) == 0 { - targets, err = sc.targetsFromIstioIngressStatus() + targets, err = sc.targetsFromIstioIngressGatewayServices() if err != nil { return nil, err } @@ -221,16 +223,22 @@ func (sc *gatewaySource) setResourceLabel(config istiomodel.Config, endpoints [] } } -func (sc *gatewaySource) targetsFromIstioIngressStatus() (targets endpoint.Targets, err error) { - if svc, e := sc.kubeClient.CoreV1().Services(sc.istioNamespace).Get(sc.istioIngressGatewayName, metav1.GetOptions{}); e != nil { - err = e - } else { - for _, lb := range svc.Status.LoadBalancer.Ingress { - if lb.IP != "" { - targets = append(targets, lb.IP) - } - if lb.Hostname != "" { - targets = append(targets, lb.Hostname) +func (sc *gatewaySource) targetsFromIstioIngressGatewayServices() (targets endpoint.Targets, err error) { + for _, lbService := range sc.istioIngressGatewayServices { + lbNamespace, lbName, err := parseIngressGateway(lbService) + if err != nil { + return nil, err + } + if svc, err := sc.kubeClient.CoreV1().Services(lbNamespace).Get(lbName, metav1.GetOptions{}); err != nil { + log.Warn(err) + } else { + for _, lb := range svc.Status.LoadBalancer.Ingress { + if lb.IP != "" { + targets = append(targets, lb.IP) + } + if lb.Hostname != "" { + targets = append(targets, lb.Hostname) + } } } } @@ -250,7 +258,7 @@ func (sc *gatewaySource) endpointsFromGatewayConfig(config istiomodel.Config) ([ targets := getTargetsFromTargetAnnotation(config.Annotations) if len(targets) == 0 { - targets, err = sc.targetsFromIstioIngressStatus() + targets, err = sc.targetsFromIstioIngressGatewayServices() if err != nil { return nil, err } @@ -269,9 +277,12 @@ func (sc *gatewaySource) endpointsFromGatewayConfig(config istiomodel.Config) ([ } } - hostnameList := getHostnamesFromAnnotations(config.Annotations) - for _, hostname := range hostnameList { - endpoints = append(endpoints, endpointsForHostname(hostname, targets, ttl, providerSpecific)...) + // Skip endpoints if we do not want entries from annotations + if !sc.ignoreHostnameAnnotation { + hostnameList := getHostnamesFromAnnotations(config.Annotations) + for _, hostname := range hostnameList { + endpoints = append(endpoints, endpointsForHostname(hostname, targets, ttl, providerSpecific)...) + } } return endpoints, nil diff --git a/source/gateway_test.go b/source/gateway_test.go index 145694293..d82d30ed4 100644 --- a/source/gateway_test.go +++ b/source/gateway_test.go @@ -31,7 +31,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" - "k8s.io/api/core/v1" + v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes/fake" ) @@ -43,9 +43,9 @@ var gatewayType = istiomodel.Gateway.Type type GatewaySuite struct { suite.Suite - source Source - ingress *v1.Service - config istiomodel.Config + source Source + lbServices []*v1.Service + config istiomodel.Config } func (suite *GatewaySuite) SetupTest() { @@ -53,21 +53,35 @@ func (suite *GatewaySuite) SetupTest() { fakeIstioClient := NewFakeConfigStore() var err error - suite.ingress = (fakeIngressGateway{ - ips: []string{"8.8.8.8"}, - hostnames: []string{"v1"}, - }).Service() - _, err = fakeKubernetesClient.CoreV1().Services(suite.ingress.Namespace).Create(suite.ingress) - suite.NoError(err, "should succeed") + suite.lbServices = []*v1.Service{ + (fakeIngressGatewayService{ + ips: []string{"8.8.8.8"}, + hostnames: []string{"v1"}, + namespace: "istio-system", + name: "istio-gateway1", + }).Service(), + (fakeIngressGatewayService{ + ips: []string{"1.1.1.1"}, + hostnames: []string{"v42"}, + namespace: "istio-other", + name: "istio-gateway2", + }).Service(), + } + + for _, loadBalancer := range suite.lbServices { + _, err = fakeKubernetesClient.CoreV1().Services(loadBalancer.Namespace).Create(loadBalancer) + suite.NoError(err, "should succeed") + } suite.source, err = NewIstioGatewaySource( fakeKubernetesClient, fakeIstioClient, - "istio-system/istio-ingressgateway", + []string{"istio-system/istio-ingressgateway"}, "default", "", "{{.Name}}", false, + false, ) suite.NoError(err, "should initialize gateway source") @@ -136,11 +150,12 @@ func TestNewIstioGatewaySource(t *testing.T) { _, err := NewIstioGatewaySource( fake.NewSimpleClientset(), NewFakeConfigStore(), - "istio-system/istio-ingressgateway", + []string{"istio-system/istio-ingressgateway"}, "", ti.annotationFilter, ti.fqdnTemplate, ti.combineFQDNAndAnnotation, + false, ) if ti.expectError { assert.Error(t, err) @@ -153,15 +168,17 @@ func TestNewIstioGatewaySource(t *testing.T) { func testEndpointsFromGatewayConfig(t *testing.T) { for _, ti := range []struct { - title string - ingress fakeIngressGateway - config fakeGatewayConfig - expected []*endpoint.Endpoint + title string + lbServices []fakeIngressGatewayService + config fakeGatewayConfig + expected []*endpoint.Endpoint }{ { title: "one rule.host one lb.hostname", - ingress: fakeIngressGateway{ - hostnames: []string{"lb.com"}, // Kubernetes omits the trailing dot + lbServices: []fakeIngressGatewayService{ + { + hostnames: []string{"lb.com"}, // Kubernetes omits the trailing dot + }, }, config: fakeGatewayConfig{ dnsnames: [][]string{ @@ -177,8 +194,10 @@ func testEndpointsFromGatewayConfig(t *testing.T) { }, { title: "one rule.host one lb.IP", - ingress: fakeIngressGateway{ - ips: []string{"8.8.8.8"}, + lbServices: []fakeIngressGatewayService{ + { + ips: []string{"8.8.8.8"}, + }, }, config: fakeGatewayConfig{ dnsnames: [][]string{ @@ -194,9 +213,11 @@ func testEndpointsFromGatewayConfig(t *testing.T) { }, { title: "one rule.host two lb.IP and two lb.Hostname", - ingress: fakeIngressGateway{ - ips: []string{"8.8.8.8", "127.0.0.1"}, - hostnames: []string{"elb.com", "alb.com"}, + lbServices: []fakeIngressGatewayService{ + { + ips: []string{"8.8.8.8", "127.0.0.1"}, + hostnames: []string{"elb.com", "alb.com"}, + }, }, config: fakeGatewayConfig{ dnsnames: [][]string{ @@ -216,9 +237,11 @@ func testEndpointsFromGatewayConfig(t *testing.T) { }, { title: "no rule.host", - ingress: fakeIngressGateway{ - ips: []string{"8.8.8.8", "127.0.0.1"}, - hostnames: []string{"elb.com", "alb.com"}, + lbServices: []fakeIngressGatewayService{ + { + ips: []string{"8.8.8.8", "127.0.0.1"}, + hostnames: []string{"elb.com", "alb.com"}, + }, }, config: fakeGatewayConfig{ dnsnames: [][]string{}, @@ -227,9 +250,11 @@ func testEndpointsFromGatewayConfig(t *testing.T) { }, { title: "one empty rule.host", - ingress: fakeIngressGateway{ - ips: []string{"8.8.8.8", "127.0.0.1"}, - hostnames: []string{"elb.com", "alb.com"}, + lbServices: []fakeIngressGatewayService{ + { + ips: []string{"8.8.8.8", "127.0.0.1"}, + hostnames: []string{"elb.com", "alb.com"}, + }, }, config: fakeGatewayConfig{ dnsnames: [][]string{ @@ -239,8 +264,8 @@ func testEndpointsFromGatewayConfig(t *testing.T) { expected: []*endpoint.Endpoint{}, }, { - title: "no targets", - ingress: fakeIngressGateway{}, + title: "no targets", + lbServices: []fakeIngressGatewayService{{}}, config: fakeGatewayConfig{ dnsnames: [][]string{ {""}, @@ -248,9 +273,35 @@ func testEndpointsFromGatewayConfig(t *testing.T) { }, expected: []*endpoint.Endpoint{}, }, + { + title: "one gateway, two ingressgateway loadbalancer hostnames", + lbServices: []fakeIngressGatewayService{ + { + hostnames: []string{"lb.com"}, + namespace: "istio-other", + name: "gateway1", + }, + { + hostnames: []string{"lb2.com"}, + namespace: "istio-other", + name: "gateway2", + }, + }, + config: fakeGatewayConfig{ + dnsnames: [][]string{ + {"foo.bar"}, // Kubernetes requires removal of trailing dot + }, + }, + expected: []*endpoint.Endpoint{ + { + DNSName: "foo.bar", + Targets: endpoint.Targets{"lb.com", "lb2.com"}, + }, + }, + }, } { t.Run(ti.title, func(t *testing.T) { - if source, err := newTestGatewaySource(ti.ingress.Service()); err != nil { + if source, err := newTestGatewaySource(ti.lbServices); err != nil { require.NoError(t, err) } else if endpoints, err := source.endpointsFromGatewayConfig(ti.config.Config()); err != nil { require.NoError(t, err) @@ -267,23 +318,26 @@ func testGatewayEndpoints(t *testing.T) { title string targetNamespace string annotationFilter string - ingressGateway fakeIngressGateway + lbServices []fakeIngressGatewayService configItems []fakeGatewayConfig expected []*endpoint.Endpoint expectError bool fqdnTemplate string combineFQDNAndAnnotation bool + ignoreHostnameAnnotation bool }{ { title: "no gateway", targetNamespace: "", }, { - title: "two simple gateways", + title: "two simple gateways, one ingressgateway loadbalancer service", targetNamespace: "", - ingressGateway: fakeIngressGateway{ - ips: []string{"8.8.8.8"}, - hostnames: []string{"lb.com"}, + lbServices: []fakeIngressGatewayService{ + { + ips: []string{"8.8.8.8"}, + hostnames: []string{"lb.com"}, + }, }, configItems: []fakeGatewayConfig{ { @@ -317,11 +371,13 @@ func testGatewayEndpoints(t *testing.T) { }, }, { - title: "two simple gateways on different namespaces", + title: "two simple gateways on different namespaces, one ingressgateway loadbalancer service", targetNamespace: "", - ingressGateway: fakeIngressGateway{ - ips: []string{"8.8.8.8"}, - hostnames: []string{"lb.com"}, + lbServices: []fakeIngressGatewayService{ + { + ips: []string{"8.8.8.8"}, + hostnames: []string{"lb.com"}, + }, }, configItems: []fakeGatewayConfig{ { @@ -355,11 +411,13 @@ func testGatewayEndpoints(t *testing.T) { }, }, { - title: "two simple gateways on different namespaces with target namespace", + title: "two simple gateways on different namespaces and a target namespace, one ingressgateway loadbalancer service", targetNamespace: "testing1", - ingressGateway: fakeIngressGateway{ - ips: []string{"8.8.8.8"}, - hostnames: []string{"lb.com"}, + lbServices: []fakeIngressGatewayService{ + { + ips: []string{"8.8.8.8"}, + hostnames: []string{"lb.com"}, + }, }, configItems: []fakeGatewayConfig{ { @@ -388,8 +446,10 @@ func testGatewayEndpoints(t *testing.T) { title: "valid matching annotation filter expression", targetNamespace: "", annotationFilter: "kubernetes.io/gateway.class in (alb, nginx)", - ingressGateway: fakeIngressGateway{ - ips: []string{"8.8.8.8"}, + lbServices: []fakeIngressGatewayService{ + { + ips: []string{"8.8.8.8"}, + }, }, configItems: []fakeGatewayConfig{ { @@ -412,8 +472,10 @@ func testGatewayEndpoints(t *testing.T) { title: "valid non-matching annotation filter expression", targetNamespace: "", annotationFilter: "kubernetes.io/gateway.class in (alb, nginx)", - ingressGateway: fakeIngressGateway{ - ips: []string{"8.8.8.8"}, + lbServices: []fakeIngressGatewayService{ + { + ips: []string{"8.8.8.8"}, + }, }, configItems: []fakeGatewayConfig{ { @@ -431,8 +493,10 @@ func testGatewayEndpoints(t *testing.T) { title: "invalid annotation filter expression", targetNamespace: "", annotationFilter: "kubernetes.io/gateway.name in (a b)", - ingressGateway: fakeIngressGateway{ - ips: []string{"8.8.8.8"}, + lbServices: []fakeIngressGatewayService{ + { + ips: []string{"8.8.8.8"}, + }, }, configItems: []fakeGatewayConfig{ { @@ -451,8 +515,10 @@ func testGatewayEndpoints(t *testing.T) { title: "valid matching annotation filter label", targetNamespace: "", annotationFilter: "kubernetes.io/gateway.class=nginx", - ingressGateway: fakeIngressGateway{ - ips: []string{"8.8.8.8"}, + lbServices: []fakeIngressGatewayService{ + { + ips: []string{"8.8.8.8"}, + }, }, configItems: []fakeGatewayConfig{ { @@ -475,8 +541,10 @@ func testGatewayEndpoints(t *testing.T) { title: "valid non-matching annotation filter label", targetNamespace: "", annotationFilter: "kubernetes.io/gateway.class=nginx", - ingressGateway: fakeIngressGateway{ - ips: []string{"8.8.8.8"}, + lbServices: []fakeIngressGatewayService{ + { + ips: []string{"8.8.8.8"}, + }, }, configItems: []fakeGatewayConfig{ { @@ -493,8 +561,10 @@ func testGatewayEndpoints(t *testing.T) { { title: "our controller type is dns-controller", targetNamespace: "", - ingressGateway: fakeIngressGateway{ - ips: []string{"8.8.8.8"}, + lbServices: []fakeIngressGatewayService{ + { + ips: []string{"8.8.8.8"}, + }, }, configItems: []fakeGatewayConfig{ { @@ -516,8 +586,10 @@ func testGatewayEndpoints(t *testing.T) { { title: "different controller types are ignored", targetNamespace: "", - ingressGateway: fakeIngressGateway{ - ips: []string{"8.8.8.8"}, + lbServices: []fakeIngressGatewayService{ + { + ips: []string{"8.8.8.8"}, + }, }, configItems: []fakeGatewayConfig{ { @@ -534,9 +606,11 @@ func testGatewayEndpoints(t *testing.T) { { title: "template for gateway if host is missing", targetNamespace: "", - ingressGateway: fakeIngressGateway{ - ips: []string{"8.8.8.8"}, - hostnames: []string{"elb.com"}, + lbServices: []fakeIngressGatewayService{ + { + ips: []string{"8.8.8.8"}, + hostnames: []string{"elb.com"}, + }, }, configItems: []fakeGatewayConfig{ { @@ -563,8 +637,10 @@ func testGatewayEndpoints(t *testing.T) { { title: "another controller annotation skipped even with template", targetNamespace: "", - ingressGateway: fakeIngressGateway{ - ips: []string{"8.8.8.8"}, + lbServices: []fakeIngressGatewayService{ + { + ips: []string{"8.8.8.8"}, + }, }, configItems: []fakeGatewayConfig{ { @@ -582,8 +658,10 @@ func testGatewayEndpoints(t *testing.T) { { title: "multiple FQDN template hostnames", targetNamespace: "", - ingressGateway: fakeIngressGateway{ - ips: []string{"8.8.8.8"}, + lbServices: []fakeIngressGatewayService{ + { + ips: []string{"8.8.8.8"}, + }, }, configItems: []fakeGatewayConfig{ { @@ -610,8 +688,10 @@ func testGatewayEndpoints(t *testing.T) { { title: "multiple FQDN template hostnames", targetNamespace: "", - ingressGateway: fakeIngressGateway{ - ips: []string{"8.8.8.8"}, + lbServices: []fakeIngressGatewayService{ + { + ips: []string{"8.8.8.8"}, + }, }, configItems: []fakeGatewayConfig{ { @@ -662,8 +742,10 @@ func testGatewayEndpoints(t *testing.T) { { title: "gateway rules with annotation", targetNamespace: "", - ingressGateway: fakeIngressGateway{ - ips: []string{"8.8.8.8"}, + lbServices: []fakeIngressGatewayService{ + { + ips: []string{"8.8.8.8"}, + }, }, configItems: []fakeGatewayConfig{ { @@ -712,8 +794,10 @@ func testGatewayEndpoints(t *testing.T) { { title: "gateway rules with hostname annotation", targetNamespace: "", - ingressGateway: fakeIngressGateway{ - ips: []string{"1.2.3.4"}, + lbServices: []fakeIngressGatewayService{ + { + ips: []string{"1.2.3.4"}, + }, }, configItems: []fakeGatewayConfig{ { @@ -741,8 +825,10 @@ func testGatewayEndpoints(t *testing.T) { { title: "gateway rules with hostname annotation having multiple hostnames", targetNamespace: "", - ingressGateway: fakeIngressGateway{ - ips: []string{"1.2.3.4"}, + lbServices: []fakeIngressGatewayService{ + { + ips: []string{"1.2.3.4"}, + }, }, configItems: []fakeGatewayConfig{ { @@ -775,8 +861,10 @@ func testGatewayEndpoints(t *testing.T) { { title: "gateway rules with hostname and target annotation", targetNamespace: "", - ingressGateway: fakeIngressGateway{ - ips: []string{}, + lbServices: []fakeIngressGatewayService{ + { + ips: []string{}, + }, }, configItems: []fakeGatewayConfig{ { @@ -805,8 +893,10 @@ func testGatewayEndpoints(t *testing.T) { { title: "gateway rules with annotation and custom TTL", targetNamespace: "", - ingressGateway: fakeIngressGateway{ - ips: []string{"8.8.8.8"}, + lbServices: []fakeIngressGatewayService{ + { + ips: []string{"8.8.8.8"}, + }, }, configItems: []fakeGatewayConfig{ { @@ -844,9 +934,11 @@ func testGatewayEndpoints(t *testing.T) { { title: "template for gateway with annotation", targetNamespace: "", - ingressGateway: fakeIngressGateway{ - ips: []string{}, - hostnames: []string{}, + lbServices: []fakeIngressGatewayService{ + { + ips: []string{}, + hostnames: []string{}, + }, }, configItems: []fakeGatewayConfig{ { @@ -896,9 +988,11 @@ func testGatewayEndpoints(t *testing.T) { { title: "Ingress with empty annotation", targetNamespace: "", - ingressGateway: fakeIngressGateway{ - ips: []string{}, - hostnames: []string{}, + lbServices: []fakeIngressGatewayService{ + { + ips: []string{}, + hostnames: []string{}, + }, }, configItems: []fakeGatewayConfig{ { @@ -913,6 +1007,53 @@ func testGatewayEndpoints(t *testing.T) { expected: []*endpoint.Endpoint{}, fqdnTemplate: "{{.Name}}.ext-dns.test.com", }, + { + title: "ignore hostname annotations", + targetNamespace: "", + lbServices: []fakeIngressGatewayService{ + { + ips: []string{"8.8.8.8"}, + hostnames: []string{"lb.com"}, + }, + }, + configItems: []fakeGatewayConfig{ + { + name: "fake1", + namespace: namespace, + annotations: map[string]string{ + hostnameAnnotationKey: "ignore.me", + }, + dnsnames: [][]string{{"example.org"}}, + }, + { + name: "fake2", + namespace: namespace, + annotations: map[string]string{ + hostnameAnnotationKey: "ignore.me.too", + }, + dnsnames: [][]string{{"new.org"}}, + }, + }, + expected: []*endpoint.Endpoint{ + { + DNSName: "example.org", + Targets: endpoint.Targets{"8.8.8.8"}, + }, + { + DNSName: "example.org", + Targets: endpoint.Targets{"lb.com"}, + }, + { + DNSName: "new.org", + Targets: endpoint.Targets{"8.8.8.8"}, + }, + { + DNSName: "new.org", + Targets: endpoint.Targets{"lb.com"}, + }, + }, + ignoreHostnameAnnotation: true, + }, } { t.Run(ti.title, func(t *testing.T) { configs := make([]istiomodel.Config, 0) @@ -921,10 +1062,17 @@ func testGatewayEndpoints(t *testing.T) { } fakeKubernetesClient := fake.NewSimpleClientset() - ingressGatewayService := ti.ingressGateway.Service() - if _, err := fakeKubernetesClient.CoreV1().Services(ingressGatewayService.Namespace).Create(ingressGatewayService); err != nil { - require.NoError(t, err) + + var fakeLoadBalancerList []string + for _, lb := range ti.lbServices { + lbService := lb.Service() + _, err := fakeKubernetesClient.CoreV1().Services(lbService.Namespace).Create(lbService) + if err != nil { + require.NoError(t, err) + } + fakeLoadBalancerList = append(fakeLoadBalancerList, lbService.Namespace+"/"+lbService.Name) } + fakeIstioClient := NewFakeConfigStore() for _, config := range configs { _, err := fakeIstioClient.Create(config) @@ -934,11 +1082,12 @@ func testGatewayEndpoints(t *testing.T) { gatewaySource, err := NewIstioGatewaySource( fakeKubernetesClient, fakeIstioClient, - ingressGatewayService.Namespace+"/"+ingressGatewayService.Name, + fakeLoadBalancerList, ti.targetNamespace, ti.annotationFilter, ti.fqdnTemplate, ti.combineFQDNAndAnnotation, + ti.ignoreHostnameAnnotation, ) require.NoError(t, err) @@ -955,23 +1104,29 @@ func testGatewayEndpoints(t *testing.T) { } // gateway specific helper functions -func newTestGatewaySource(ingress *v1.Service) (*gatewaySource, error) { +func newTestGatewaySource(loadBalancerList []fakeIngressGatewayService) (*gatewaySource, error) { fakeKubernetesClient := fake.NewSimpleClientset() fakeIstioClient := NewFakeConfigStore() - _, err := fakeKubernetesClient.CoreV1().Services(ingress.Namespace).Create(ingress) - if err != nil { - return nil, err + var lbList []string + for _, lb := range loadBalancerList { + lbService := lb.Service() + _, err := fakeKubernetesClient.CoreV1().Services(lbService.Namespace).Create(lbService) + if err != nil { + return nil, err + } + lbList = append(lbList, lbService.Namespace+"/"+lbService.Name) } src, err := NewIstioGatewaySource( fakeKubernetesClient, fakeIstioClient, - "istio-system/istio-ingressgateway", + lbList, "default", "", "{{.Name}}", false, + false, ) if err != nil { return nil, err @@ -985,16 +1140,18 @@ func newTestGatewaySource(ingress *v1.Service) (*gatewaySource, error) { return gwsrc, nil } -type fakeIngressGateway struct { +type fakeIngressGatewayService struct { ips []string hostnames []string + namespace string + name string } -func (ig fakeIngressGateway) Service() *v1.Service { +func (ig fakeIngressGatewayService) Service() *v1.Service { svc := &v1.Service{ ObjectMeta: metav1.ObjectMeta{ - Namespace: "istio-system", - Name: "istio-ingressgateway", + Namespace: ig.namespace, + Name: ig.name, }, Status: v1.ServiceStatus{ LoadBalancer: v1.LoadBalancerStatus{ @@ -1067,13 +1224,12 @@ func (f *fakeConfigStore) ConfigDescriptor() istiomodel.ConfigDescriptor { return f.descriptor } -func (f *fakeConfigStore) Get(typ, name, namespace string) (config *istiomodel.Config, exists bool) { +func (f *fakeConfigStore) Get(typ, name, namespace string) (config *istiomodel.Config) { f.RLock() defer f.RUnlock() if cfg, _ := f.get(typ, name, namespace); cfg != nil { config = cfg - exists = true } return diff --git a/source/ingress.go b/source/ingress.go index cbda3cd92..ff3f00bef 100644 --- a/source/ingress.go +++ b/source/ingress.go @@ -23,14 +23,18 @@ import ( "strings" "text/template" - log "github.com/sirupsen/logrus" + "time" + "github.com/kubernetes-incubator/external-dns/endpoint" + log "github.com/sirupsen/logrus" "k8s.io/api/extensions/v1beta1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/util/wait" + kubeinformers "k8s.io/client-go/informers" + extinformers "k8s.io/client-go/informers/extensions/v1beta1" "k8s.io/client-go/kubernetes" - - "github.com/kubernetes-incubator/external-dns/endpoint" + "k8s.io/client-go/tools/cache" ) // ingressSource is an implementation of Source for Kubernetes ingress objects. @@ -38,15 +42,17 @@ import ( // Use targetAnnotationKey to explicitly set Endpoint. (useful if the ingress // controller does not update, or to override with alternative endpoint) type ingressSource struct { - client kubernetes.Interface - namespace string - annotationFilter string - fqdnTemplate *template.Template - combineFQDNAnnotation bool + client kubernetes.Interface + namespace string + annotationFilter string + fqdnTemplate *template.Template + combineFQDNAnnotation bool + ignoreHostnameAnnotation bool + ingressInformer extinformers.IngressInformer } // NewIngressSource creates a new ingressSource with the given config. -func NewIngressSource(kubeClient kubernetes.Interface, namespace, annotationFilter string, fqdnTemplate string, combineFqdnAnnotation bool) (Source, error) { +func NewIngressSource(kubeClient kubernetes.Interface, namespace, annotationFilter string, fqdnTemplate string, combineFqdnAnnotation bool, ignoreHostnameAnnotation bool) (Source, error) { var ( tmpl *template.Template err error @@ -60,30 +66,57 @@ func NewIngressSource(kubeClient kubernetes.Interface, namespace, annotationFilt } } - return &ingressSource{ - client: kubeClient, - namespace: namespace, - annotationFilter: annotationFilter, - fqdnTemplate: tmpl, - combineFQDNAnnotation: combineFqdnAnnotation, - }, nil + // Use shared informer to listen for add/update/delete of ingresses in the specified namespace. + // Set resync period to 0, to prevent processing when nothing has changed. + informerFactory := kubeinformers.NewSharedInformerFactoryWithOptions(kubeClient, 0, kubeinformers.WithNamespace(namespace)) + ingressInformer := informerFactory.Extensions().V1beta1().Ingresses() + + // Add default resource event handlers to properly initialize informer. + ingressInformer.Informer().AddEventHandler( + cache.ResourceEventHandlerFuncs{ + AddFunc: func(obj interface{}) { + }, + }, + ) + + // TODO informer is not explicitly stopped since controller is not passing in its channel. + informerFactory.Start(wait.NeverStop) + + // wait for the local cache to be populated. + err = wait.Poll(time.Second, 60*time.Second, func() (bool, error) { + return ingressInformer.Informer().HasSynced() == true, nil + }) + if err != nil { + return nil, fmt.Errorf("failed to sync cache: %v", err) + } + + sc := &ingressSource{ + client: kubeClient, + namespace: namespace, + annotationFilter: annotationFilter, + fqdnTemplate: tmpl, + combineFQDNAnnotation: combineFqdnAnnotation, + ignoreHostnameAnnotation: ignoreHostnameAnnotation, + ingressInformer: ingressInformer, + } + return sc, nil } // Endpoints returns endpoint objects for each host-target combination that should be processed. // Retrieves all ingress resources on all namespaces func (sc *ingressSource) Endpoints() ([]*endpoint.Endpoint, error) { - ingresses, err := sc.client.Extensions().Ingresses(sc.namespace).List(metav1.ListOptions{}) + ingresses, err := sc.ingressInformer.Lister().Ingresses(sc.namespace).List(labels.Everything()) if err != nil { return nil, err } - ingresses.Items, err = sc.filterByAnnotations(ingresses.Items) + ingresses, err = sc.filterByAnnotations(ingresses) if err != nil { return nil, err } endpoints := []*endpoint.Endpoint{} - for _, ing := range ingresses.Items { + for _, ing := range ingresses { // Check controller annotation to see if we are responsible. controller, ok := ing.Annotations[controllerAnnotationKey] if ok && controller != controllerAnnotationValue { @@ -92,11 +125,11 @@ func (sc *ingressSource) Endpoints() ([]*endpoint.Endpoint, error) { continue } - ingEndpoints := endpointsFromIngress(&ing) + ingEndpoints := endpointsFromIngress(ing, sc.ignoreHostnameAnnotation) // apply template if host is missing on ingress if (sc.combineFQDNAnnotation || len(ingEndpoints) == 0) && sc.fqdnTemplate != nil { - iEndpoints, err := sc.endpointsFromTemplate(&ing) + iEndpoints, err := sc.endpointsFromTemplate(ing) if err != nil { return nil, err } @@ -159,7 +192,7 @@ func (sc *ingressSource) endpointsFromTemplate(ing *v1beta1.Ingress) ([]*endpoin } // filterByAnnotations filters a list of ingresses by a given annotation selector. -func (sc *ingressSource) filterByAnnotations(ingresses []v1beta1.Ingress) ([]v1beta1.Ingress, error) { +func (sc *ingressSource) filterByAnnotations(ingresses []*v1beta1.Ingress) ([]*v1beta1.Ingress, error) { labelSelector, err := metav1.ParseToLabelSelector(sc.annotationFilter) if err != nil { return nil, err @@ -174,7 +207,7 @@ func (sc *ingressSource) filterByAnnotations(ingresses []v1beta1.Ingress) ([]v1b return ingresses, nil } - filteredList := []v1beta1.Ingress{} + filteredList := []*v1beta1.Ingress{} for _, ingress := range ingresses { // convert the ingress' annotations to an equivalent label selector @@ -189,14 +222,14 @@ func (sc *ingressSource) filterByAnnotations(ingresses []v1beta1.Ingress) ([]v1b return filteredList, nil } -func (sc *ingressSource) setResourceLabel(ingress v1beta1.Ingress, endpoints []*endpoint.Endpoint) { +func (sc *ingressSource) setResourceLabel(ingress *v1beta1.Ingress, endpoints []*endpoint.Endpoint) { for _, ep := range endpoints { ep.Labels[endpoint.ResourceLabelKey] = fmt.Sprintf("ingress/%s/%s", ingress.Namespace, ingress.Name) } } // endpointsFromIngress extracts the endpoints from ingress object -func endpointsFromIngress(ing *v1beta1.Ingress) []*endpoint.Endpoint { +func endpointsFromIngress(ing *v1beta1.Ingress, ignoreHostnameAnnotation bool) []*endpoint.Endpoint { var endpoints []*endpoint.Endpoint ttl, err := getTTLFromAnnotations(ing.Annotations) @@ -228,11 +261,13 @@ func endpointsFromIngress(ing *v1beta1.Ingress) []*endpoint.Endpoint { } } - hostnameList := getHostnamesFromAnnotations(ing.Annotations) - for _, hostname := range hostnameList { - endpoints = append(endpoints, endpointsForHostname(hostname, targets, ttl, providerSpecific)...) + // Skip endpoints if we do not want entries from annotations + if !ignoreHostnameAnnotation { + hostnameList := getHostnamesFromAnnotations(ing.Annotations) + for _, hostname := range hostnameList { + endpoints = append(endpoints, endpointsForHostname(hostname, targets, ttl, providerSpecific)...) + } } - return endpoints } diff --git a/source/ingress_test.go b/source/ingress_test.go index 7d0182653..ee79177a5 100644 --- a/source/ingress_test.go +++ b/source/ingress_test.go @@ -18,10 +18,12 @@ package source import ( "testing" + "time" - "k8s.io/api/core/v1" + v1 "k8s.io/api/core/v1" "k8s.io/api/extensions/v1beta1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/wait" "k8s.io/client-go/kubernetes/fake" "github.com/kubernetes-incubator/external-dns/endpoint" @@ -50,6 +52,7 @@ func (suite *IngressSuite) SetupTest() { "", "{{.Name}}", false, + false, ) suite.NoError(err, "should initialize ingress source") @@ -123,6 +126,7 @@ func TestNewIngressSource(t *testing.T) { ti.annotationFilter, ti.fqdnTemplate, ti.combineFQDNAndAnnotation, + false, ) if ti.expectError { assert.Error(t, err) @@ -210,7 +214,7 @@ func testEndpointsFromIngress(t *testing.T) { } { t.Run(ti.title, func(t *testing.T) { realIngress := ti.ingress.Ingress() - validateEndpoints(t, endpointsFromIngress(realIngress), ti.expected) + validateEndpoints(t, endpointsFromIngress(realIngress, false), ti.expected) }) } } @@ -226,6 +230,7 @@ func testIngressEndpoints(t *testing.T) { expectError bool fqdnTemplate string combineFQDNAndAnnotation bool + ignoreHostnameAnnotation bool }{ { title: "no ingress", @@ -934,6 +939,38 @@ func testIngressEndpoints(t *testing.T) { expected: []*endpoint.Endpoint{}, fqdnTemplate: "{{.Name}}.ext-dns.test.com", }, + { + title: "ignore hostname annotation", + targetNamespace: "", + ignoreHostnameAnnotation: true, + ingressItems: []fakeIngress{ + { + name: "fake1", + namespace: namespace, + dnsnames: []string{"example.org"}, + ips: []string{"8.8.8.8"}, + }, + { + name: "fake2", + namespace: namespace, + annotations: map[string]string{ + hostnameAnnotationKey: "dns-through-hostname.com", + }, + dnsnames: []string{"new.org"}, + hostnames: []string{"lb.com"}, + }, + }, + expected: []*endpoint.Endpoint{ + { + DNSName: "example.org", + Targets: endpoint.Targets{"8.8.8.8"}, + }, + { + DNSName: "new.org", + Targets: endpoint.Targets{"lb.com"}, + }, + }, + }, } { t.Run(ti.title, func(t *testing.T) { ingresses := make([]*v1beta1.Ingress, 0) @@ -948,13 +985,26 @@ func testIngressEndpoints(t *testing.T) { ti.annotationFilter, ti.fqdnTemplate, ti.combineFQDNAndAnnotation, + ti.ignoreHostnameAnnotation, ) for _, ingress := range ingresses { _, err := fakeClient.Extensions().Ingresses(ingress.Namespace).Create(ingress) require.NoError(t, err) } - res, err := ingressSource.Endpoints() + var res []*endpoint.Endpoint + var err error + + // wait up to a few seconds for new resources to appear in informer cache. + err = wait.Poll(time.Second, 3*time.Second, func() (bool, error) { + res, err = ingressSource.Endpoints() + if err != nil { + // stop waiting if we get an error + return true, err + } + return len(res) >= len(ti.expected), nil + }) + if ti.expectError { assert.Error(t, err) } else { diff --git a/source/service.go b/source/service.go index 28cb1b9cd..606716e65 100644 --- a/source/service.go +++ b/source/service.go @@ -23,14 +23,21 @@ import ( "strings" "text/template" + kubeinformers "k8s.io/client-go/informers" + coreinformers "k8s.io/client-go/informers/core/v1" + "k8s.io/client-go/tools/cache" + log "github.com/sirupsen/logrus" - "k8s.io/api/core/v1" + v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/util/wait" "k8s.io/client-go/kubernetes" + "time" + "github.com/kubernetes-incubator/external-dns/endpoint" ) @@ -48,16 +55,20 @@ type serviceSource struct { namespace string annotationFilter string // process Services with legacy annotations - compatibility string - fqdnTemplate *template.Template - combineFQDNAnnotation bool - publishInternal bool - publishHostIP bool - serviceTypeFilter map[string]struct{} + compatibility string + fqdnTemplate *template.Template + combineFQDNAnnotation bool + ignoreHostnameAnnotation bool + publishInternal bool + publishHostIP bool + serviceInformer coreinformers.ServiceInformer + podInformer coreinformers.PodInformer + nodeInformer coreinformers.NodeInformer + serviceTypeFilter map[string]struct{} } // NewServiceSource creates a new serviceSource with the given config. -func NewServiceSource(kubeClient kubernetes.Interface, namespace, annotationFilter string, fqdnTemplate string, combineFqdnAnnotation bool, compatibility string, publishInternal bool, publishHostIP bool, serviceTypeFilter []string) (Source, error) { +func NewServiceSource(kubeClient kubernetes.Interface, namespace, annotationFilter string, fqdnTemplate string, combineFqdnAnnotation bool, compatibility string, publishInternal bool, publishHostIP bool, serviceTypeFilter []string, ignoreHostnameAnnotation bool) (Source, error) { var ( tmpl *template.Template err error @@ -71,6 +82,47 @@ func NewServiceSource(kubeClient kubernetes.Interface, namespace, annotationFilt } } + // Use shared informers to listen for add/update/delete of services/pods/nodes in the specified namespace. + // Set resync period to 0, to prevent processing when nothing has changed + informerFactory := kubeinformers.NewSharedInformerFactoryWithOptions(kubeClient, 0, kubeinformers.WithNamespace(namespace)) + serviceInformer := informerFactory.Core().V1().Services() + podInformer := informerFactory.Core().V1().Pods() + nodeInformer := informerFactory.Core().V1().Nodes() + + // Add default resource event handlers to properly initialize informer. + serviceInformer.Informer().AddEventHandler( + cache.ResourceEventHandlerFuncs{ + AddFunc: func(obj interface{}) { + log.Debug("service added") + }, + }, + ) + podInformer.Informer().AddEventHandler( + cache.ResourceEventHandlerFuncs{ + AddFunc: func(obj interface{}) { + log.Debug("pod added") + }, + }, + ) + nodeInformer.Informer().AddEventHandler( + cache.ResourceEventHandlerFuncs{ + AddFunc: func(obj interface{}) { + log.Debug("node added") + }, + }, + ) + + // TODO informer is not explicitly stopped since controller is not passing in its channel. + informerFactory.Start(wait.NeverStop) + + // wait for the local cache to be populated. + err = wait.Poll(time.Second, 60*time.Second, func() (bool, error) { + return serviceInformer.Informer().HasSynced() == true, nil + }) + if err != nil { + return nil, fmt.Errorf("failed to sync cache: %v", err) + } + // Transform the slice into a map so it will // be way much easier and fast to filter later serviceTypes := make(map[string]struct{}) @@ -79,32 +131,36 @@ func NewServiceSource(kubeClient kubernetes.Interface, namespace, annotationFilt } return &serviceSource{ - client: kubeClient, - namespace: namespace, - annotationFilter: annotationFilter, - compatibility: compatibility, - fqdnTemplate: tmpl, - combineFQDNAnnotation: combineFqdnAnnotation, - publishInternal: publishInternal, - publishHostIP: publishHostIP, - serviceTypeFilter: serviceTypes, + client: kubeClient, + namespace: namespace, + annotationFilter: annotationFilter, + compatibility: compatibility, + fqdnTemplate: tmpl, + combineFQDNAnnotation: combineFqdnAnnotation, + ignoreHostnameAnnotation: ignoreHostnameAnnotation, + publishInternal: publishInternal, + publishHostIP: publishHostIP, + serviceInformer: serviceInformer, + podInformer: podInformer, + nodeInformer: nodeInformer, + serviceTypeFilter: serviceTypes, }, nil } // Endpoints returns endpoint objects for each service that should be processed. func (sc *serviceSource) Endpoints() ([]*endpoint.Endpoint, error) { - services, err := sc.client.CoreV1().Services(sc.namespace).List(metav1.ListOptions{}) + services, err := sc.serviceInformer.Lister().Services(sc.namespace).List(labels.Everything()) if err != nil { return nil, err } - services.Items, err = sc.filterByAnnotations(services.Items) + services, err = sc.filterByAnnotations(services) if err != nil { return nil, err } // filter on service types if at least one has been provided if len(sc.serviceTypeFilter) > 0 { - services.Items = sc.filterByServiceType(services.Items) + services = sc.filterByServiceType(services) } // get the ip addresses of all the nodes and cache them for this run @@ -115,7 +171,7 @@ func (sc *serviceSource) Endpoints() ([]*endpoint.Endpoint, error) { endpoints := []*endpoint.Endpoint{} - for _, svc := range services.Items { + for _, svc := range services { // Check controller annotation to see if we are responsible. controller, ok := svc.Annotations[controllerAnnotationKey] if ok && controller != controllerAnnotationValue { @@ -124,16 +180,16 @@ func (sc *serviceSource) Endpoints() ([]*endpoint.Endpoint, error) { continue } - svcEndpoints := sc.endpoints(&svc, nodeTargets) + svcEndpoints := sc.endpoints(svc, nodeTargets) // process legacy annotations if no endpoints were returned and compatibility mode is enabled. if len(svcEndpoints) == 0 && sc.compatibility != "" { - svcEndpoints = legacyEndpointsFromService(&svc, sc.compatibility) + svcEndpoints = legacyEndpointsFromService(svc, sc.compatibility) } // apply template if none of the above is found if (sc.combineFQDNAnnotation || len(svcEndpoints) == 0) && sc.fqdnTemplate != nil { - sEndpoints, err := sc.endpointsFromTemplate(&svc, nodeTargets) + sEndpoints, err := sc.endpointsFromTemplate(svc, nodeTargets) if err != nil { return nil, err } @@ -165,44 +221,62 @@ func (sc *serviceSource) Endpoints() ([]*endpoint.Endpoint, error) { func (sc *serviceSource) extractHeadlessEndpoints(svc *v1.Service, hostname string, ttl endpoint.TTL) []*endpoint.Endpoint { var endpoints []*endpoint.Endpoint - pods, err := sc.client.CoreV1().Pods(svc.Namespace).List(metav1.ListOptions{LabelSelector: labels.Set(svc.Spec.Selector).AsSelectorPreValidated().String()}) + labelSelector, err := metav1.ParseToLabelSelector(labels.Set(svc.Spec.Selector).AsSelectorPreValidated().String()) + if err != nil { + return nil + } + selector, err := metav1.LabelSelectorAsSelector(labelSelector) + if err != nil { + return nil + } + + pods, err := sc.podInformer.Lister().Pods(svc.Namespace).List(selector) if err != nil { log.Errorf("List Pods of service[%s] error:%v", svc.GetName(), err) return endpoints } - for _, v := range pods.Items { - headlessDomain := hostname + targetsByHeadlessDomain := make(map[string][]string) + for _, v := range pods { + headlessDomains := []string{hostname} + if v.Spec.Hostname != "" { - headlessDomain = v.Spec.Hostname + "." + headlessDomain + headlessDomains = append(headlessDomains, fmt.Sprintf("%s.%s", v.Spec.Hostname, hostname)) } - - if sc.publishHostIP == true { - log.Debugf("Generating matching endpoint %s with HostIP %s", headlessDomain, v.Status.HostIP) - // To reduce traffice on the DNS API only add record for running Pods. Good Idea? - if v.Status.Phase == v1.PodRunning { - if ttl.IsConfigured() { - endpoints = append(endpoints, endpoint.NewEndpointWithTTL(headlessDomain, endpoint.RecordTypeA, ttl, v.Status.HostIP)) + for _, headlessDomain := range headlessDomains { + if sc.publishHostIP == true { + log.Debugf("Generating matching endpoint %s with HostIP %s", headlessDomain, v.Status.HostIP) + // To reduce traffice on the DNS API only add record for running Pods. Good Idea? + if v.Status.Phase == v1.PodRunning { + targetsByHeadlessDomain[headlessDomain] = append(targetsByHeadlessDomain[headlessDomain], v.Status.HostIP) } else { - endpoints = append(endpoints, endpoint.NewEndpoint(headlessDomain, endpoint.RecordTypeA, v.Status.HostIP)) + log.Debugf("Pod %s is not in running phase", v.Spec.Hostname) } } else { - log.Debugf("Pod %s is not in running phase", v.Spec.Hostname) + log.Debugf("Generating matching endpoint %s with PodIP %s", headlessDomain, v.Status.PodIP) + // To reduce traffice on the DNS API only add record for running Pods. Good Idea? + if v.Status.Phase == v1.PodRunning { + targetsByHeadlessDomain[headlessDomain] = append(targetsByHeadlessDomain[headlessDomain], v.Status.PodIP) + } else { + log.Debugf("Pod %s is not in running phase", v.Spec.Hostname) + } } + } + + } + + headlessDomains := []string{} + for headlessDomain := range targetsByHeadlessDomain { + headlessDomains = append(headlessDomains, headlessDomain) + } + sort.Strings(headlessDomains) + for _, headlessDomain := range headlessDomains { + targets := targetsByHeadlessDomain[headlessDomain] + if ttl.IsConfigured() { + endpoints = append(endpoints, endpoint.NewEndpointWithTTL(headlessDomain, endpoint.RecordTypeA, ttl, targets...)) } else { - log.Debugf("Generating matching endpoint %s with PodIP %s", headlessDomain, v.Status.PodIP) - // To reduce traffice on the DNS API only add record for running Pods. Good Idea? - if v.Status.Phase == v1.PodRunning { - if ttl.IsConfigured() { - endpoints = append(endpoints, endpoint.NewEndpointWithTTL(headlessDomain, endpoint.RecordTypeA, ttl, v.Status.PodIP)) - } else { - endpoints = append(endpoints, endpoint.NewEndpoint(headlessDomain, endpoint.RecordTypeA, v.Status.PodIP)) - } - } else { - log.Debugf("Pod %s is not in running phase", v.Spec.Hostname) - } + endpoints = append(endpoints, endpoint.NewEndpoint(headlessDomain, endpoint.RecordTypeA, targets...)) } - } return endpoints @@ -218,9 +292,10 @@ func (sc *serviceSource) endpointsFromTemplate(svc *v1.Service, nodeTargets endp return nil, fmt.Errorf("failed to apply template on service %s: %v", svc.String(), err) } + providerSpecific := getProviderSpecificAnnotations(svc.Annotations) hostnameList := strings.Split(strings.Replace(buf.String(), " ", "", -1), ",") for _, hostname := range hostnameList { - endpoints = append(endpoints, sc.generateEndpoints(svc, hostname, nodeTargets)...) + endpoints = append(endpoints, sc.generateEndpoints(svc, hostname, nodeTargets, providerSpecific)...) } return endpoints, nil @@ -229,17 +304,19 @@ func (sc *serviceSource) endpointsFromTemplate(svc *v1.Service, nodeTargets endp // endpointsFromService extracts the endpoints from a service object func (sc *serviceSource) endpoints(svc *v1.Service, nodeTargets endpoint.Targets) []*endpoint.Endpoint { var endpoints []*endpoint.Endpoint - - hostnameList := getHostnamesFromAnnotations(svc.Annotations) - for _, hostname := range hostnameList { - endpoints = append(endpoints, sc.generateEndpoints(svc, hostname, nodeTargets)...) + // Skip endpoints if we do not want entries from annotations + if !sc.ignoreHostnameAnnotation { + providerSpecific := getProviderSpecificAnnotations(svc.Annotations) + hostnameList := getHostnamesFromAnnotations(svc.Annotations) + for _, hostname := range hostnameList { + endpoints = append(endpoints, sc.generateEndpoints(svc, hostname, nodeTargets, providerSpecific)...) + } } - return endpoints } // filterByAnnotations filters a list of services by a given annotation selector. -func (sc *serviceSource) filterByAnnotations(services []v1.Service) ([]v1.Service, error) { +func (sc *serviceSource) filterByAnnotations(services []*v1.Service) ([]*v1.Service, error) { labelSelector, err := metav1.ParseToLabelSelector(sc.annotationFilter) if err != nil { return nil, err @@ -254,7 +331,7 @@ func (sc *serviceSource) filterByAnnotations(services []v1.Service) ([]v1.Servic return services, nil } - filteredList := []v1.Service{} + filteredList := []*v1.Service{} for _, service := range services { // convert the service's annotations to an equivalent label selector @@ -270,8 +347,8 @@ func (sc *serviceSource) filterByAnnotations(services []v1.Service) ([]v1.Servic } // filterByServiceType filters services according their types -func (sc *serviceSource) filterByServiceType(services []v1.Service) []v1.Service { - filteredList := []v1.Service{} +func (sc *serviceSource) filterByServiceType(services []*v1.Service) []*v1.Service { + filteredList := []*v1.Service{} for _, service := range services { // Check if the service is of the given type or not if _, ok := sc.serviceTypeFilter[string(service.Spec.Type)]; ok { @@ -282,13 +359,13 @@ func (sc *serviceSource) filterByServiceType(services []v1.Service) []v1.Service return filteredList } -func (sc *serviceSource) setResourceLabel(service v1.Service, endpoints []*endpoint.Endpoint) { +func (sc *serviceSource) setResourceLabel(service *v1.Service, endpoints []*endpoint.Endpoint) { for _, ep := range endpoints { ep.Labels[endpoint.ResourceLabelKey] = fmt.Sprintf("service/%s/%s", service.Namespace, service.Name) } } -func (sc *serviceSource) generateEndpoints(svc *v1.Service, hostname string, nodeTargets endpoint.Targets) []*endpoint.Endpoint { +func (sc *serviceSource) generateEndpoints(svc *v1.Service, hostname string, nodeTargets endpoint.Targets, providerSpecific endpoint.ProviderSpecific) []*endpoint.Endpoint { hostname = strings.TrimSuffix(hostname, ".") ttl, err := getTTLFromAnnotations(svc.Annotations) if err != nil { @@ -296,19 +373,21 @@ func (sc *serviceSource) generateEndpoints(svc *v1.Service, hostname string, nod } epA := &endpoint.Endpoint{ - RecordTTL: ttl, - RecordType: endpoint.RecordTypeA, - Labels: endpoint.NewLabels(), - Targets: make(endpoint.Targets, 0, defaultTargetsCapacity), - DNSName: hostname, + RecordTTL: ttl, + RecordType: endpoint.RecordTypeA, + Labels: endpoint.NewLabels(), + Targets: make(endpoint.Targets, 0, defaultTargetsCapacity), + DNSName: hostname, + ProviderSpecific: providerSpecific, } epCNAME := &endpoint.Endpoint{ - RecordTTL: ttl, - RecordType: endpoint.RecordTypeCNAME, - Labels: endpoint.NewLabels(), - Targets: make(endpoint.Targets, 0, defaultTargetsCapacity), - DNSName: hostname, + RecordTTL: ttl, + RecordType: endpoint.RecordTypeCNAME, + Labels: endpoint.NewLabels(), + Targets: make(endpoint.Targets, 0, defaultTargetsCapacity), + DNSName: hostname, + ProviderSpecific: providerSpecific, } var endpoints []*endpoint.Endpoint @@ -378,7 +457,7 @@ func (sc *serviceSource) extractNodeTargets() (endpoint.Targets, error) { externalIPs endpoint.Targets ) - nodes, err := sc.client.CoreV1().Nodes().List(metav1.ListOptions{}) + nodes, err := sc.nodeInformer.Lister().List(labels.Everything()) if err != nil { if errors.IsForbidden(err) { // Return an empty list because it makes sense to continue and try other sources. @@ -388,7 +467,7 @@ func (sc *serviceSource) extractNodeTargets() (endpoint.Targets, error) { return nil, err } - for _, node := range nodes.Items { + for _, node := range nodes { for _, address := range node.Status.Addresses { switch address.Type { case v1.NodeExternalIP: diff --git a/source/service_test.go b/source/service_test.go index edc4666ab..909d5456a 100644 --- a/source/service_test.go +++ b/source/service_test.go @@ -19,8 +19,11 @@ package source import ( "net" "testing" + "time" - "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/util/wait" + + v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes/fake" @@ -51,6 +54,7 @@ func (suite *ServiceSuite) SetupTest() { false, false, []string{}, + false, ) suite.fooWithTargets = &v1.Service{ Spec: v1.ServiceSpec{ @@ -142,6 +146,7 @@ func testServiceSourceNewServiceSource(t *testing.T) { false, false, ti.serviceTypesFilter, + false, ) if ti.expectError { @@ -165,6 +170,7 @@ func testServiceSourceEndpoints(t *testing.T) { compatibility string fqdnTemplate string combineFQDNAndAnnotation bool + ignoreHostnameAnnotation bool labels map[string]string annotations map[string]string clusterIP string @@ -183,6 +189,26 @@ func testServiceSourceEndpoints(t *testing.T) { "", "", false, + false, + map[string]string{}, + map[string]string{}, + "", + []string{"1.2.3.4"}, + []string{}, + []*endpoint.Endpoint{}, + false, + }, + { + "no annotated services return no endpoints when ignoreing annotations", + "", + "", + "testing", + "foo", + v1.ServiceTypeLoadBalancer, + "", + "", + false, + true, map[string]string{}, map[string]string{}, "", @@ -201,6 +227,7 @@ func testServiceSourceEndpoints(t *testing.T) { "", "", false, + false, map[string]string{}, map[string]string{ hostnameAnnotationKey: "foo.example.org.", @@ -213,6 +240,27 @@ func testServiceSourceEndpoints(t *testing.T) { }, false, }, + { + "hostname annotation on services is ignored", + "", + "", + "testing", + "foo", + v1.ServiceTypeLoadBalancer, + "", + "", + false, + true, + map[string]string{}, + map[string]string{ + hostnameAnnotationKey: "foo.example.org.", + }, + "", + []string{"1.2.3.4"}, + []string{}, + []*endpoint.Endpoint{}, + false, + }, { "annotated ClusterIp aren't processed without explicit authorization", "", @@ -223,6 +271,7 @@ func testServiceSourceEndpoints(t *testing.T) { "", "", false, + false, map[string]string{}, map[string]string{ hostnameAnnotationKey: "foo.example.org.", @@ -243,6 +292,29 @@ func testServiceSourceEndpoints(t *testing.T) { "", "{{.Name}}.fqdn.org,{{.Name}}.fqdn.com", false, + false, + map[string]string{}, + map[string]string{}, + "", + []string{"1.2.3.4"}, + []string{}, + []*endpoint.Endpoint{ + {DNSName: "foo.fqdn.org", Targets: endpoint.Targets{"1.2.3.4"}}, + {DNSName: "foo.fqdn.com", Targets: endpoint.Targets{"1.2.3.4"}}, + }, + false, + }, + { + "FQDN template with multiple hostnames return an endpoint with target IP when ignoreing annotations", + "", + "", + "testing", + "foo", + v1.ServiceTypeLoadBalancer, + "", + "{{.Name}}.fqdn.org,{{.Name}}.fqdn.com", + false, + true, map[string]string{}, map[string]string{}, "", @@ -264,6 +336,7 @@ func testServiceSourceEndpoints(t *testing.T) { "", "{{.Name}}.fqdn.org,{{.Name}}.fqdn.com", true, + false, map[string]string{}, map[string]string{ hostnameAnnotationKey: "foo.example.org., bar.example.org.", @@ -279,6 +352,30 @@ func testServiceSourceEndpoints(t *testing.T) { }, false, }, + { + "FQDN template and annotation both with multiple hostnames while ignoring annotations will only return FQDN endpoints", + "", + "", + "testing", + "foo", + v1.ServiceTypeLoadBalancer, + "", + "{{.Name}}.fqdn.org,{{.Name}}.fqdn.com", + true, + true, + map[string]string{}, + map[string]string{ + hostnameAnnotationKey: "foo.example.org., bar.example.org.", + }, + "", + []string{"1.2.3.4"}, + []string{}, + []*endpoint.Endpoint{ + {DNSName: "foo.fqdn.org", Targets: endpoint.Targets{"1.2.3.4"}}, + {DNSName: "foo.fqdn.com", Targets: endpoint.Targets{"1.2.3.4"}}, + }, + false, + }, { "annotated services with multiple hostnames return an endpoint with target IP", "", @@ -289,6 +386,7 @@ func testServiceSourceEndpoints(t *testing.T) { "", "", false, + false, map[string]string{}, map[string]string{ hostnameAnnotationKey: "foo.example.org., bar.example.org.", @@ -312,6 +410,7 @@ func testServiceSourceEndpoints(t *testing.T) { "", "", false, + false, map[string]string{}, map[string]string{ hostnameAnnotationKey: "foo.example.org, bar.example.org", @@ -335,6 +434,7 @@ func testServiceSourceEndpoints(t *testing.T) { "", "", false, + false, map[string]string{}, map[string]string{ hostnameAnnotationKey: "foo.example.org.", @@ -357,6 +457,7 @@ func testServiceSourceEndpoints(t *testing.T) { "", "", false, + false, map[string]string{}, map[string]string{ hostnameAnnotationKey: "foo.example.org", // Trailing dot is omitted @@ -380,6 +481,7 @@ func testServiceSourceEndpoints(t *testing.T) { "", "", false, + false, map[string]string{}, map[string]string{ controllerAnnotationKey: controllerAnnotationValue, @@ -403,6 +505,7 @@ func testServiceSourceEndpoints(t *testing.T) { "", "{{.Name}}.ext-dns.test.com", false, + false, map[string]string{}, map[string]string{ controllerAnnotationKey: "some-other-tool", @@ -424,6 +527,7 @@ func testServiceSourceEndpoints(t *testing.T) { "", "", false, + false, map[string]string{}, map[string]string{ hostnameAnnotationKey: "foo.example.org.", @@ -446,6 +550,7 @@ func testServiceSourceEndpoints(t *testing.T) { "", "", false, + false, map[string]string{}, map[string]string{ hostnameAnnotationKey: "foo.example.org.", @@ -466,6 +571,7 @@ func testServiceSourceEndpoints(t *testing.T) { "", "", false, + false, map[string]string{}, map[string]string{ hostnameAnnotationKey: "foo.example.org.", @@ -488,6 +594,7 @@ func testServiceSourceEndpoints(t *testing.T) { "", "", false, + false, map[string]string{}, map[string]string{ hostnameAnnotationKey: "foo.example.org.", @@ -511,6 +618,7 @@ func testServiceSourceEndpoints(t *testing.T) { "", "", false, + false, map[string]string{}, map[string]string{ hostnameAnnotationKey: "foo.example.org.", @@ -532,6 +640,7 @@ func testServiceSourceEndpoints(t *testing.T) { "", "", false, + false, map[string]string{}, map[string]string{ hostnameAnnotationKey: "foo.example.org.", @@ -553,6 +662,7 @@ func testServiceSourceEndpoints(t *testing.T) { "", "", false, + false, map[string]string{}, map[string]string{ hostnameAnnotationKey: "foo.example.org.", @@ -576,6 +686,7 @@ func testServiceSourceEndpoints(t *testing.T) { "", "", false, + false, map[string]string{}, map[string]string{ hostnameAnnotationKey: "foo.example.org.", @@ -597,6 +708,7 @@ func testServiceSourceEndpoints(t *testing.T) { "", "", false, + false, map[string]string{}, map[string]string{ hostnameAnnotationKey: "foo.example.org.", @@ -617,6 +729,7 @@ func testServiceSourceEndpoints(t *testing.T) { "", "", false, + false, map[string]string{}, map[string]string{ hostnameAnnotationKey: "foo.example.org.", @@ -639,6 +752,7 @@ func testServiceSourceEndpoints(t *testing.T) { "", "", false, + false, map[string]string{}, map[string]string{ "zalando.org/dnsname": "foo.example.org.", @@ -659,6 +773,7 @@ func testServiceSourceEndpoints(t *testing.T) { "mate", "", false, + false, map[string]string{}, map[string]string{ "zalando.org/dnsname": "foo.example.org.", @@ -681,6 +796,7 @@ func testServiceSourceEndpoints(t *testing.T) { "molecule", "", false, + false, map[string]string{ "dns": "route53", }, @@ -706,6 +822,7 @@ func testServiceSourceEndpoints(t *testing.T) { "", "{{.Name}}.bar.example.com", false, + false, map[string]string{}, map[string]string{}, "", @@ -727,6 +844,7 @@ func testServiceSourceEndpoints(t *testing.T) { "", "{{.Name}}.bar.example.com", false, + false, map[string]string{}, map[string]string{ hostnameAnnotationKey: "foo.example.org.", @@ -750,6 +868,7 @@ func testServiceSourceEndpoints(t *testing.T) { "mate", "{{.Name}}.bar.example.com", false, + false, map[string]string{}, map[string]string{ "zalando.org/dnsname": "mate.example.org.", @@ -772,6 +891,7 @@ func testServiceSourceEndpoints(t *testing.T) { "", "{{.Calibre}}.bar.example.com", false, + false, map[string]string{}, map[string]string{}, "", @@ -790,6 +910,7 @@ func testServiceSourceEndpoints(t *testing.T) { "", "", false, + false, map[string]string{}, map[string]string{ hostnameAnnotationKey: "foo.example.org.", @@ -812,6 +933,7 @@ func testServiceSourceEndpoints(t *testing.T) { "", "", false, + false, map[string]string{}, map[string]string{ hostnameAnnotationKey: "foo.example.org.", @@ -835,6 +957,7 @@ func testServiceSourceEndpoints(t *testing.T) { "", "", false, + false, map[string]string{}, map[string]string{ hostnameAnnotationKey: "foo.example.org.", @@ -858,6 +981,7 @@ func testServiceSourceEndpoints(t *testing.T) { "", "", false, + false, map[string]string{}, map[string]string{ hostnameAnnotationKey: "foo.example.org.", @@ -881,6 +1005,7 @@ func testServiceSourceEndpoints(t *testing.T) { "", "", false, + false, map[string]string{}, map[string]string{ hostnameAnnotationKey: "foo.example.org.", @@ -903,6 +1028,7 @@ func testServiceSourceEndpoints(t *testing.T) { "", "", false, + false, map[string]string{}, map[string]string{ hostnameAnnotationKey: "foo.example.org.", @@ -960,10 +1086,22 @@ func testServiceSourceEndpoints(t *testing.T) { false, false, tc.serviceTypesFilter, + tc.ignoreHostnameAnnotation, ) require.NoError(t, err) - endpoints, err := client.Endpoints() + var res []*endpoint.Endpoint + + // wait up to a few seconds for new resources to appear in informer cache. + err = wait.Poll(time.Second, 3*time.Second, func() (bool, error) { + res, err = client.Endpoints() + if err != nil { + // stop waiting if we get an error + return true, err + } + return len(res) >= len(tc.expected), nil + }) + if tc.expectError { require.Error(t, err) } else { @@ -971,7 +1109,7 @@ func testServiceSourceEndpoints(t *testing.T) { } // Validate returned endpoints against desired endpoints. - validateEndpoints(t, endpoints, tc.expected) + validateEndpoints(t, res, tc.expected) }) } } @@ -979,20 +1117,21 @@ func testServiceSourceEndpoints(t *testing.T) { // testServiceSourceEndpoints tests that various services generate the correct endpoints. func TestClusterIpServices(t *testing.T) { for _, tc := range []struct { - title string - targetNamespace string - annotationFilter string - svcNamespace string - svcName string - svcType v1.ServiceType - compatibility string - fqdnTemplate string - labels map[string]string - annotations map[string]string - clusterIP string - lbs []string - expected []*endpoint.Endpoint - expectError bool + title string + targetNamespace string + annotationFilter string + svcNamespace string + svcName string + svcType v1.ServiceType + compatibility string + fqdnTemplate string + ignoreHostnameAnnotation bool + labels map[string]string + annotations map[string]string + clusterIP string + lbs []string + expected []*endpoint.Endpoint + expectError bool }{ { "annotated ClusterIp services return an endpoint with Cluster IP", @@ -1003,6 +1142,7 @@ func TestClusterIpServices(t *testing.T) { v1.ServiceTypeClusterIP, "", "", + false, map[string]string{}, map[string]string{ hostnameAnnotationKey: "foo.example.org.", @@ -1014,6 +1154,25 @@ func TestClusterIpServices(t *testing.T) { }, false, }, + { + "hostname annotated ClusterIp services are ignored", + "", + "", + "testing", + "foo", + v1.ServiceTypeClusterIP, + "", + "", + true, + map[string]string{}, + map[string]string{ + hostnameAnnotationKey: "foo.example.org.", + }, + "1.2.3.4", + []string{}, + []*endpoint.Endpoint{}, + false, + }, { "non-annotated ClusterIp services with set fqdnTemplate return an endpoint with target IP", "", @@ -1023,6 +1182,7 @@ func TestClusterIpServices(t *testing.T) { v1.ServiceTypeClusterIP, "", "{{.Name}}.bar.example.com", + false, map[string]string{}, map[string]string{}, "4.5.6.7", @@ -1041,6 +1201,7 @@ func TestClusterIpServices(t *testing.T) { v1.ServiceTypeClusterIP, "", "", + false, map[string]string{}, map[string]string{}, v1.ClusterIPNone, @@ -1095,6 +1256,7 @@ func TestClusterIpServices(t *testing.T) { true, false, []string{}, + tc.ignoreHostnameAnnotation, ) require.NoError(t, err) @@ -1114,20 +1276,21 @@ func TestClusterIpServices(t *testing.T) { // testNodePortServices tests that various services generate the correct endpoints. func TestNodePortServices(t *testing.T) { for _, tc := range []struct { - title string - targetNamespace string - annotationFilter string - svcNamespace string - svcName string - svcType v1.ServiceType - compatibility string - fqdnTemplate string - labels map[string]string - annotations map[string]string - lbs []string - expected []*endpoint.Endpoint - expectError bool - nodes []*v1.Node + title string + targetNamespace string + annotationFilter string + svcNamespace string + svcName string + svcType v1.ServiceType + compatibility string + fqdnTemplate string + ignoreHostnameAnnotation bool + labels map[string]string + annotations map[string]string + lbs []string + expected []*endpoint.Endpoint + expectError bool + nodes []*v1.Node }{ { "annotated NodePort services return an endpoint with IP addresses of the cluster's nodes", @@ -1138,6 +1301,7 @@ func TestNodePortServices(t *testing.T) { v1.ServiceTypeNodePort, "", "", + false, map[string]string{}, map[string]string{ hostnameAnnotationKey: "foo.example.org.", @@ -1170,6 +1334,45 @@ func TestNodePortServices(t *testing.T) { }, }}, }, + { + "hostname annotated NodePort services are ignored", + "", + "", + "testing", + "foo", + v1.ServiceTypeNodePort, + "", + "", + true, + map[string]string{}, + map[string]string{ + hostnameAnnotationKey: "foo.example.org.", + }, + nil, + []*endpoint.Endpoint{}, + false, + []*v1.Node{{ + ObjectMeta: metav1.ObjectMeta{ + Name: "node1", + }, + Status: v1.NodeStatus{ + Addresses: []v1.NodeAddress{ + {Type: v1.NodeExternalIP, Address: "54.10.11.1"}, + {Type: v1.NodeInternalIP, Address: "10.0.1.1"}, + }, + }, + }, { + ObjectMeta: metav1.ObjectMeta{ + Name: "node2", + }, + Status: v1.NodeStatus{ + Addresses: []v1.NodeAddress{ + {Type: v1.NodeExternalIP, Address: "54.10.11.2"}, + {Type: v1.NodeInternalIP, Address: "10.0.1.2"}, + }, + }, + }}, + }, { "non-annotated NodePort services with set fqdnTemplate return an endpoint with target IP", "", @@ -1179,6 +1382,7 @@ func TestNodePortServices(t *testing.T) { v1.ServiceTypeNodePort, "", "{{.Name}}.bar.example.com", + false, map[string]string{}, map[string]string{}, nil, @@ -1218,6 +1422,7 @@ func TestNodePortServices(t *testing.T) { v1.ServiceTypeNodePort, "", "", + false, map[string]string{}, map[string]string{ hostnameAnnotationKey: "foo.example.org.", @@ -1292,6 +1497,7 @@ func TestNodePortServices(t *testing.T) { true, false, []string{}, + tc.ignoreHostnameAnnotation, ) require.NoError(t, err) @@ -1311,24 +1517,25 @@ func TestNodePortServices(t *testing.T) { // TestHeadlessServices tests that headless services generate the correct endpoints. func TestHeadlessServices(t *testing.T) { for _, tc := range []struct { - title string - targetNamespace string - svcNamespace string - svcName string - svcType v1.ServiceType - compatibility string - fqdnTemplate string - labels map[string]string - annotations map[string]string - clusterIP string - podIP string - selector map[string]string - lbs []string - podnames []string - hostnames []string - phases []v1.PodPhase - expected []*endpoint.Endpoint - expectError bool + title string + targetNamespace string + svcNamespace string + svcName string + svcType v1.ServiceType + compatibility string + fqdnTemplate string + ignoreHostnameAnnotation bool + labels map[string]string + annotations map[string]string + clusterIP string + podIPs []string + selector map[string]string + lbs []string + podnames []string + hostnames []string + phases []v1.PodPhase + expected []*endpoint.Endpoint + expectError bool }{ { "annotated Headless services return endpoints for each selected Pod", @@ -1338,12 +1545,13 @@ func TestHeadlessServices(t *testing.T) { v1.ServiceTypeClusterIP, "", "", + false, map[string]string{"component": "foo"}, map[string]string{ hostnameAnnotationKey: "service.example.org", }, v1.ClusterIPNone, - "1.1.1.1", + []string{"1.1.1.1", "1.1.1.2"}, map[string]string{ "component": "foo", }, @@ -1353,10 +1561,36 @@ func TestHeadlessServices(t *testing.T) { []v1.PodPhase{v1.PodRunning, v1.PodRunning}, []*endpoint.Endpoint{ {DNSName: "foo-0.service.example.org", Targets: endpoint.Targets{"1.1.1.1"}}, - {DNSName: "foo-1.service.example.org", Targets: endpoint.Targets{"1.1.1.1"}}, + {DNSName: "foo-1.service.example.org", Targets: endpoint.Targets{"1.1.1.2"}}, + {DNSName: "service.example.org", Targets: endpoint.Targets{"1.1.1.1", "1.1.1.2"}}, }, false, }, + { + "hostname annotated Headless services are ignored", + "", + "testing", + "foo", + v1.ServiceTypeClusterIP, + "", + "", + true, + map[string]string{"component": "foo"}, + map[string]string{ + hostnameAnnotationKey: "service.example.org", + }, + v1.ClusterIPNone, + []string{"1.1.1.1", "1.1.1.2"}, + map[string]string{ + "component": "foo", + }, + []string{}, + []string{"foo-0", "foo-1"}, + []string{"foo-0", "foo-1"}, + []v1.PodPhase{v1.PodRunning, v1.PodRunning}, + []*endpoint.Endpoint{}, + false, + }, { "annotated Headless services return endpoints with TTL for each selected Pod", "", @@ -1365,13 +1599,14 @@ func TestHeadlessServices(t *testing.T) { v1.ServiceTypeClusterIP, "", "", + false, map[string]string{"component": "foo"}, map[string]string{ hostnameAnnotationKey: "service.example.org", ttlAnnotationKey: "1", }, v1.ClusterIPNone, - "1.1.1.1", + []string{"1.1.1.1", "1.1.1.2"}, map[string]string{ "component": "foo", }, @@ -1381,7 +1616,8 @@ func TestHeadlessServices(t *testing.T) { []v1.PodPhase{v1.PodRunning, v1.PodRunning}, []*endpoint.Endpoint{ {DNSName: "foo-0.service.example.org", Targets: endpoint.Targets{"1.1.1.1"}, RecordTTL: endpoint.TTL(1)}, - {DNSName: "foo-1.service.example.org", Targets: endpoint.Targets{"1.1.1.1"}, RecordTTL: endpoint.TTL(1)}, + {DNSName: "foo-1.service.example.org", Targets: endpoint.Targets{"1.1.1.2"}, RecordTTL: endpoint.TTL(1)}, + {DNSName: "service.example.org", Targets: endpoint.Targets{"1.1.1.1", "1.1.1.2"}, RecordTTL: endpoint.TTL(1)}, }, false, }, @@ -1393,12 +1629,13 @@ func TestHeadlessServices(t *testing.T) { v1.ServiceTypeClusterIP, "", "", + false, map[string]string{"component": "foo"}, map[string]string{ hostnameAnnotationKey: "service.example.org", }, v1.ClusterIPNone, - "1.1.1.1", + []string{"1.1.1.1", "1.1.1.2"}, map[string]string{ "component": "foo", }, @@ -1408,6 +1645,7 @@ func TestHeadlessServices(t *testing.T) { []v1.PodPhase{v1.PodRunning, v1.PodFailed}, []*endpoint.Endpoint{ {DNSName: "foo-0.service.example.org", Targets: endpoint.Targets{"1.1.1.1"}}, + {DNSName: "service.example.org", Targets: endpoint.Targets{"1.1.1.1"}}, }, false, }, @@ -1419,12 +1657,13 @@ func TestHeadlessServices(t *testing.T) { v1.ServiceTypeClusterIP, "", "", + false, map[string]string{"component": "foo"}, map[string]string{ hostnameAnnotationKey: "service.example.org", }, v1.ClusterIPNone, - "1.1.1.1", + []string{"1.1.1.1", "1.1.1.2"}, map[string]string{ "component": "foo", }, @@ -1433,8 +1672,7 @@ func TestHeadlessServices(t *testing.T) { []string{"", ""}, []v1.PodPhase{v1.PodRunning, v1.PodRunning}, []*endpoint.Endpoint{ - {DNSName: "service.example.org", Targets: endpoint.Targets{"1.1.1.1"}}, - {DNSName: "service.example.org", Targets: endpoint.Targets{"1.1.1.1"}}, + {DNSName: "service.example.org", Targets: endpoint.Targets{"1.1.1.1", "1.1.1.2"}}, }, false, }, @@ -1473,7 +1711,7 @@ func TestHeadlessServices(t *testing.T) { Annotations: tc.annotations, }, Status: v1.PodStatus{ - PodIP: tc.podIP, + PodIP: tc.podIPs[i], Phase: tc.phases[i], }, } @@ -1493,6 +1731,7 @@ func TestHeadlessServices(t *testing.T) { true, false, []string{}, + tc.ignoreHostnameAnnotation, ) require.NoError(t, err) @@ -1512,24 +1751,25 @@ func TestHeadlessServices(t *testing.T) { // TestHeadlessServices tests that headless services generate the correct endpoints. func TestHeadlessServicesHostIP(t *testing.T) { for _, tc := range []struct { - title string - targetNamespace string - svcNamespace string - svcName string - svcType v1.ServiceType - compatibility string - fqdnTemplate string - labels map[string]string - annotations map[string]string - clusterIP string - hostIP string - selector map[string]string - lbs []string - podnames []string - hostnames []string - phases []v1.PodPhase - expected []*endpoint.Endpoint - expectError bool + title string + targetNamespace string + svcNamespace string + svcName string + svcType v1.ServiceType + compatibility string + fqdnTemplate string + ignoreHostnameAnnotation bool + labels map[string]string + annotations map[string]string + clusterIP string + hostIPs []string + selector map[string]string + lbs []string + podnames []string + hostnames []string + phases []v1.PodPhase + expected []*endpoint.Endpoint + expectError bool }{ { "annotated Headless services return endpoints for each selected Pod", @@ -1539,12 +1779,13 @@ func TestHeadlessServicesHostIP(t *testing.T) { v1.ServiceTypeClusterIP, "", "", + false, map[string]string{"component": "foo"}, map[string]string{ hostnameAnnotationKey: "service.example.org", }, v1.ClusterIPNone, - "1.1.1.1", + []string{"1.1.1.1", "1.1.1.2"}, map[string]string{ "component": "foo", }, @@ -1554,10 +1795,36 @@ func TestHeadlessServicesHostIP(t *testing.T) { []v1.PodPhase{v1.PodRunning, v1.PodRunning}, []*endpoint.Endpoint{ {DNSName: "foo-0.service.example.org", Targets: endpoint.Targets{"1.1.1.1"}}, - {DNSName: "foo-1.service.example.org", Targets: endpoint.Targets{"1.1.1.1"}}, + {DNSName: "foo-1.service.example.org", Targets: endpoint.Targets{"1.1.1.2"}}, + {DNSName: "service.example.org", Targets: endpoint.Targets{"1.1.1.1", "1.1.1.2"}}, }, false, }, + { + "hostname annotated Headless services are ignored", + "", + "testing", + "foo", + v1.ServiceTypeClusterIP, + "", + "", + true, + map[string]string{"component": "foo"}, + map[string]string{ + hostnameAnnotationKey: "service.example.org", + }, + v1.ClusterIPNone, + []string{"1.1.1.1", "1.1.1.2"}, + map[string]string{ + "component": "foo", + }, + []string{}, + []string{"foo-0", "foo-1"}, + []string{"foo-0", "foo-1"}, + []v1.PodPhase{v1.PodRunning, v1.PodRunning}, + []*endpoint.Endpoint{}, + false, + }, { "annotated Headless services return endpoints with TTL for each selected Pod", "", @@ -1566,13 +1833,14 @@ func TestHeadlessServicesHostIP(t *testing.T) { v1.ServiceTypeClusterIP, "", "", + false, map[string]string{"component": "foo"}, map[string]string{ hostnameAnnotationKey: "service.example.org", ttlAnnotationKey: "1", }, v1.ClusterIPNone, - "1.1.1.1", + []string{"1.1.1.1", "1.1.1.2"}, map[string]string{ "component": "foo", }, @@ -1582,7 +1850,8 @@ func TestHeadlessServicesHostIP(t *testing.T) { []v1.PodPhase{v1.PodRunning, v1.PodRunning}, []*endpoint.Endpoint{ {DNSName: "foo-0.service.example.org", Targets: endpoint.Targets{"1.1.1.1"}, RecordTTL: endpoint.TTL(1)}, - {DNSName: "foo-1.service.example.org", Targets: endpoint.Targets{"1.1.1.1"}, RecordTTL: endpoint.TTL(1)}, + {DNSName: "foo-1.service.example.org", Targets: endpoint.Targets{"1.1.1.2"}, RecordTTL: endpoint.TTL(1)}, + {DNSName: "service.example.org", Targets: endpoint.Targets{"1.1.1.1", "1.1.1.2"}, RecordTTL: endpoint.TTL(1)}, }, false, }, @@ -1594,12 +1863,13 @@ func TestHeadlessServicesHostIP(t *testing.T) { v1.ServiceTypeClusterIP, "", "", + false, map[string]string{"component": "foo"}, map[string]string{ hostnameAnnotationKey: "service.example.org", }, v1.ClusterIPNone, - "1.1.1.1", + []string{"1.1.1.1", "1.1.1.2"}, map[string]string{ "component": "foo", }, @@ -1609,6 +1879,7 @@ func TestHeadlessServicesHostIP(t *testing.T) { []v1.PodPhase{v1.PodRunning, v1.PodFailed}, []*endpoint.Endpoint{ {DNSName: "foo-0.service.example.org", Targets: endpoint.Targets{"1.1.1.1"}}, + {DNSName: "service.example.org", Targets: endpoint.Targets{"1.1.1.1"}}, }, false, }, @@ -1620,12 +1891,13 @@ func TestHeadlessServicesHostIP(t *testing.T) { v1.ServiceTypeClusterIP, "", "", + false, map[string]string{"component": "foo"}, map[string]string{ hostnameAnnotationKey: "service.example.org", }, v1.ClusterIPNone, - "1.1.1.1", + []string{"1.1.1.1", "1.1.1.2"}, map[string]string{ "component": "foo", }, @@ -1634,8 +1906,7 @@ func TestHeadlessServicesHostIP(t *testing.T) { []string{"", ""}, []v1.PodPhase{v1.PodRunning, v1.PodRunning}, []*endpoint.Endpoint{ - {DNSName: "service.example.org", Targets: endpoint.Targets{"1.1.1.1"}}, - {DNSName: "service.example.org", Targets: endpoint.Targets{"1.1.1.1"}}, + {DNSName: "service.example.org", Targets: endpoint.Targets{"1.1.1.1", "1.1.1.2"}}, }, false, }, @@ -1674,7 +1945,7 @@ func TestHeadlessServicesHostIP(t *testing.T) { Annotations: tc.annotations, }, Status: v1.PodStatus{ - HostIP: tc.hostIP, + HostIP: tc.hostIPs[i], Phase: tc.phases[i], }, } @@ -1694,6 +1965,7 @@ func TestHeadlessServicesHostIP(t *testing.T) { true, true, []string{}, + tc.ignoreHostnameAnnotation, ) require.NoError(t, err) @@ -1734,7 +2006,7 @@ func BenchmarkServiceEndpoints(b *testing.B) { _, err := kubernetes.CoreV1().Services(service.Namespace).Create(service) require.NoError(b, err) - client, err := NewServiceSource(kubernetes, v1.NamespaceAll, "", "", false, "", false, false, []string{}) + client, err := NewServiceSource(kubernetes, v1.NamespaceAll, "", "", false, "", false, false, []string{}, false) require.NoError(b, err) for i := 0; i < b.N; i++ { diff --git a/source/shared_test.go b/source/shared_test.go index aedd47106..09645fcb4 100644 --- a/source/shared_test.go +++ b/source/shared_test.go @@ -17,6 +17,8 @@ limitations under the License. package source import ( + "sort" + "strings" "testing" "github.com/kubernetes-incubator/external-dns/endpoint" @@ -28,6 +30,13 @@ func validateEndpoints(t *testing.T, endpoints, expected []*endpoint.Endpoint) { if len(endpoints) != len(expected) { t.Fatalf("expected %d endpoints, got %d", len(expected), len(endpoints)) } + // Make sure endpoints are sorted - validateEndpoint() depends on it. + sort.SliceStable(endpoints, func(i, j int) bool { + return strings.Compare(endpoints[i].DNSName, endpoints[j].DNSName) < 0 + }) + sort.SliceStable(expected, func(i, j int) bool { + return strings.Compare(expected[i].DNSName, expected[j].DNSName) < 0 + }) for i := range endpoints { validateEndpoint(t, endpoints[i], expected[i]) diff --git a/source/source.go b/source/source.go index 338397dfc..c4e856fb9 100644 --- a/source/source.go +++ b/source/source.go @@ -41,6 +41,12 @@ const ( controllerAnnotationValue = "dns-controller" ) +// Provider-specific annotations +const ( + // The annotation used for determining if traffic will go through Cloudflare + CloudflareProxiedKey = "external-dns.alpha.kubernetes.io/cloudflare-proxied" +) + const ( ttlMinimum = 1 ttlMaximum = math.MaxUint32 @@ -72,7 +78,6 @@ func getHostnamesFromAnnotations(annotations map[string]string) []string { if !exists { return nil } - return strings.Split(strings.Replace(hostnameAnnotation, " ", "", -1), ",") } @@ -82,10 +87,22 @@ func getAliasFromAnnotations(annotations map[string]string) bool { } func getProviderSpecificAnnotations(annotations map[string]string) endpoint.ProviderSpecific { - if getAliasFromAnnotations(annotations) { - return map[string]string{"alias": "true"} + providerSpecificAnnotations := endpoint.ProviderSpecific{} + + v, exists := annotations[CloudflareProxiedKey] + if exists { + providerSpecificAnnotations = append(providerSpecificAnnotations, endpoint.ProviderSpecificProperty{ + Name: CloudflareProxiedKey, + Value: v, + }) } - return map[string]string{} + if getAliasFromAnnotations(annotations) { + providerSpecificAnnotations = append(providerSpecificAnnotations, endpoint.ProviderSpecificProperty{ + Name: "alias", + Value: "true", + }) + } + return providerSpecificAnnotations } // getTargetsFromTargetAnnotation gets endpoints from optional "target" annotation. diff --git a/source/store.go b/source/store.go index 10ab64eb7..5491085e8 100644 --- a/source/store.go +++ b/source/store.go @@ -25,6 +25,7 @@ import ( "sync" + cfclient "github.com/cloudfoundry-community/go-cfclient" "github.com/linki/instrumented_http" log "github.com/sirupsen/logrus" istiocrd "istio.io/istio/pilot/pkg/config/kube/crd" @@ -38,26 +39,31 @@ var ErrSourceNotFound = errors.New("source not found") // Config holds shared configuration options for all Sources. type Config struct { - Namespace string - AnnotationFilter string - FQDNTemplate string - CombineFQDNAndAnnotation bool - Compatibility string - PublishInternal bool - PublishHostIP bool - ConnectorServer string - CRDSourceAPIVersion string - CRDSourceKind string - KubeConfig string - KubeMaster string - ServiceTypeFilter []string - IstioIngressGateway string + Namespace string + AnnotationFilter string + FQDNTemplate string + CombineFQDNAndAnnotation bool + IgnoreHostnameAnnotation bool + Compatibility string + PublishInternal bool + PublishHostIP bool + ConnectorServer string + CRDSourceAPIVersion string + CRDSourceKind string + KubeConfig string + KubeMaster string + ServiceTypeFilter []string + IstioIngressGatewayServices []string + CFAPIEndpoint string + CFUsername string + CFPassword string } // ClientGenerator provides clients type ClientGenerator interface { KubeClient() (kubernetes.Interface, error) IstioClient() (istiomodel.ConfigStore, error) + CloudFoundryClient(cfAPPEndpoint string, cfUsername string, cfPassword string) (*cfclient.Client, error) } // SingletonClientGenerator stores provider clients and guarantees that only one instance of client @@ -68,8 +74,10 @@ type SingletonClientGenerator struct { RequestTimeout time.Duration kubeClient kubernetes.Interface istioClient istiomodel.ConfigStore + cfClient *cfclient.Client kubeOnce sync.Once istioOnce sync.Once + cfOnce sync.Once } // KubeClient generates a kube client if it was not created before @@ -90,6 +98,30 @@ func (p *SingletonClientGenerator) IstioClient() (istiomodel.ConfigStore, error) return p.istioClient, err } +// CloudFoundryClient generates a cf client if it was not created before +func (p *SingletonClientGenerator) CloudFoundryClient(cfAPIEndpoint string, cfUsername string, cfPassword string) (*cfclient.Client, error) { + var err error + p.cfOnce.Do(func() { + p.cfClient, err = NewCFClient(cfAPIEndpoint, cfUsername, cfPassword) + }) + return p.cfClient, err +} + +// NewCFClient return a new CF client object. +func NewCFClient(cfAPIEndpoint string, cfUsername string, cfPassword string) (*cfclient.Client, error) { + c := &cfclient.Config{ + ApiAddress: "https://" + cfAPIEndpoint, + Username: cfUsername, + Password: cfPassword, + } + client, err := cfclient.NewClient(c) + if err != nil { + return nil, err + } + + return client, nil +} + // ByNames returns multiple Sources given multiple names. func ByNames(p ClientGenerator, names []string, cfg *Config) ([]Source, error) { sources := []Source{} @@ -112,13 +144,13 @@ func BuildWithConfig(source string, p ClientGenerator, cfg *Config) (Source, err if err != nil { return nil, err } - return NewServiceSource(client, cfg.Namespace, cfg.AnnotationFilter, cfg.FQDNTemplate, cfg.CombineFQDNAndAnnotation, cfg.Compatibility, cfg.PublishInternal, cfg.PublishHostIP, cfg.ServiceTypeFilter) + return NewServiceSource(client, cfg.Namespace, cfg.AnnotationFilter, cfg.FQDNTemplate, cfg.CombineFQDNAndAnnotation, cfg.Compatibility, cfg.PublishInternal, cfg.PublishHostIP, cfg.ServiceTypeFilter, cfg.IgnoreHostnameAnnotation) case "ingress": client, err := p.KubeClient() if err != nil { return nil, err } - return NewIngressSource(client, cfg.Namespace, cfg.AnnotationFilter, cfg.FQDNTemplate, cfg.CombineFQDNAndAnnotation) + return NewIngressSource(client, cfg.Namespace, cfg.AnnotationFilter, cfg.FQDNTemplate, cfg.CombineFQDNAndAnnotation, cfg.IgnoreHostnameAnnotation) case "istio-gateway": kubernetesClient, err := p.KubeClient() if err != nil { @@ -128,7 +160,13 @@ func BuildWithConfig(source string, p ClientGenerator, cfg *Config) (Source, err if err != nil { return nil, err } - return NewIstioGatewaySource(kubernetesClient, istioClient, cfg.IstioIngressGateway, cfg.Namespace, cfg.AnnotationFilter, cfg.FQDNTemplate, cfg.CombineFQDNAndAnnotation) + return NewIstioGatewaySource(kubernetesClient, istioClient, cfg.IstioIngressGatewayServices, cfg.Namespace, cfg.AnnotationFilter, cfg.FQDNTemplate, cfg.CombineFQDNAndAnnotation, cfg.IgnoreHostnameAnnotation) + case "cloudfoundry": + cfClient, err := p.CloudFoundryClient(cfg.CFAPIEndpoint, cfg.CFUsername, cfg.CFPassword) + if err != nil { + return nil, err + } + return NewCloudFoundrySource(cfClient) case "fake": return NewFakeSource(cfg.FQDNTemplate) case "connector": diff --git a/source/store_test.go b/source/store_test.go index 1ba8ad115..176ac9bbd 100644 --- a/source/store_test.go +++ b/source/store_test.go @@ -24,14 +24,16 @@ import ( "k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes/fake" + cfclient "github.com/cloudfoundry-community/go-cfclient" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/suite" ) type MockClientGenerator struct { mock.Mock - kubeClient kubernetes.Interface - istioClient istiomodel.ConfigStore + kubeClient kubernetes.Interface + istioClient istiomodel.ConfigStore + cloudFoundryClient *cfclient.Client } func (m *MockClientGenerator) KubeClient() (kubernetes.Interface, error) { @@ -52,6 +54,15 @@ func (m *MockClientGenerator) IstioClient() (istiomodel.ConfigStore, error) { return nil, args.Error(1) } +func (m *MockClientGenerator) CloudFoundryClient(cfAPIEndpoint string, cfUsername string, cfPassword string) (*cfclient.Client, error) { + args := m.Called() + if args.Error(1) == nil { + m.cloudFoundryClient = args.Get(0).(*cfclient.Client) + return m.cloudFoundryClient, nil + } + return nil, args.Error(1) +} + type ByNamesTestSuite struct { suite.Suite } @@ -113,5 +124,5 @@ func TestByNames(t *testing.T) { } var minimalConfig = &Config{ - IstioIngressGateway: "istio-system/istio-ingressgateway", + IstioIngressGatewayServices: []string{"istio-system/istio-ingressgateway"}, }