diff --git a/.travis.yml b/.travis.yml index 6bd734cd6..166bcf585 100644 --- a/.travis.yml +++ b/.travis.yml @@ -21,8 +21,6 @@ before_install: install: - gometalinter --install -- sed -i 's/--deadline=50s/--deadline=120s/g' - ./vendor/github.com/kubernetes/repo-infra/verify/go-tools/verify-gometalinter.sh script: - vendor/github.com/kubernetes/repo-infra/verify/verify-boilerplate.sh --rootdir=$(pwd) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b8023bf3..4f4471e8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,34 @@ + - Add aws evaluate target health flag (#628) @peterbale + +## v0.5.4 - 2018-06-28 + + - Only store endpoints with their labels in the cache (#612) @njuettner + - Read hostnames from spec.tls.hosts on Ingress object (#611) @ysoldak + - Reorder provider/aws suitable-zones tests (#608) @elordahl + - Adds TLS flags for pdns provider (#607) @jhoch-palantir + - Update RBAC for external-dns to list nodes (#600) @njuettner + - Add aws max change count flag (#596) @peterbale + - AWS provider: Properly check suitable domains (#594) @elordahl + - Annotation with upper-case hostnames block further updates (#579) @njuettner + +## v0.5.3 - 2018-06-15 + + - Print a message if no hosted zones match (aws provider) (#592) @svend + - Add support for NodePort services (#559) @grimmy + - Update azure.md to fix protocol value (#593) @JasonvanBrackel + - Add cache to limit calls to providers (#589) @jessfraz + - Add Azure MSI support (#578) @r7vme + - CoreDNS/SkyDNS provider (#253) @istalker2 + +## v0.5.2 - 2018-05-31 + + - DNSimple: Make DNSimple tolerant of unknown zones (#574) @jbowes + - Cloudflare: Custom record TTL (#572) @njuettner + - AWS ServiceDiscovery: Implementation of AWS ServiceDiscovery provider (#483) @vanekjar + - Update docs to latest changes (#563) @Raffo + - New source - connector (#552) @shashidharatd + - Update AWS SDK dependency to v1.13.7 @vanekjar + ## v0.5.1 - 2018-05-16 - Refactor implementation of sync loop to use `time.Ticker` (#553) @r0fls diff --git a/Gopkg.lock b/Gopkg.lock index 6c8d61e4b..6ba5fc6e5 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -24,8 +24,8 @@ "autorest/date", "autorest/to" ] - revision = "58f6f26e200fa5dfb40c9cd1c83f3e2c860d779d" - version = "v8.0.0" + revision = "aa2a4534ab680e938d933870f58f23f77e0e208e" + version = "v10.9.0" [[projects]] name = "github.com/PuerkitoBio/purell" @@ -108,6 +108,18 @@ revision = "4c6994ac3877fbb627766edadc67f4e816e8c890" version = "v0.7.4" +[[projects]] + name = "github.com/coreos/etcd" + packages = [ + "client", + "pkg/pathutil", + "pkg/srv", + "pkg/types", + "version" + ] + revision = "1b3ac99e8a431b381e633802cc42fe70e663baf5" + version = "v3.2.15" + [[projects]] name = "github.com/coreos/go-oidc" packages = [ @@ -119,6 +131,12 @@ ] revision = "be73733bb8cc830d0205609b95d125215f8e9c70" +[[projects]] + name = "github.com/coreos/go-semver" + packages = ["semver"] + revision = "8ab6407b697782a06568d4b7f1db25550ec2e4c6" + version = "v0.2.0" + [[projects]] name = "github.com/coreos/pkg" packages = [ @@ -131,13 +149,14 @@ [[projects]] name = "github.com/davecgh/go-spew" packages = ["spew"] - revision = "5215b55f46b2b919f50a1df0eaa5886afe4e3b3d" + revision = "346938d642f2ec3594ed81d874461961cd0faa76" + version = "v1.1.0" [[projects]] name = "github.com/dgrijalva/jwt-go" packages = ["."] - revision = "d2709f9f1f31ebcda9651b03077758c1f3a0018c" - version = "v3.0.0" + revision = "06ea1031745cb8b3dab3f6a236daf2b0aa468b7e" + version = "v3.2.0" [[projects]] name = "github.com/digitalocean/godo" @@ -171,6 +190,12 @@ ] revision = "09691a3b6378b740595c1002f40c34dd5f218a22" +[[projects]] + name = "github.com/exoscale/egoscale" + packages = ["."] + revision = "631ee6ea16ccb48a0c98054fdbf0f6e94d8f4a8c" + version = "v0.9.31" + [[projects]] branch = "master" name = "github.com/ffledgling/pdns-go" @@ -267,7 +292,13 @@ branch = "master" name = "github.com/infobloxopen/infoblox-go-client" packages = ["."] - revision = "e2811d86bed7bb487eeb0806337b6f9e9d93d5e7" + revision = "61dc5f9b0a655ebf43026f0d8a837ad1e28e4b96" + +[[projects]] + branch = "master" + name = "github.com/jinzhu/copier" + packages = ["."] + revision = "7e38e58719c33e0d44d585c4ab477a30f8cb82dd" [[projects]] name = "github.com/jmespath/go-jmespath" @@ -287,7 +318,7 @@ [[projects]] name = "github.com/kubernetes/repo-infra" packages = ["verify/boilerplate/test"] - revision = "2d2eb5e12b4663fc4d764b5db9daab39334d3f37" + revision = "c2f9667a4c29e70a39b0e89db2d4f0cab907dbee" [[projects]] name = "github.com/linki/instrumented_http" @@ -316,6 +347,15 @@ revision = "cdd946344b54bdf7dbeac406c2f1fe93150f08ea" version = "v0.6.0" +[[projects]] + name = "github.com/oracle/oci-go-sdk" + packages = [ + "common", + "dns" + ] + revision = "a2ded717dc4bb4916c0416ec79f81718b576dbc4" + version = "v1.8.0" + [[projects]] name = "github.com/pkg/errors" packages = ["."] @@ -324,7 +364,8 @@ [[projects]] name = "github.com/pmezard/go-difflib" packages = ["difflib"] - revision = "d8ed2627bdf02c080bf22230dbb337003b7aba2d" + revision = "792786c7400a136282c1664665ae0a8db921c6c2" + version = "v1.0.0" [[projects]] name = "github.com/prometheus/client_golang" @@ -373,7 +414,8 @@ [[projects]] name = "github.com/stretchr/objx" packages = ["."] - revision = "cbeaeb16a013161a98496fad62933b1d21786672" + revision = "facf9a85c22f48d2f52f2380e4efce1768749a89" + version = "v0.1" [[projects]] name = "github.com/stretchr/testify" @@ -383,8 +425,8 @@ "require", "suite" ] - revision = "69483b4bd14f5845b5a1e55bca19e954e827f1d0" - version = "v1.1.4" + revision = "12b6f73e6084dad08a7c6e575284b177ecafbc71" + version = "v1.2.1" [[projects]] branch = "master" @@ -410,7 +452,8 @@ "http2", "http2/hpack", "idna", - "lex/httplex" + "lex/httplex", + "publicsuffix" ] revision = "e90d6d0afc4c315a0d87a568ae68577cc15149a0" @@ -648,6 +691,6 @@ [solve-meta] analyzer-name = "dep" analyzer-version = 1 - inputs-digest = "d5deea43eb04e9ef3a6ecb3589b91c149e092505f66905baa01c67379776d231" + inputs-digest = "d704eb6432ef9b41338900e647421a195366f87134918f9feb023fc377064f57" solver-name = "gps-cdcl" solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml index 6042ecd50..6cdb5c119 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -10,7 +10,7 @@ ignored = ["github.com/kubernetes/repo-infra/kazel"] [[constraint]] name = "github.com/Azure/go-autorest" - version = "~8.0.0" + version = "~10.9.0" [[constraint]] name = "github.com/alecthomas/kingpin" @@ -24,6 +24,10 @@ ignored = ["github.com/kubernetes/repo-infra/kazel"] name = "github.com/cloudflare/cloudflare-go" version = "0.7.3" +[[constraint]] + name = "github.com/coreos/etcd" + version = "~3.2.15" + [[constraint]] name = "github.com/digitalocean/godo" version = "~1.1.0" @@ -50,7 +54,7 @@ ignored = ["github.com/kubernetes/repo-infra/kazel"] [[constraint]] name = "github.com/stretchr/testify" - version = "~1.1.4" + version = "~1.2.1" [[constraint]] name = "k8s.io/client-go" @@ -58,8 +62,16 @@ ignored = ["github.com/kubernetes/repo-infra/kazel"] [[override]] name = "github.com/kubernetes/repo-infra" - revision = "2d2eb5e12b4663fc4d764b5db9daab39334d3f37" + revision = "c2f9667a4c29e70a39b0e89db2d4f0cab907dbee" [[constraint]] name = "github.com/nesv/go-dynect" version = "0.6.0" + +[[constraint]] + name = "github.com/exoscale/egoscale" + version = "~0.9.31" + +[[constraint]] + name = "github.com/oracle/oci-go-sdk" + version = "1.8.0" diff --git a/README.md b/README.md index 6e1d854c0..9a847370b 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ To see ExternalDNS in action, have a look at this [video](https://www.youtube.co 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/) * [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/de/dns) * [DigitalOcean](https://www.digitalocean.com/products/networking) @@ -34,6 +35,9 @@ ExternalDNS' current release is `v0.5`. This version allows you to keep selected * [Dyn](https://dyn.com/dns/) * [OpenStack Designate](https://docs.openstack.org/designate/latest/) * [PowerDNS](https://www.powerdns.com/) +* [CoreDNS](https://coredns.io/) +* [Exoscale](https://www.exoscale.com/dns/) +* [Oracle Cloud Infrastructure DNS](https://docs.cloud.oracle.com/iaas/Content/DNS/Concepts/dnszonemanagement.htm) 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. @@ -55,6 +59,8 @@ The following tutorials are provided: * Google Container Engine * [Using Google's Default Ingress Controller](docs/tutorials/gke.md) * [Using the Nginx Ingress Controller](docs/tutorials/nginx-ingress.md) +* [Exoscale](docs/tutorials/exoscale.md) +* [Oracle Cloud Infrastructure (OCI) DNS](docs/tutorials/oracle.md) ## Running Locally diff --git a/delivery.yaml b/delivery.yaml index ddbc25921..28ed23b5d 100644 --- a/delivery.yaml +++ b/delivery.yaml @@ -9,10 +9,11 @@ build_steps: apt-get install -y docker-ce - desc: Build and push docker image cmd: | - image=registry-write.opensource.zalan.do/teapot/external-dns:$(git describe --always --dirty --tags) - docker build --squash --tag $image . - IS_PR_BUILD=${CDP_PULL_REQUEST_NUMBER+"true"} - if [[ ${CDP_TARGET_BRANCH} == "master" && ${IS_PR_BUILD} != "true" ]] - then - docker push $image + 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} + else + IMAGE=registry-write.opensource.zalan.do/teapot/external-dns-test:${CDP_BUILD_VERSION} fi + docker build --squash --tag "$IMAGE" . + docker push "$IMAGE" diff --git a/docs/faq.md b/docs/faq.md index b62497a72..dcf6873fb 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -169,7 +169,7 @@ $ docker run \ -e EXTERNAL_DNS_SOURCE=$'service\ningress' \ -e EXTERNAL_DNS_PROVIDER=google \ -e EXTERNAL_DNS_DOMAIN_FILTER=$'foo.com\nbar.com' \ - registry.opensource.zalan.do/teapot/external-dns:v0.5.1 + registry.opensource.zalan.do/teapot/external-dns:latest time="2017-08-08T14:10:26Z" level=info msg="config: &{Master: KubeConfig: Sources:[service ingress] Namespace: ... ``` diff --git a/docs/initial-design.md b/docs/initial-design.md index 6c021ee60..be9ba2d81 100644 --- a/docs/initial-design.md +++ b/docs/initial-design.md @@ -37,7 +37,9 @@ New cloud providers should be easily pluggable. Initially only AWS/Google platfo DNS records will be automatically created in multiple situations: 1. Setting `spec.rules.host` on an ingress object. -2. Adding the annotation `external-dns.alpha.kubernetes.io/hostname` on a `type=LoadBalancer` service object. +2. Setting `spec.tls.hosts` on an ingress object. +3. Adding the annotation `external-dns.alpha.kubernetes.io/hostname` on an ingress object. +4. Adding the annotation `external-dns.alpha.kubernetes.io/hostname` on a `type=LoadBalancer` service object. ### Annotations diff --git a/docs/tutorials/aws-sd.md b/docs/tutorials/aws-sd.md index 04ab45ff7..b554e42e7 100644 --- a/docs/tutorials/aws-sd.md +++ b/docs/tutorials/aws-sd.md @@ -77,7 +77,7 @@ spec: spec: containers: - name: external-dns - image: registry.opensource.zalan.do/teapot/external-dns:v0.4.8 + image: registry.opensource.zalan.do/teapot/external-dns:latest args: - --source=service - --source=ingress @@ -197,4 +197,4 @@ $ aws servicediscovery list-namespaces ```console $ aws servicediscovery delete-namespace --id ns-durf2oxu4gxcgo6z -``` \ No newline at end of file +``` diff --git a/docs/tutorials/aws.md b/docs/tutorials/aws.md index 4dc10ce1f..dc9be8ad9 100644 --- a/docs/tutorials/aws.md +++ b/docs/tutorials/aws.md @@ -83,7 +83,7 @@ spec: spec: containers: - name: external-dns - image: registry.opensource.zalan.do/teapot/external-dns:v0.5.1 + image: registry.opensource.zalan.do/teapot/external-dns:latest args: - --source=service - --source=ingress @@ -117,6 +117,9 @@ rules: - apiGroups: ["extensions"] resources: ["ingresses"] verbs: ["get","watch","list"] +- apiGroups: [""] + resources: ["nodes"] + verbs: ["list"] --- apiVersion: rbac.authorization.k8s.io/v1beta1 kind: ClusterRoleBinding @@ -146,7 +149,7 @@ spec: serviceAccountName: external-dns containers: - name: external-dns - image: registry.opensource.zalan.do/teapot/external-dns:v0.5.1 + image: registry.opensource.zalan.do/teapot/external-dns:latest args: - --source=service - --source=ingress diff --git a/docs/tutorials/azure.md b/docs/tutorials/azure.md index ffd8362e9..0df336cf2 100644 --- a/docs/tutorials/azure.md +++ b/docs/tutorials/azure.md @@ -103,6 +103,16 @@ If the Kubernetes cluster is not hosted by Azure Container Services and you stil "resourceGroup": "MyDnsResourceGroup", } ``` +If [Azure Managed Service Identity (MSI)](https://docs.microsoft.com/en-us/azure/active-directory/managed-service-identity/overview) is enabled for virtual machines, then there is no need to create separate service principal. The contents of `azure.json` should be similar to this: +``` +{ + "tenantId": "AzureAD tenant Id", + "subscriptionId": "Id", + "resourceGroup": "MyDnsResourceGroup", + "useManagedIdentityExtension": true, +} +``` + If you have all the information necessary: create a file called azure.json containing the json structure above and substitute the values. Otherwise create a service principal as previously shown before creating the Kubernetes secret. Then add the secret to the Kubernetes cluster before continuing: @@ -143,7 +153,7 @@ spec: spec: containers: - name: external-dns - image: registry.opensource.zalan.do/teapot/external-dns:v0.5.1 + image: registry.opensource.zalan.do/teapot/external-dns:latest args: - --source=service - --source=ingress @@ -181,6 +191,9 @@ rules: - apiGroups: ["extensions"] resources: ["ingresses"] verbs: ["get","watch","list"] +- apiGroups: [""] + resources: ["nodes"] + verbs: ["list"] --- apiVersion: rbac.authorization.k8s.io/v1beta1 kind: ClusterRoleBinding @@ -210,7 +223,7 @@ spec: serviceAccountName: external-dns containers: - name: external-dns - image: registry.opensource.zalan.do/teapot/external-dns:v0.5.1 + image: registry.opensource.zalan.do/teapot/external-dns:latest args: - --source=service - --source=ingress @@ -261,7 +274,7 @@ metadata: spec: ports: - port: 80 - protocol: tcp + protocol: TCP targetPort: 80 selector: app: nginx diff --git a/docs/tutorials/cloudflare.md b/docs/tutorials/cloudflare.md index eea5baec6..308cb8186 100644 --- a/docs/tutorials/cloudflare.md +++ b/docs/tutorials/cloudflare.md @@ -42,7 +42,7 @@ spec: spec: containers: - name: external-dns - image: registry.opensource.zalan.do/teapot/external-dns:v0.5.1 + 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. @@ -77,6 +77,9 @@ rules: - apiGroups: ["extensions"] resources: ["ingresses"] verbs: ["get","watch","list"] +- apiGroups: [""] + resources: ["nodes"] + verbs: ["list"] --- apiVersion: rbac.authorization.k8s.io/v1beta1 kind: ClusterRoleBinding @@ -106,7 +109,7 @@ spec: serviceAccountName: external-dns containers: - name: external-dns - image: registry.opensource.zalan.do/teapot/external-dns:v0.5.1 + 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. @@ -179,7 +182,7 @@ the Cloudflare DNS records. ## Verifying Cloudflare DNS records -Check your [Cloudflare dasbhboard](https://www.cloudflare.com/a/dns/example.com) to view the records for your Cloudflare DNS zone. +Check your [Cloudflare dashboard](https://www.cloudflare.com/a/dns/example.com) to view the records for your Cloudflare DNS zone. Substitute the zone for the one created above if a different domain was used. diff --git a/docs/tutorials/digitalocean.md b/docs/tutorials/digitalocean.md index 9b2dbb000..9a313427b 100644 --- a/docs/tutorials/digitalocean.md +++ b/docs/tutorials/digitalocean.md @@ -39,7 +39,7 @@ spec: spec: containers: - name: external-dns - image: registry.opensource.zalan.do/teapot/external-dns:v0.5.1 + 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. @@ -70,6 +70,9 @@ rules: - apiGroups: ["extensions"] resources: ["ingresses"] verbs: ["get","watch","list"] +- apiGroups: [""] + resources: ["nodes"] + verbs: ["list"] --- apiVersion: rbac.authorization.k8s.io/v1beta1 kind: ClusterRoleBinding @@ -99,7 +102,7 @@ spec: serviceAccountName: external-dns containers: - name: external-dns - image: registry.opensource.zalan.do/teapot/external-dns:v0.5.1 + 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. diff --git a/docs/tutorials/dyn.md b/docs/tutorials/dyn.md index 0e2238af6..3af01f6c7 100644 --- a/docs/tutorials/dyn.md +++ b/docs/tutorials/dyn.md @@ -41,7 +41,7 @@ spec: spec: containers: - name: external-dns - image: registry.opensource.zalan.do/teapot/external-dns:v0.5.1 + image: registry.opensource.zalan.do/teapot/external-dns:latest args: - --source=ingress - --txt-prefix=_d @@ -142,4 +142,4 @@ Login to the console at https://portal.dynect.net/login/ and verify records are ## Clean up Login to the console at https://portal.dynect.net/login/ and delete the records created. Alternatively, just delete the sample -Ingress resources and external-dns will delete the records. \ No newline at end of file +Ingress resources and external-dns will delete the records. diff --git a/docs/tutorials/exoscale.md b/docs/tutorials/exoscale.md new file mode 100644 index 000000000..c05c7c639 --- /dev/null +++ b/docs/tutorials/exoscale.md @@ -0,0 +1,158 @@ +# Setting up ExternalDNS for Exoscale + +## Prerequisites + +Exoscale provider support was added via [this PR](https://github.com/kubernetes-incubator/external-dns/pull/625), thus you need to use external-dns v0.5.5. + +The Exoscale provider expects that your Exoscale zones, you wish to add records to, already exists +and are configured correctly. It does not add, remove or configure new zones in anyway. + +To do this pease refer to the [Exoscale DNS documentation](https://community.exoscale.com/documentation/dns/). + +Additionally you will have to provide the Exoscale...: + +* API Key +* API Secret +* API Endpoint +* Elastic IP address, to access the workers + +## Deployment + +Deploying external DNS for Exoscale is actually nearly identical to deploying +it for other providers. This is what a sample `deployment.yaml` looks like: + +```yaml +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: external-dns +spec: + strategy: + type: Recreate + template: + metadata: + labels: + app: external-dns + spec: + # Only use if you're also using RBAC + # serviceAccountName: external-dns + containers: + - name: external-dns + image: registry.opensource.zalan.do/teapot/external-dns:v0.5.5 + args: + - --source=ingress # or service or both + - --provider=exoscale + - --domain-filter={{ my-domain }} + - --policy=sync # if you want DNS entries to get deleted as well + - --txt-owner-id={{ owner-id-for-this-external-dns }} + - --exoscale-endpoint={{ endpoint }} # usually https://api.exoscale.ch/dns + - --exoscale-apikey={{ api-key}} + - --exoscale-apisecret={{ api-secret }} +``` + +## RBAC + +If your cluster is RBAC enabled, you also need to setup the following, before you can run external-dns: + +```yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: external-dns + namespace: default + +--- + +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 +``` + +## Testing and Verification + +**Important!**: Remember to change `example.com` with your own domain throughout the following text. + +Spin up a simple nginx HTTP server with the following spec (`kubectl apply -f`): + +```yaml +apiVersion: extensions/v1beta1 +kind: Ingress +metadata: + name: nginx + annotations: + kubernetes.io/ingress.class: nginx + external-dns.alpha.kubernetes.io/target: {{ Elastic-IP-address }} +spec: + rules: + - host: via-ingress.example.com + http: + paths: + - backend: + serviceName: nginx + servicePort: 80 + +--- + +apiVersion: v1 +kind: Service +metadata: + name: nginx +spec: + ports: + - port: 80 + targetPort: 80 + selector: + app: nginx + +--- + +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: nginx +spec: + template: + metadata: + labels: + app: nginx + spec: + containers: + - image: nginx + name: nginx + ports: + - containerPort: 80 +``` + +**Important!**: Don't run dig, nslookup or similar immediately (until you've +confirmed the record exists). You'll get hit by [negative DNS caching](https://tools.ietf.org/html/rfc2308), which is hard to flush. + +Wait about 30s-1m (interval for external-dns to kick in), then check Exoscales [portal](https://portal.exoscale.com/dns/example.com)... via-ingress.example.com should appear as a A and TXT record with your Elastic-IP-address. \ No newline at end of file diff --git a/docs/tutorials/gke.md b/docs/tutorials/gke.md index 644e9c031..9e73f13de 100644 --- a/docs/tutorials/gke.md +++ b/docs/tutorials/gke.md @@ -88,7 +88,7 @@ spec: spec: containers: - name: external-dns - image: registry.opensource.zalan.do/teapot/external-dns:v0.5.1 + image: registry.opensource.zalan.do/teapot/external-dns:latest args: - --source=service - --source=ingress @@ -121,6 +121,9 @@ rules: - apiGroups: ["extensions"] resources: ["ingresses"] verbs: ["get","watch","list"] +- apiGroups: [""] + resources: ["nodes"] + verbs: ["list"] --- apiVersion: rbac.authorization.k8s.io/v1beta1 kind: ClusterRoleBinding @@ -150,7 +153,7 @@ spec: serviceAccountName: external-dns containers: - name: external-dns - image: registry.opensource.zalan.do/teapot/external-dns:v0.5.1 + image: registry.opensource.zalan.do/teapot/external-dns:latest args: - --source=service - --source=ingress diff --git a/docs/tutorials/hostport.md b/docs/tutorials/hostport.md index e3df0ff0d..4f8741e4c 100644 --- a/docs/tutorials/hostport.md +++ b/docs/tutorials/hostport.md @@ -25,7 +25,7 @@ spec: spec: containers: - name: external-dns - image: registry.opensource.zalan.do/teapot/external-dns:v0.5.1 + image: registry.opensource.zalan.do/teapot/external-dns:latest args: - --debug - --source=service @@ -58,6 +58,9 @@ rules: - apiGroups: ["extensions"] resources: ["ingresses"] verbs: ["get","watch","list"] +- apiGroups: [""] + resources: ["nodes"] + verbs: ["list"] --- apiVersion: rbac.authorization.k8s.io/v1beta1 kind: ClusterRoleBinding @@ -84,7 +87,7 @@ spec: serviceAccountName: external-dns containers: - name: external-dns - image: registry.opensource.zalan.do/teapot/external-dns:v0.5.1 + image: registry.opensource.zalan.do/teapot/external-dns:latest args: - --debug - --source=service diff --git a/docs/tutorials/infoblox.md b/docs/tutorials/infoblox.md index 8ed0f7c0e..b61d2045e 100644 --- a/docs/tutorials/infoblox.md +++ b/docs/tutorials/infoblox.md @@ -66,7 +66,7 @@ spec: spec: containers: - name: external-dns - image: registry.opensource.zalan.do/teapot/external-dns:v0.5.1 + image: registry.opensource.zalan.do/teapot/external-dns:latest args: - --source=service - --domain-filter=example.com # (optional) limit to only example.com domains. @@ -114,6 +114,9 @@ rules: - apiGroups: ["extensions"] resources: ["ingresses"] verbs: ["get","watch","list"] +- apiGroups: [""] + resources: ["nodes"] + verbs: ["list"] --- apiVersion: rbac.authorization.k8s.io/v1beta1 kind: ClusterRoleBinding @@ -143,7 +146,7 @@ spec: serviceAccountName: external-dns containers: - name: external-dns - image: registry.opensource.zalan.do/teapot/external-dns:v0.5.1 + image: registry.opensource.zalan.do/teapot/external-dns:latest args: - --source=service - --domain-filter=example.com # (optional) limit to only example.com domains. diff --git a/docs/tutorials/nginx-ingress.md b/docs/tutorials/nginx-ingress.md index a3a192b36..3c495be64 100644 --- a/docs/tutorials/nginx-ingress.md +++ b/docs/tutorials/nginx-ingress.md @@ -222,6 +222,9 @@ rules: - apiGroups: ["extensions"] resources: ["ingresses"] verbs: ["get","watch","list"] +- apiGroups: [""] + resources: ["nodes"] + verbs: ["list"] --- apiVersion: rbac.authorization.k8s.io/v1beta1 kind: ClusterRoleBinding @@ -251,7 +254,7 @@ spec: serviceAccountName: external-dns containers: - name: external-dns - image: registry.opensource.zalan.do/teapot/external-dns:v0.5.1 + image: registry.opensource.zalan.do/teapot/external-dns:latest args: - --source=ingress - --domain-filter=external-dns-test.gcp.zalan.do diff --git a/docs/tutorials/oracle.md b/docs/tutorials/oracle.md new file mode 100644 index 000000000..adfab29c2 --- /dev/null +++ b/docs/tutorials/oracle.md @@ -0,0 +1,155 @@ +# Setting up ExternalDNS for Oracle Cloud Infrastructure (OCI) + +This tutorial describes how to setup ExternalDNS for usage within a Kubernetes cluster using OCI DNS. + +Make sure to use the latest version of ExternalDNS for this tutorial. + +## Creating an OCI DNS Zone + +Create a DNS zone which will contain the managed DNS records. Let's use `example.com` as an reference here. + +For more information about OCI DNS see the documentation [here][1]. + +## Deploy ExternalDNS + +Connect your `kubectl` client to the cluster you want to test ExternalDNS with. +We first need to create a config file containing the information needed to connect with the OCI API. + +Create a new file (oci.yaml) and modify the contents to match the example below. Be sure to adjust the values to match your own credentials: + +```yaml +auth: + region: us-phoenix-1 + tenancy: ocid1.tenancy.oc1... + user: ocid1.user.oc1... + -----BEGIN RSA PRIVATE KEY----- + -----END RSA PRIVATE KEY----- + fingerprint: af:81:71:8e... +compartment: ocid1.compartment.oc1... +``` + +Create a secret using the config file above: + +```shell +$ kubectl create secret generic external-dns-config --from-file=oci.yaml +``` + +### Manifest (for clusters with RBAC enabled) + +Apply the following manifest to deploy ExternalDNS. + +```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 + - --source=ingress + - --provider=oci + - --policy=upsert-only # prevent ExternalDNSfrom deleting any records, omit to enable full synchronization + - --txt-owner-id=my-identifier + volumeMounts: + - name: config + mountPath: /etc/kubernetes/ + volumes: + - name: config + secret: + secretName: external-dns-config +``` + +## Verify ExternalDNS works (Service example) + +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. + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: nginx + annotations: + external-dns.alpha.kubernetes.io/hostname: example.com +spec: + type: LoadBalancer + ports: + - port: 80 + name: http + targetPort: 80 + selector: + app: nginx +--- +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: nginx +spec: + template: + metadata: + labels: + app: nginx + spec: + containers: + - image: nginx + name: nginx + ports: + - containerPort: 80 + name: http +``` + +Apply the manifest above and wait roughly two minutes and check that a corresponding DNS record for your service was created. + +``` +$ kubectl apply -f nginx.yaml +``` + +[1]: https://docs.cloud.oracle.com/iaas/Content/DNS/Concepts/dnszonemanagement.htm diff --git a/docs/tutorials/pdns.md b/docs/tutorials/pdns.md index f6c76092c..33b43f3bb 100644 --- a/docs/tutorials/pdns.md +++ b/docs/tutorials/pdns.md @@ -40,7 +40,7 @@ spec: # serviceAccountName: external-dns containers: - name: external-dns - image: registry.opensource.zalan.do/teapot/external-dns:v0.5.1 + image: registry.opensource.zalan.do/teapot/external-dns:latest args: - --source=service # or ingress or both - --provider=pdns @@ -74,6 +74,9 @@ rules: - apiGroups: [""] resources: ["pods"] verbs: ["get","watch","list"] +- apiGroups: [""] + resources: ["nodes"] + verbs: ["list"] --- apiVersion: rbac.authorization.k8s.io/v1beta1 kind: ClusterRoleBinding diff --git a/docs/tutorials/public-private-route53.md b/docs/tutorials/public-private-route53.md index 8089811cb..6bf1ec42f 100644 --- a/docs/tutorials/public-private-route53.md +++ b/docs/tutorials/public-private-route53.md @@ -243,7 +243,7 @@ spec: - --txt-owner-id=external-dns - --annotation-filter=kubernetes.io/ingress.class=external-ingress - --aws-zone-type=public - image: registry.opensource.zalan.do/teapot/external-dns:v0.5.1 + image: registry.opensource.zalan.do/teapot/external-dns:latest name: external-dns-public ``` @@ -281,7 +281,7 @@ spec: - --txt-owner-id=dev.k8s.nexus - --annotation-filter=kubernetes.io/ingress.class=internal-ingress - --aws-zone-type=private - image: registry.opensource.zalan.do/teapot/external-dns:v0.5.1 + image: registry.opensource.zalan.do/teapot/external-dns:latest name: external-dns-private ``` diff --git a/endpoint/endpoint.go b/endpoint/endpoint.go index 0350c63bb..d7af428cc 100644 --- a/endpoint/endpoint.go +++ b/endpoint/endpoint.go @@ -29,6 +29,8 @@ const ( RecordTypeCNAME = "CNAME" // RecordTypeTXT is a RecordType enum value RecordTypeTXT = "TXT" + // RecordTypeSRV is a RecordType enum value + RecordTypeSRV = "SRV" ) // TTL is a structure defining the TTL of a DNS record diff --git a/internal/testresources/ca.pem b/internal/testresources/ca.pem new file mode 100644 index 000000000..6a0e84115 --- /dev/null +++ b/internal/testresources/ca.pem @@ -0,0 +1,23 @@ +-----BEGIN CERTIFICATE----- +MIIDwDCCAqigAwIBAgIUYsFawvERY3xGTHmKWq/78Cp70AIwDQYJKoZIhvcNAQEL +BQAweDEQMA4GA1UEBhMHQ291bnRyeTERMA8GA1UEBxMITG9jYWxpdHkxFTATBgNV +BAoTDE9yZ2FuaXphdGlvbjEbMBkGA1UECxMST3JnYW5pemF0aW9uYWxVbml0MR0w +GwYDVQQDExRleHRlcm5hbC1kbnMgdGVzdCBDQTAeFw0xODA2MTQyMTE5MDBaFw0y +MzA2MTMyMTE5MDBaMHgxEDAOBgNVBAYTB0NvdW50cnkxETAPBgNVBAcTCExvY2Fs +aXR5MRUwEwYDVQQKEwxPcmdhbml6YXRpb24xGzAZBgNVBAsTEk9yZ2FuaXphdGlv +bmFsVW5pdDEdMBsGA1UEAxMUZXh0ZXJuYWwtZG5zIHRlc3QgQ0EwggEiMA0GCSqG +SIb3DQEBAQUAA4IBDwAwggEKAoIBAQDMEaMix69YrVXPtfUsOgz5RJqaAlitw+HV +BaYv7BbcChiidYQ+/iKo26HA5vjBP5xOMNYTnVowXA+q+RmMGfhSc+j9CiJeADP0 +oxjSNq/w5Xb/IFIHSjr+dEdavcdsV95y3BYB8PkopjXEmNgbEgPbHNuJMQkd89rC +2ztSIPHbjhorrauAGm8cgzdKK6Tnxhey9yQralIgdrOHXMTOZrWywTPiUtIuxrn5 +XfIaylfqQO+Q79rEGhk9YQuFUqs+GDDp/PiCGC56/IbF7NVLEdrJIc6Tf9Rg2/K/ +9ydeZ5hcaM542Q2UoXIbRp8jn/J1Xr3mcxhpnhJN4TcjLalnSY6tAgMBAAGjQjBA +MA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTym+5y +SEghmx5Xr+wd5Z0V+9AWxDANBgkqhkiG9w0BAQsFAAOCAQEAlGaKZY40apdmROAx +usSLJjgVIw5GjX7lNw6BqorbQavexPjghfhB1TSpFOvHCz7s164Eq18+wfbzfnR5 +L+Xza+eibvkgO8ZojGMFXR+5NCbM2cPBTmWZVNVRoZQX6j5RT/DeLcjEKDBWZBdP +IxAQCFprxHgBZeHOfzvombzdbDM5To9ff+3gaunMbWs7YAmpv1czRc0F0arXg+mA +AzG4fc94lJ5oMF6sClZ1rbJjLcohtINx6HstUzLxlAcgJMJcvvJrrdTkzJSCOmE6 +a52RJX6qVaZNHCXDooiy2uDapXyA4sPCt4n3KhRfP+JGp6Xmg10rm7ga8+ZUCYae +UyE4mA== +-----END CERTIFICATE----- diff --git a/internal/testresources/client-cert-key.pem b/internal/testresources/client-cert-key.pem new file mode 100644 index 000000000..9d4f04355 --- /dev/null +++ b/internal/testresources/client-cert-key.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAykDCIA6Q0p8HJLx3njenAqvKvHGwJzjLP+tw4iq8J3//aEon +e23t9CwGW9YVNTM4Xu6n0+exUsUK4LweHRTYLmYkTHUc+pB9+XpQ+wKutmxv+zhk +uWi+4u6pqC6e8TSuURs3c9ML2TxCTIRubd3hPRGMizo3POuQ8iaRo8icfdQcv/XE +bieB8ma5ZUkRxkY+Mu+MYOsleoIpSByej18ycJkVkipV6CeSxenCsQaeAaJPTOVO +9rYZL3F3fNHXezIpCmw3sgCiudQ1MvPKGs4G5UahCO0OSp42IMUodYxvQ0G1o5uZ +9k9R8Xcumso61xHM/ZCkzqJ89P/2zXyUC7c2OQIDAQABAoIBABoHL3RUq4qPcKnn +nzU7UEDlvtd1ggfqJS36rLJOcZxbupC/Skl2IjNUHxVefag1CUIeUHbS0F0ognfd +fbqcXh3Kg01bnPkZ8zxR424KMFXFqruXzE6YDkjCEg9UwJul/fDuIbrEJDg2qwmR +2WxGK6BiS8X3Hfi3EBY5pHCBdrIyiVWZn8CYYmiBhJehdNHEwtIk+Bo8mWBTOOpS +x9e1czYICM39zyZQtuLvI/CIcSg1uyRL19r7KrAoBS2o6ijDrp5KqCyAVL9UYq5m +B3k/KltYi6d3HtcHwuMHPKNpWfOQCKu7MDX+ZNi7E6LrPxWqrPoiZ5xzAQPQHD3B +e3fmt6ECgYEA5z5z3kxjF6EYttzDMylUghTfPs/vyhecFKzbcipdMJNs8o9KnHF9 +WgH7JaPU8cYe8CluQWZn6ByxdaB7G56wfHrYYCm5pbsuxlkoqqLMmAmVBPZVMhEy +thoxi4PxAdcs5HsqwYbazpToYZ6ktvknIUKOp24oQgYUG5T0mkNm910CgYEA3+fE +4Mh2rikegQLYar9gsFAXpBjxiMRQlUH23Qk9p21AkczTjGpYeV4v2LxxTYKiaZRJ +8X4Ab745j+yLbsYMxZKihNCQSLTK44FSgK6fSEs+yHLpQjT4V5IyvD+tHMan9n/s +YqDppse1GHGGxF7N4FatrQk5Kz8FG9EYa6BNWI0CgYBYXALGRIXwt3vME9r6p6ZE +9li/lZDYteDL/aj0nL9zGkIdBSfLU4pEZFFk9o8du0iDGDGrB8hYZu5gNewUh7SE +PCSFyivH6hhbbiId4Ysv5Slt9fpj4TJxZtzbpJTo0SG0RGP4AuGE4l1RP99MkzOi +f94ml+8GG3B/gZFdiLfFeQKBgQC9pDxoduGuWT1w38wVfcqTCwM7BbVttXjbMme4 +hx8lM6/Azc9P2rLc+R1lYRZGJCMTcXm/hI0yF9gBQsRGKpCetrfX7h6Gtjoxv1L1 +kvFt9e1TMaDHZr4Azd8S+ovRF6Ejcu3wC4JatEN6VI1kvTd6j2/CY1F8g3/8M3mP +jtJz8QKBgCL2XDev1Vls2hzqrNqjNehYVAqTNdNNr2jzCR1g8AMFqSy7k/4gFtu/ +bXBnSKnGrtmb+VWKDMwNy7oe6g6haFLTjPbnl8/afKBbH0WQzlvVJgKWSX1faWTG +1WMRAqD8nIdcYbfj7AhmNGL6zYGr0g+YP9CF+j6je2Rb0so+S5cZ +-----END RSA PRIVATE KEY----- diff --git a/internal/testresources/client-cert.pem b/internal/testresources/client-cert.pem new file mode 100644 index 000000000..f38e9e868 --- /dev/null +++ b/internal/testresources/client-cert.pem @@ -0,0 +1,24 @@ +-----BEGIN CERTIFICATE----- +MIIEEzCCAvugAwIBAgIUJxJZIg35oCO6747gR3c9ZkQc8TMwDQYJKoZIhvcNAQEL +BQAweDEQMA4GA1UEBhMHQ291bnRyeTERMA8GA1UEBxMITG9jYWxpdHkxFTATBgNV +BAoTDE9yZ2FuaXphdGlvbjEbMBkGA1UECxMST3JnYW5pemF0aW9uYWxVbml0MR0w +GwYDVQQDExRleHRlcm5hbC1kbnMgdGVzdCBDQTAeFw0xODA2MTQyMTIzMDBaFw0y +MzA2MTMyMTIzMDBaMIGIMRAwDgYDVQQGEwdDb3VudHJ5MREwDwYDVQQHEwhMb2Nh +bGl0eTEVMBMGA1UEChMMT3JnYW5pemF0aW9uMRswGQYDVQQLExJPcmdhbml6YXRp +b25hbFVuaXQxLTArBgNVBAMTJGV4dGVybmFsLWRucyB0ZXN0IGNsaWVudCBjZXJ0 +aWZpY2F0ZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMpAwiAOkNKf +ByS8d543pwKryrxxsCc4yz/rcOIqvCd//2hKJ3tt7fQsBlvWFTUzOF7up9PnsVLF +CuC8Hh0U2C5mJEx1HPqQffl6UPsCrrZsb/s4ZLlovuLuqagunvE0rlEbN3PTC9k8 +QkyEbm3d4T0RjIs6NzzrkPImkaPInH3UHL/1xG4ngfJmuWVJEcZGPjLvjGDrJXqC +KUgcno9fMnCZFZIqVegnksXpwrEGngGiT0zlTva2GS9xd3zR13syKQpsN7IAornU +NTLzyhrOBuVGoQjtDkqeNiDFKHWMb0NBtaObmfZPUfF3LprKOtcRzP2QpM6ifPT/ +9s18lAu3NjkCAwEAAaOBgzCBgDAOBgNVHQ8BAf8EBAMCBaAwEwYDVR0lBAwwCgYI +KwYBBQUHAwIwDAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQUA+Do/9mVdkyYJAPYUzbr +9FBi8gMwHwYDVR0jBBgwFoAU8pvuckhIIZseV6/sHeWdFfvQFsQwCwYDVR0RBAQw +AoIAMA0GCSqGSIb3DQEBCwUAA4IBAQBFzN/cqkVjGYaQzCpOWVgizIwBhGFRfYGY +Cw5m9HaZIMjbSxt55NhRTFm6Q5qFfD2KXXPueEUL4U5iPg+LPHrUfNmKiJtUcKc8 +M2FJimb7nRsw46F+tRt0lgM5sDeqe2QUlNTKFKaxnHUDqWt4mW7adzog3sX7UfGB +yvbJR9Y6cEMlG2it3rl9/ZiAJnTJSvCqBV9QlBAkCCh0JgJEtPLubz97BVGkMORh ++ZgHCw/A9sew/7Krpbyp/NtHeFVsa8tH8wivnaGeITGD4J23U9E3YYUaNPN7kBcX +DuFCSEKHGsbAvH2Igxkk+rD5T8d6RwJ98jkXOYXCxGmGBuDEkyGZ +-----END CERTIFICATE----- diff --git a/main.go b/main.go index d539ac247..76c913b1f 100644 --- a/main.go +++ b/main.go @@ -95,7 +95,16 @@ func main() { var p provider.Provider switch cfg.Provider { case "aws": - p, err = provider.NewAWSProvider(domainFilter, zoneIDFilter, zoneTypeFilter, cfg.AWSAssumeRole, cfg.DryRun) + p, err = provider.NewAWSProvider( + provider.AWSConfig{ + DomainFilter: domainFilter, + ZoneIDFilter: zoneIDFilter, + ZoneTypeFilter: zoneTypeFilter, + MaxChangeCount: cfg.AWSMaxChangeCount, + AssumeRole: cfg.AWSAssumeRole, + DryRun: cfg.DryRun, + }, + ) case "aws-sd": // Check that only compatible Registry is used with AWS-SD if cfg.Registry != "noop" && cfg.Registry != "aws-sd" { @@ -140,12 +149,35 @@ func main() { AppVersion: externaldns.Version, }, ) + case "coredns", "skydns": + p, err = provider.NewCoreDNSProvider(domainFilter, cfg.DryRun) + case "exoscale": + p, err = provider.NewExoscaleProvider(cfg.ExoscaleEndpoint, cfg.ExoscaleAPIKey, cfg.ExoscaleAPISecret, cfg.DryRun, provider.ExoscaleWithDomain(domainFilter), provider.ExoscaleWithLogging()), nil case "inmemory": p, err = provider.NewInMemoryProvider(provider.InMemoryInitZones(cfg.InMemoryZones), provider.InMemoryWithDomain(domainFilter), provider.InMemoryWithLogging()), nil case "designate": p, err = provider.NewDesignateProvider(domainFilter, cfg.DryRun) case "pdns": - p, err = provider.NewPDNSProvider(cfg.PDNSServer, cfg.PDNSAPIKey, domainFilter, cfg.DryRun) + p, err = provider.NewPDNSProvider( + provider.PDNSConfig{ + DomainFilter: domainFilter, + DryRun: cfg.DryRun, + Server: cfg.PDNSServer, + APIKey: cfg.PDNSAPIKey, + TLSConfig: provider.TLSConfig{ + TLSEnabled: cfg.PDNSTLSEnabled, + CAFilePath: cfg.TLSCA, + ClientCertFilePath: cfg.TLSClientCert, + ClientCertKeyFilePath: cfg.TLSClientCertKey, + }, + }, + ) + case "oci": + var config *provider.OCIConfig + config, err = provider.LoadOCIConfig(cfg.OCIConfigFile) + if err == nil { + p, err = provider.NewOCIProvider(*config, domainFilter, zoneIDFilter, cfg.DryRun) + } default: log.Fatalf("unknown dns provider: %s", cfg.Provider) } @@ -158,7 +190,7 @@ func main() { case "noop": r, err = registry.NewNoopRegistry(p) case "txt": - r, err = registry.NewTXTRegistry(p, cfg.TXTPrefix, cfg.TXTOwnerID) + r, err = registry.NewTXTRegistry(p, cfg.TXTPrefix, cfg.TXTOwnerID, cfg.TXTCacheInterval) case "aws-sd": r, err = registry.NewAWSSDRegistry(p.(*provider.AWSSDProvider), cfg.TXTOwnerID) default: diff --git a/pkg/apis/externaldns/types.go b/pkg/apis/externaldns/types.go index 2948bc0f3..dc8cdd798 100644 --- a/pkg/apis/externaldns/types.go +++ b/pkg/apis/externaldns/types.go @@ -52,6 +52,8 @@ type Config struct { ZoneIDFilter []string AWSZoneType string AWSAssumeRole string + AWSMaxChangeCount int + AWSEvaluateTargetHealth bool AzureConfigFile string AzureResourceGroup string CloudflareProxied bool @@ -65,9 +67,14 @@ type Config struct { 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 @@ -78,6 +85,10 @@ type Config struct { LogFormat string MetricsAddress string LogLevel string + TXTCacheInterval time.Duration + ExoscaleEndpoint string + ExoscaleAPIKey string + ExoscaleAPISecret string } var defaultConfig = &Config{ @@ -96,6 +107,8 @@ var defaultConfig = &Config{ DomainFilter: []string{}, AWSZoneType: "", AWSAssumeRole: "", + AWSMaxChangeCount: 4000, + AWSEvaluateTargetHealth: true, AzureConfigFile: "/etc/kubernetes/azure.json", AzureResourceGroup: "", CloudflareProxied: false, @@ -105,19 +118,28 @@ var defaultConfig = &Config{ 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: "", } // NewConfig returns new Config object @@ -134,6 +156,9 @@ func (cfg *Config) String() string { if temp.InfobloxWapiPassword != "" { temp.InfobloxWapiPassword = passwordMask } + if temp.PDNSAPIKey != "" { + temp.PDNSAPIKey = "" + } return fmt.Sprintf("%+v", temp) } @@ -168,12 +193,14 @@ func (cfg *Config) ParseFlags(args []string) error { app.Flag("connector-source-server", "The server to connect for connector source, valid only when using connector source").Default(defaultConfig.ConnectorSourceServer).StringVar(&cfg.ConnectorSourceServer) // 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, inmemory, pdns)").Required().PlaceHolder("provider").EnumVar(&cfg.Provider, "aws", "aws-sd", "google", "azure", "cloudflare", "digitalocean", "dnsimple", "infoblox", "dyn", "designate", "inmemory", "pdns") + 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)").Required().PlaceHolder("provider").EnumVar(&cfg.Provider, "aws", "aws-sd", "google", "azure", "cloudflare", "digitalocean", "dnsimple", "infoblox", "dyn", "designate", "coredns", "skydns", "inmemory", "pdns", "oci", "exoscale") 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("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-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-max-change-count", "When using the AWS provider, set the maximum number of changes that will be applied.").Default(strconv.Itoa(defaultConfig.AWSMaxChangeCount)).IntVar(&cfg.AWSMaxChangeCount) + 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("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) @@ -187,10 +214,21 @@ func (cfg *Config) ParseFlags(args []string) error { 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("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 URL to the pdns server (required when --provider=pdns)").Default(defaultConfig.PDNSAPIKey).StringVar(&cfg.PDNSAPIKey) + 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) + + // 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) + app.Flag("tls-client-cert", "When using TLS communication, the path to the certificate to present as a client (not required for TLS)").Default(defaultConfig.TLSClientCert).StringVar(&cfg.TLSClientCert) + app.Flag("tls-client-cert-key", "When using TLS communication, the path to the certificate key to use with the client certificate (not required for TLS)").Default(defaultConfig.TLSClientCertKey).StringVar(&cfg.TLSClientCertKey) + + app.Flag("exoscale-endpoint", "Provide the endpoint for the Exoscale provider").Default(defaultConfig.ExoscaleEndpoint).StringVar(&cfg.ExoscaleEndpoint) + app.Flag("exoscale-apikey", "Provide your API Key for the Exoscale provider").Default(defaultConfig.ExoscaleAPIKey).StringVar(&cfg.ExoscaleAPIKey) + app.Flag("exoscale-apisecret", "Provide your API Secret for the Exoscale provider").Default(defaultConfig.ExoscaleAPISecret).StringVar(&cfg.ExoscaleAPISecret) // Flags related to policies app.Flag("policy", "Modify how DNS records are sychronized between sources and providers (default: sync, options: sync, upsert-only)").Default(defaultConfig.Policy).EnumVar(&cfg.Policy, "sync", "upsert-only") @@ -201,6 +239,7 @@ func (cfg *Config) ParseFlags(args []string) error { app.Flag("txt-prefix", "When using the TXT registry, a custom string that's prefixed to each ownership DNS record (optional)").Default(defaultConfig.TXTPrefix).StringVar(&cfg.TXTPrefix) // Flags related to the main control loop + app.Flag("txt-cache-interval", "The interval between cache synchronizations in duration format (default: disabled)").Default(defaultConfig.TXTCacheInterval.String()).DurationVar(&cfg.TXTCacheInterval) app.Flag("interval", "The interval between two consecutive synchronizations in duration format (default: 1m)").Default(defaultConfig.Interval.String()).DurationVar(&cfg.Interval) app.Flag("once", "When enabled, exits the synchronization loop after the first iteration (default: disabled)").BoolVar(&cfg.Once) app.Flag("dry-run", "When enabled, prints DNS record changes rather than actually performing them (default: disabled)").BoolVar(&cfg.DryRun) diff --git a/pkg/apis/externaldns/types_test.go b/pkg/apis/externaldns/types_test.go index 467219137..1edacff18 100644 --- a/pkg/apis/externaldns/types_test.go +++ b/pkg/apis/externaldns/types_test.go @@ -29,79 +29,97 @@ import ( var ( minimalConfig = &Config{ - Master: "", - KubeConfig: "", - Sources: []string{"service"}, - Namespace: "", - FQDNTemplate: "", - Compatibility: "", - Provider: "google", - GoogleProject: "", - DomainFilter: []string{""}, - ZoneIDFilter: []string{""}, - AWSZoneType: "", - AWSAssumeRole: "", - AzureConfigFile: "/etc/kubernetes/azure.json", - AzureResourceGroup: "", - CloudflareProxied: false, - InfobloxGridHost: "", - InfobloxWapiPort: 443, - InfobloxWapiUsername: "admin", - InfobloxWapiPassword: "", - InfobloxWapiVersion: "2.3.1", - InfobloxSSLVerify: true, - InMemoryZones: []string{""}, - PDNSServer: "http://localhost:8081", - PDNSAPIKey: "", - Policy: "sync", - Registry: "txt", - TXTOwnerID: "default", - TXTPrefix: "", - Interval: time.Minute, - Once: false, - DryRun: false, - LogFormat: "text", - MetricsAddress: ":7979", - LogLevel: logrus.InfoLevel.String(), - ConnectorSourceServer: "localhost:8080", + Master: "", + KubeConfig: "", + Sources: []string{"service"}, + Namespace: "", + FQDNTemplate: "", + Compatibility: "", + Provider: "google", + GoogleProject: "", + DomainFilter: []string{""}, + ZoneIDFilter: []string{""}, + AWSZoneType: "", + AWSAssumeRole: "", + AWSMaxChangeCount: 4000, + 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: "", } overriddenConfig = &Config{ - Master: "http://127.0.0.1:8080", - KubeConfig: "/some/path", - 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"}, - AWSZoneType: "private", - AWSAssumeRole: "some-other-role", - AzureConfigFile: "azure.json", - AzureResourceGroup: "arg", - CloudflareProxied: true, - InfobloxGridHost: "127.0.0.1", - InfobloxWapiPort: 8443, - InfobloxWapiUsername: "infoblox", - InfobloxWapiPassword: "infoblox", - InfobloxWapiVersion: "2.6.1", - InfobloxSSLVerify: false, - InMemoryZones: []string{"example.org", "company.com"}, - PDNSServer: "http://ns.example.com:8081", - PDNSAPIKey: "some-secret-key", - Policy: "upsert-only", - Registry: "noop", - TXTOwnerID: "owner-1", - TXTPrefix: "associated-txt-record", - Interval: 10 * time.Minute, - Once: true, - DryRun: true, - LogFormat: "json", - MetricsAddress: "127.0.0.1:9099", - LogLevel: logrus.DebugLevel.String(), - ConnectorSourceServer: "localhost:8081", + Master: "http://127.0.0.1:8080", + KubeConfig: "/some/path", + 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"}, + AWSZoneType: "private", + AWSAssumeRole: "some-other-role", + AWSMaxChangeCount: 100, + 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", } ) @@ -146,6 +164,11 @@ func TestParseFlags(t *testing.T) { "--inmemory-zone=company.com", "--pdns-server=http://ns.example.com:8081", "--pdns-api-key=some-secret-key", + "--pdns-tls-enabled", + "--oci-config-file=oci.yaml", + "--tls-ca=/path/to/ca.crt", + "--tls-client-cert=/path/to/cert.pem", + "--tls-client-cert-key=/path/to/key.pem", "--no-infoblox-ssl-verify", "--domain-filter=example.org", "--domain-filter=company.com", @@ -153,10 +176,13 @@ func TestParseFlags(t *testing.T) { "--zone-id-filter=/hostedzone/ZTST2", "--aws-zone-type=private", "--aws-assume-role=some-other-role", + "--aws-max-change-count=100", + "--no-aws-evaluate-target-health", "--policy=upsert-only", "--registry=noop", "--txt-owner-id=owner-1", "--txt-prefix=associated-txt-record", + "--txt-cache-interval=12h", "--interval=10m", "--once", "--dry-run", @@ -164,6 +190,9 @@ func TestParseFlags(t *testing.T) { "--metrics-address=127.0.0.1:9099", "--log-level=debug", "--connector-source-server=localhost:8081", + "--exoscale-endpoint=https://api.foo.ch/dns", + "--exoscale-apikey=1", + "--exoscale-apisecret=2", }, envVars: map[string]string{}, expected: overriddenConfig, @@ -172,41 +201,52 @@ func TestParseFlags(t *testing.T) { title: "override everything via environment variables", args: []string{}, envVars: map[string]string{ - "EXTERNAL_DNS_MASTER": "http://127.0.0.1:8080", - "EXTERNAL_DNS_KUBECONFIG": "/some/path", - "EXTERNAL_DNS_SOURCE": "service\ningress\nconnector", - "EXTERNAL_DNS_NAMESPACE": "namespace", - "EXTERNAL_DNS_FQDN_TEMPLATE": "{{.Name}}.service.example.com", - "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_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_SSL_VERIFY": "0", - "EXTERNAL_DNS_INMEMORY_ZONE": "example.org\ncompany.com", - "EXTERNAL_DNS_DOMAIN_FILTER": "example.org\ncompany.com", - "EXTERNAL_DNS_PDNS_SERVER": "http://ns.example.com:8081", - "EXTERNAL_DNS_PDNS_API_KEY": "some-secret-key", - "EXTERNAL_DNS_ZONE_ID_FILTER": "/hostedzone/ZTST1\n/hostedzone/ZTST2", - "EXTERNAL_DNS_AWS_ZONE_TYPE": "private", - "EXTERNAL_DNS_AWS_ASSUME_ROLE": "some-other-role", - "EXTERNAL_DNS_POLICY": "upsert-only", - "EXTERNAL_DNS_REGISTRY": "noop", - "EXTERNAL_DNS_TXT_OWNER_ID": "owner-1", - "EXTERNAL_DNS_TXT_PREFIX": "associated-txt-record", - "EXTERNAL_DNS_INTERVAL": "10m", - "EXTERNAL_DNS_ONCE": "1", - "EXTERNAL_DNS_DRY_RUN": "1", - "EXTERNAL_DNS_LOG_FORMAT": "json", - "EXTERNAL_DNS_METRICS_ADDRESS": "127.0.0.1:9099", - "EXTERNAL_DNS_LOG_LEVEL": "debug", - "EXTERNAL_DNS_CONNECTOR_SOURCE_SERVER": "localhost:8081", + "EXTERNAL_DNS_MASTER": "http://127.0.0.1:8080", + "EXTERNAL_DNS_KUBECONFIG": "/some/path", + "EXTERNAL_DNS_SOURCE": "service\ningress\nconnector", + "EXTERNAL_DNS_NAMESPACE": "namespace", + "EXTERNAL_DNS_FQDN_TEMPLATE": "{{.Name}}.service.example.com", + "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_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_SSL_VERIFY": "0", + "EXTERNAL_DNS_OCI_CONFIG_FILE": "oci.yaml", + "EXTERNAL_DNS_INMEMORY_ZONE": "example.org\ncompany.com", + "EXTERNAL_DNS_DOMAIN_FILTER": "example.org\ncompany.com", + "EXTERNAL_DNS_PDNS_SERVER": "http://ns.example.com:8081", + "EXTERNAL_DNS_PDNS_API_KEY": "some-secret-key", + "EXTERNAL_DNS_PDNS_TLS_ENABLED": "1", + "EXTERNAL_DNS_TLS_CA": "/path/to/ca.crt", + "EXTERNAL_DNS_TLS_CLIENT_CERT": "/path/to/cert.pem", + "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_ASSUME_ROLE": "some-other-role", + "EXTERNAL_DNS_AWS_MAX_CHANGE_COUNT": "100", + "EXTERNAL_DNS_AWS_EVALUATE_TARGET_HEALTH": "0", + "EXTERNAL_DNS_POLICY": "upsert-only", + "EXTERNAL_DNS_REGISTRY": "noop", + "EXTERNAL_DNS_TXT_OWNER_ID": "owner-1", + "EXTERNAL_DNS_TXT_PREFIX": "associated-txt-record", + "EXTERNAL_DNS_TXT_CACHE_INTERVAL": "12h", + "EXTERNAL_DNS_INTERVAL": "10m", + "EXTERNAL_DNS_ONCE": "1", + "EXTERNAL_DNS_DRY_RUN": "1", + "EXTERNAL_DNS_LOG_FORMAT": "json", + "EXTERNAL_DNS_METRICS_ADDRESS": "127.0.0.1:9099", + "EXTERNAL_DNS_LOG_LEVEL": "debug", + "EXTERNAL_DNS_CONNECTOR_SOURCE_SERVER": "localhost:8081", + "EXTERNAL_DNS_EXOSCALE_ENDPOINT": "https://api.foo.ch/dns", + "EXTERNAL_DNS_EXOSCALE_APIKEY": "1", + "EXTERNAL_DNS_EXOSCALE_APISECRET": "2", }, expected: overriddenConfig, }, @@ -245,10 +285,12 @@ func TestPasswordsNotLogged(t *testing.T) { cfg := Config{ DynPassword: "dyn-pass", InfobloxWapiPassword: "infoblox-pass", + PDNSAPIKey: "pdns-api-key", } s := cfg.String() 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")) } diff --git a/pkg/tlsutils/tlsconfig.go b/pkg/tlsutils/tlsconfig.go index d2e0fb741..106a21af7 100644 --- a/pkg/tlsutils/tlsconfig.go +++ b/pkg/tlsutils/tlsconfig.go @@ -26,6 +26,8 @@ import ( "strings" ) +const defaultMinVersion = 0 + // CreateTLSConfig creates tls.Config instance from TLS parameters passed in environment variables with the given prefix func CreateTLSConfig(prefix string) (*tls.Config, error) { caFile := os.Getenv(fmt.Sprintf("%s_CA_FILE", prefix)) @@ -34,14 +36,15 @@ func CreateTLSConfig(prefix string) (*tls.Config, error) { serverName := os.Getenv(fmt.Sprintf("%s_TLS_SERVER_NAME", prefix)) isInsecureStr := strings.ToLower(os.Getenv(fmt.Sprintf("%s_TLS_INSECURE", prefix))) isInsecure := isInsecureStr == "true" || isInsecureStr == "yes" || isInsecureStr == "1" - tlsConfig, err := newTLSConfig(certFile, keyFile, caFile, serverName, isInsecure) + tlsConfig, err := NewTLSConfig(certFile, keyFile, caFile, serverName, isInsecure, defaultMinVersion) if err != nil { return nil, err } return tlsConfig, nil } -func newTLSConfig(certPath, keyPath, caPath, serverName string, insecure bool) (*tls.Config, error) { +// NewTLSConfig creates a tls.Config instance from directly-passed parameters, loading the ca, cert, and key from disk +func NewTLSConfig(certPath, keyPath, caPath, serverName string, insecure bool, minVersion uint16) (*tls.Config, error) { if certPath != "" && keyPath == "" || certPath == "" && keyPath != "" { return nil, errors.New("either both cert and key or none must be provided") } @@ -59,6 +62,7 @@ func newTLSConfig(certPath, keyPath, caPath, serverName string, insecure bool) ( } return &tls.Config{ + MinVersion: minVersion, Certificates: certificates, RootCAs: roots, InsecureSkipVerify: insecure, diff --git a/plan/plan.go b/plan/plan.go index 6caa73513..21bf5b677 100644 --- a/plan/plan.go +++ b/plan/plan.go @@ -17,6 +17,8 @@ limitations under the License. package plan import ( + "strings" + "github.com/kubernetes-incubator/external-dns/endpoint" ) @@ -77,17 +79,19 @@ type planTableRow struct { } func (t planTable) addCurrent(e *endpoint.Endpoint) { - if _, ok := t.rows[e.DNSName]; !ok { - t.rows[e.DNSName] = &planTableRow{} + dnsName := sanitizeDNSName(e.DNSName) + if _, ok := t.rows[dnsName]; !ok { + t.rows[dnsName] = &planTableRow{} } - t.rows[e.DNSName].current = e + t.rows[dnsName].current = e } func (t planTable) addCandidate(e *endpoint.Endpoint) { - if _, ok := t.rows[e.DNSName]; !ok { - t.rows[e.DNSName] = &planTableRow{} + dnsName := sanitizeDNSName(e.DNSName) + if _, ok := t.rows[dnsName]; !ok { + t.rows[dnsName] = &planTableRow{} } - t.rows[e.DNSName].candidates = append(t.rows[e.DNSName].candidates, e) + t.rows[dnsName].candidates = append(t.rows[dnsName].candidates, e) } // TODO: allows record type change, which might not be supported by all dns providers @@ -199,3 +203,9 @@ 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)) +} diff --git a/plan/plan_test.go b/plan/plan_test.go index 29c6eb289..e36b343a6 100644 --- a/plan/plan_test.go +++ b/plan/plan_test.go @@ -21,6 +21,7 @@ import ( "github.com/kubernetes-incubator/external-dns/endpoint" "github.com/kubernetes-incubator/external-dns/internal/testutils" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" ) @@ -383,3 +384,55 @@ func validateEntries(t *testing.T, entries, expected []*endpoint.Endpoint) { t.Fatalf("expected %q to match %q", entries, expected) } } + +func TestSanitizeDNSName(t *testing.T) { + records := []struct { + dnsName string + expect string + }{ + { + "3AAAA.FOO.BAR.COM ", + "3aaaa.foo.bar.com", + }, + { + " example.foo.com", + "example.foo.com", + }, + { + "example123.foo.com ", + "example123.foo.com", + }, + { + "foo", + "foo", + }, + { + "123foo.bar", + "123foo.bar", + }, + { + "foo.com", + "foo.com", + }, + { + "foo123.COM", + "foo123.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-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) + assert.Equal(t, r.expect, gotName) + } +} diff --git a/provider/aws.go b/provider/aws.go index 63376c44b..8be1ad192 100644 --- a/provider/aws.go +++ b/provider/aws.go @@ -31,9 +31,7 @@ import ( ) const ( - evaluateTargetHealth = true - recordTTL = 300 - maxChangeCount = 4000 + recordTTL = 300 ) var ( @@ -86,8 +84,10 @@ type Route53API interface { // AWSProvider is an implementation of Provider for AWS Route53. type AWSProvider struct { - client Route53API - dryRun bool + client Route53API + dryRun bool + maxChangeCount int + evaluateTargetHealth bool // only consider hosted zones managing domains ending in this suffix domainFilter DomainFilter // filter hosted zones by id @@ -96,8 +96,19 @@ type AWSProvider struct { zoneTypeFilter ZoneTypeFilter } +// AWSConfig contains configuration to create a new AWS provider. +type AWSConfig struct { + DomainFilter DomainFilter + ZoneIDFilter ZoneIDFilter + ZoneTypeFilter ZoneTypeFilter + MaxChangeCount int + EvaluateTargetHealth bool + AssumeRole string + DryRun bool +} + // NewAWSProvider initializes a new AWS Route53 based Provider. -func NewAWSProvider(domainFilter DomainFilter, zoneIDFilter ZoneIDFilter, zoneTypeFilter ZoneTypeFilter, assumeRole string, dryRun bool) (*AWSProvider, error) { +func NewAWSProvider(awsConfig AWSConfig) (*AWSProvider, error) { config := aws.NewConfig() config.WithHTTPClient( @@ -117,17 +128,19 @@ func NewAWSProvider(domainFilter DomainFilter, zoneIDFilter ZoneIDFilter, zoneTy return nil, err } - if assumeRole != "" { - log.Infof("Assuming role: %s", assumeRole) - session.Config.WithCredentials(stscreds.NewCredentials(session, assumeRole)) + if awsConfig.AssumeRole != "" { + log.Infof("Assuming role: %s", awsConfig.AssumeRole) + session.Config.WithCredentials(stscreds.NewCredentials(session, awsConfig.AssumeRole)) } provider := &AWSProvider{ - client: route53.New(session), - domainFilter: domainFilter, - zoneIDFilter: zoneIDFilter, - zoneTypeFilter: zoneTypeFilter, - dryRun: dryRun, + client: route53.New(session), + domainFilter: awsConfig.DomainFilter, + zoneIDFilter: awsConfig.ZoneIDFilter, + zoneTypeFilter: awsConfig.ZoneTypeFilter, + maxChangeCount: awsConfig.MaxChangeCount, + evaluateTargetHealth: awsConfig.EvaluateTargetHealth, + dryRun: awsConfig.DryRun, } return provider, nil @@ -231,26 +244,26 @@ 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(newChanges(route53.ChangeActionCreate, endpoints)) + return p.submitChanges(p.newChanges(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(newChanges(route53.ChangeActionUpsert, endpoints)) + return p.submitChanges(p.newChanges(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(newChanges(route53.ChangeActionDelete, endpoints)) + return p.submitChanges(p.newChanges(route53.ChangeActionDelete, endpoints)) } // ApplyChanges applies a given set of changes in a given zone. func (p *AWSProvider) ApplyChanges(changes *plan.Changes) error { combinedChanges := make([]*route53.Change, 0, len(changes.Create)+len(changes.UpdateNew)+len(changes.Delete)) - combinedChanges = append(combinedChanges, newChanges(route53.ChangeActionCreate, changes.Create)...) - combinedChanges = append(combinedChanges, newChanges(route53.ChangeActionUpsert, changes.UpdateNew)...) - combinedChanges = append(combinedChanges, newChanges(route53.ChangeActionDelete, 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)...) return p.submitChanges(combinedChanges) } @@ -270,9 +283,12 @@ func (p *AWSProvider) submitChanges(changes []*route53.Change) error { // separate into per-zone change sets to be passed to the API. changesByZone := changesByZone(zones, changes) + if len(changesByZone) == 0 { + log.Info("All records are already up to date, there are no changes for the matching hosted zones") + } for z, cs := range changesByZone { - limCs := limitChangeSet(cs, maxChangeCount) + limCs := limitChangeSet(cs, p.maxChangeCount) for _, c := range limCs { log.Infof("Desired change: %s %s %s", *c.Action, *c.ResourceRecordSet.Name, *c.ResourceRecordSet.Type) @@ -297,6 +313,53 @@ func (p *AWSProvider) submitChanges(changes []*route53.Change) error { return nil } +// newChanges returns a collection of Changes based on the given records and action. +func (p *AWSProvider) newChanges(action string, endpoints []*endpoint.Endpoint) []*route53.Change { + changes := make([]*route53.Change, 0, len(endpoints)) + + for _, endpoint := range endpoints { + changes = append(changes, p.newChange(action, endpoint)) + } + + return changes +} + +// 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 { + change := &route53.Change{ + Action: aws.String(action), + ResourceRecordSet: &route53.ResourceRecordSet{ + Name: aws.String(endpoint.DNSName), + }, + } + + if isAWSLoadBalancer(endpoint) { + change.ResourceRecordSet.Type = aws.String(route53.RRTypeA) + change.ResourceRecordSet.AliasTarget = &route53.AliasTarget{ + DNSName: aws.String(endpoint.Targets[0]), + HostedZoneId: aws.String(canonicalHostedZone(endpoint.Targets[0])), + EvaluateTargetHealth: aws.Bool(p.evaluateTargetHealth), + } + } else { + change.ResourceRecordSet.Type = aws.String(endpoint.RecordType) + if !endpoint.RecordTTL.IsConfigured() { + change.ResourceRecordSet.TTL = aws.Int64(recordTTL) + } else { + change.ResourceRecordSet.TTL = aws.Int64(int64(endpoint.RecordTTL)) + } + change.ResourceRecordSet.ResourceRecords = make([]*route53.ResourceRecord, len(endpoint.Targets)) + for idx, val := range endpoint.Targets { + change.ResourceRecordSet.ResourceRecords[idx] = &route53.ResourceRecord{ + Value: aws.String(val), + } + } + } + + return change +} + func limitChangeSet(cs []*route53.Change, limit int) []*route53.Change { if len(cs) <= limit { return cs @@ -381,53 +444,6 @@ func changesByZone(zones map[string]*route53.HostedZone, changeSet []*route53.Ch return changes } -// newChanges returns a collection of Changes based on the given records and action. -func newChanges(action string, endpoints []*endpoint.Endpoint) []*route53.Change { - changes := make([]*route53.Change, 0, len(endpoints)) - - for _, endpoint := range endpoints { - changes = append(changes, newChange(action, endpoint)) - } - - return changes -} - -// 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 newChange(action string, endpoint *endpoint.Endpoint) *route53.Change { - change := &route53.Change{ - Action: aws.String(action), - ResourceRecordSet: &route53.ResourceRecordSet{ - Name: aws.String(endpoint.DNSName), - }, - } - - if isAWSLoadBalancer(endpoint) { - change.ResourceRecordSet.Type = aws.String(route53.RRTypeA) - change.ResourceRecordSet.AliasTarget = &route53.AliasTarget{ - DNSName: aws.String(endpoint.Targets[0]), - HostedZoneId: aws.String(canonicalHostedZone(endpoint.Targets[0])), - EvaluateTargetHealth: aws.Bool(evaluateTargetHealth), - } - } else { - change.ResourceRecordSet.Type = aws.String(endpoint.RecordType) - if !endpoint.RecordTTL.IsConfigured() { - change.ResourceRecordSet.TTL = aws.Int64(recordTTL) - } else { - change.ResourceRecordSet.TTL = aws.Int64(int64(endpoint.RecordTTL)) - } - change.ResourceRecordSet.ResourceRecords = make([]*route53.ResourceRecord, len(endpoint.Targets)) - for idx, val := range endpoint.Targets { - change.ResourceRecordSet.ResourceRecords[idx] = &route53.ResourceRecord{ - Value: aws.String(val), - } - } - } - - return change -} - // suitableZones returns all suitable private zones and the most suitable public zone // for a given hostname and a set of zones. func suitableZones(hostname string, zones map[string]*route53.HostedZone) []*route53.HostedZone { @@ -435,7 +451,7 @@ func suitableZones(hostname string, zones map[string]*route53.HostedZone) []*rou var publicZone *route53.HostedZone for _, z := range zones { - if strings.HasSuffix(hostname, aws.StringValue(z.Name)) { + if aws.StringValue(z.Name) == hostname || strings.HasSuffix(hostname, "."+aws.StringValue(z.Name)) { if z.Config == nil || !aws.BoolValue(z.Config.PrivateZone) { // Only select the best matching public zone if publicZone == nil || len(aws.StringValue(z.Name)) > len(aws.StringValue(publicZone.Name)) { diff --git a/provider/aws_test.go b/provider/aws_test.go index d215d903c..63d6fa7c8 100644 --- a/provider/aws_test.go +++ b/provider/aws_test.go @@ -32,6 +32,11 @@ import ( "github.com/stretchr/testify/require" ) +const ( + defaultMaxChangeCount = 4000 + defaultEvaluateTargetHealth = true +) + // Compile time check for interface conformance var _ Route53API = &Route53APIStub{} @@ -194,7 +199,7 @@ func TestAWSZones(t *testing.T) { {"unknown filter", NewZoneIDFilter([]string{}), NewZoneTypeFilter("unknown"), noZones}, {"zone id filter", NewZoneIDFilter([]string{"/hostedzone/zone-3.ext-dns-test-2.teapot.zalan.do."}), NewZoneTypeFilter(""), privateZones}, } { - provider := newAWSProvider(t, NewDomainFilter([]string{"ext-dns-test-2.teapot.zalan.do."}), ti.zoneIDFilter, ti.zoneTypeFilter, false, []*endpoint.Endpoint{}) + provider := newAWSProvider(t, NewDomainFilter([]string{"ext-dns-test-2.teapot.zalan.do."}), ti.zoneIDFilter, ti.zoneTypeFilter, defaultEvaluateTargetHealth, false, []*endpoint.Endpoint{}) zones, err := provider.Zones() require.NoError(t, err) @@ -204,7 +209,7 @@ func TestAWSZones(t *testing.T) { } func TestAWSRecords(t *testing.T) { - provider := newAWSProvider(t, NewDomainFilter([]string{"ext-dns-test-2.teapot.zalan.do."}), NewZoneIDFilter([]string{}), NewZoneTypeFilter(""), false, []*endpoint.Endpoint{ + provider := newAWSProvider(t, NewDomainFilter([]string{"ext-dns-test-2.teapot.zalan.do."}), NewZoneIDFilter([]string{}), NewZoneTypeFilter(""), defaultEvaluateTargetHealth, false, []*endpoint.Endpoint{ 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"), @@ -228,7 +233,7 @@ func TestAWSRecords(t *testing.T) { func TestAWSCreateRecords(t *testing.T) { customTTL := endpoint.TTL(60) - provider := newAWSProvider(t, NewDomainFilter([]string{"ext-dns-test-2.teapot.zalan.do."}), NewZoneIDFilter([]string{}), NewZoneTypeFilter(""), false, []*endpoint.Endpoint{}) + provider := newAWSProvider(t, NewDomainFilter([]string{"ext-dns-test-2.teapot.zalan.do."}), NewZoneIDFilter([]string{}), NewZoneTypeFilter(""), defaultEvaluateTargetHealth, false, []*endpoint.Endpoint{}) records := []*endpoint.Endpoint{ endpoint.NewEndpoint("create-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "1.2.3.4"), @@ -253,7 +258,7 @@ func TestAWSCreateRecords(t *testing.T) { } func TestAWSUpdateRecords(t *testing.T) { - provider := newAWSProvider(t, NewDomainFilter([]string{"ext-dns-test-2.teapot.zalan.do."}), NewZoneIDFilter([]string{}), NewZoneTypeFilter(""), false, []*endpoint.Endpoint{ + 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("update-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), "foo.elb.amazonaws.com"), @@ -296,7 +301,7 @@ func TestAWSDeleteRecords(t *testing.T) { endpoint.NewEndpointWithTTL("delete-test-multiple.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "8.8.8.8", "8.8.4.4"), } - provider := newAWSProvider(t, NewDomainFilter([]string{"ext-dns-test-2.teapot.zalan.do."}), NewZoneIDFilter([]string{}), NewZoneTypeFilter(""), false, originalEndpoints) + provider := newAWSProvider(t, NewDomainFilter([]string{"ext-dns-test-2.teapot.zalan.do."}), NewZoneIDFilter([]string{}), NewZoneTypeFilter(""), defaultEvaluateTargetHealth, false, originalEndpoints) require.NoError(t, provider.DeleteRecords(originalEndpoints)) @@ -308,7 +313,7 @@ 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(""), false, []*endpoint.Endpoint{ + 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"), @@ -392,7 +397,7 @@ func TestAWSApplyChangesDryRun(t *testing.T) { 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"), } - provider := newAWSProvider(t, NewDomainFilter([]string{"ext-dns-test-2.teapot.zalan.do."}), NewZoneIDFilter([]string{}), NewZoneTypeFilter(""), true, originalEndpoints) + provider := newAWSProvider(t, NewDomainFilter([]string{"ext-dns-test-2.teapot.zalan.do."}), NewZoneIDFilter([]string{}), NewZoneTypeFilter(""), defaultEvaluateTargetHealth, true, originalEndpoints) createRecords := []*endpoint.Endpoint{ endpoint.NewEndpoint("create-test.zone-1.ext-dns-test-2.teapot.zalan.do", endpoint.RecordTypeA, "8.8.8.8"), @@ -538,9 +543,9 @@ func TestAWSChangesByZones(t *testing.T) { } func TestAWSsubmitChanges(t *testing.T) { - provider := newAWSProvider(t, NewDomainFilter([]string{"ext-dns-test-2.teapot.zalan.do."}), NewZoneIDFilter([]string{}), NewZoneTypeFilter(""), false, []*endpoint.Endpoint{}) + provider := newAWSProvider(t, NewDomainFilter([]string{"ext-dns-test-2.teapot.zalan.do."}), NewZoneIDFilter([]string{}), NewZoneTypeFilter(""), defaultEvaluateTargetHealth, false, []*endpoint.Endpoint{}) const subnets = 16 - const hosts = maxChangeCount / subnets + const hosts = defaultMaxChangeCount / subnets endpoints := make([]*endpoint.Endpoint, 0) for i := 0; i < subnets; i++ { @@ -553,7 +558,7 @@ func TestAWSsubmitChanges(t *testing.T) { } cs := make([]*route53.Change, 0, len(endpoints)) - cs = append(cs, newChanges(route53.ChangeActionCreate, endpoints)...) + cs = append(cs, provider.newChanges(route53.ChangeActionCreate, endpoints)...) require.NoError(t, provider.submitChanges(cs)) @@ -566,7 +571,7 @@ func TestAWSsubmitChanges(t *testing.T) { func TestAWSLimitChangeSet(t *testing.T) { var cs []*route53.Change - for i := 1; i <= maxChangeCount; i += 2 { + for i := 1; i <= defaultMaxChangeCount; i += 2 { cs = append(cs, &route53.Change{ Action: aws.String(route53.ChangeActionCreate), ResourceRecordSet: &route53.ResourceRecordSet{ @@ -583,7 +588,7 @@ func TestAWSLimitChangeSet(t *testing.T) { }) } - limCs := limitChangeSet(cs, maxChangeCount) + limCs := limitChangeSet(cs, defaultMaxChangeCount) // sorting cs not needed as it should be returned as is validateAWSChangeRecords(t, limCs, cs) @@ -650,7 +655,7 @@ func validateAWSChangeRecord(t *testing.T, record *route53.Change, expected *rou } func TestAWSCreateRecordsWithCNAME(t *testing.T) { - provider := newAWSProvider(t, NewDomainFilter([]string{"ext-dns-test-2.teapot.zalan.do."}), NewZoneIDFilter([]string{}), NewZoneTypeFilter(""), false, []*endpoint.Endpoint{}) + provider := newAWSProvider(t, NewDomainFilter([]string{"ext-dns-test-2.teapot.zalan.do."}), NewZoneIDFilter([]string{}), NewZoneTypeFilter(""), defaultEvaluateTargetHealth, false, []*endpoint.Endpoint{}) records := []*endpoint.Endpoint{ {DNSName: "create-test.zone-1.ext-dns-test-2.teapot.zalan.do", Targets: endpoint.Targets{"foo.example.org"}, RecordType: endpoint.RecordTypeCNAME}, @@ -675,27 +680,32 @@ func TestAWSCreateRecordsWithCNAME(t *testing.T) { } func TestAWSCreateRecordsWithALIAS(t *testing.T) { - provider := newAWSProvider(t, NewDomainFilter([]string{"ext-dns-test-2.teapot.zalan.do."}), NewZoneIDFilter([]string{}), NewZoneTypeFilter(""), false, []*endpoint.Endpoint{}) + for _, evaluateTargetHealth := range []bool{ + true, + false, + } { + provider := newAWSProvider(t, NewDomainFilter([]string{"ext-dns-test-2.teapot.zalan.do."}), NewZoneIDFilter([]string{}), NewZoneTypeFilter(""), evaluateTargetHealth, false, []*endpoint.Endpoint{}) - records := []*endpoint.Endpoint{ - {DNSName: "create-test.zone-1.ext-dns-test-2.teapot.zalan.do", Targets: endpoint.Targets{"foo.eu-central-1.elb.amazonaws.com"}, RecordType: endpoint.RecordTypeCNAME}, - } + records := []*endpoint.Endpoint{ + {DNSName: "create-test.zone-1.ext-dns-test-2.teapot.zalan.do", Targets: endpoint.Targets{"foo.eu-central-1.elb.amazonaws.com"}, RecordType: endpoint.RecordTypeCNAME}, + } - require.NoError(t, provider.CreateRecords(records)) + require.NoError(t, provider.CreateRecords(records)) - recordSets := listAWSRecords(t, provider.client, "/hostedzone/zone-1.ext-dns-test-2.teapot.zalan.do.") + recordSets := listAWSRecords(t, provider.client, "/hostedzone/zone-1.ext-dns-test-2.teapot.zalan.do.") - validateRecords(t, recordSets, []*route53.ResourceRecordSet{ - { - AliasTarget: &route53.AliasTarget{ - DNSName: aws.String("foo.eu-central-1.elb.amazonaws.com."), - EvaluateTargetHealth: aws.Bool(true), - HostedZoneId: aws.String("Z215JYRZR1TBD5"), + validateRecords(t, recordSets, []*route53.ResourceRecordSet{ + { + AliasTarget: &route53.AliasTarget{ + DNSName: aws.String("foo.eu-central-1.elb.amazonaws.com."), + EvaluateTargetHealth: aws.Bool(evaluateTargetHealth), + HostedZoneId: aws.String("Z215JYRZR1TBD5"), + }, + Name: aws.String("create-test.zone-1.ext-dns-test-2.teapot.zalan.do."), + Type: aws.String(endpoint.RecordTypeA), }, - Name: aws.String("create-test.zone-1.ext-dns-test-2.teapot.zalan.do."), - Type: aws.String(endpoint.RecordTypeA), - }, - }) + }) + } } func TestAWSisLoadBalancer(t *testing.T) { @@ -779,6 +789,13 @@ func TestAWSSuitableZones(t *testing.T) { hostname string expected []*route53.HostedZone }{ + // bar.example.org is NOT suitable + {"foobar.example.org.", []*route53.HostedZone{zones["example-org-private"], zones["example-org"]}}, + + // all matching private zones are suitable + // https://github.com/kubernetes-incubator/external-dns/pull/356 + {"bar.example.org.", []*route53.HostedZone{zones["example-org-private"], zones["bar-example-org-private"], zones["bar-example-org"]}}, + {"foo.bar.example.org.", []*route53.HostedZone{zones["example-org-private"], zones["bar-example-org-private"], zones["bar-example-org"]}}, {"foo.example.org.", []*route53.HostedZone{zones["example-org-private"], zones["example-org"]}}, {"foo.kubernetes.io.", nil}, @@ -859,15 +876,17 @@ func clearAWSRecords(t *testing.T, provider *AWSProvider, zone string) { } } -func newAWSProvider(t *testing.T, domainFilter DomainFilter, zoneIDFilter ZoneIDFilter, zoneTypeFilter ZoneTypeFilter, dryRun bool, records []*endpoint.Endpoint) *AWSProvider { +func newAWSProvider(t *testing.T, domainFilter DomainFilter, zoneIDFilter ZoneIDFilter, zoneTypeFilter ZoneTypeFilter, evaluateTargetHealth, dryRun bool, records []*endpoint.Endpoint) *AWSProvider { client := NewRoute53APIStub() provider := &AWSProvider{ - client: client, - domainFilter: domainFilter, - zoneIDFilter: zoneIDFilter, - zoneTypeFilter: zoneTypeFilter, - dryRun: false, + client: client, + maxChangeCount: defaultMaxChangeCount, + evaluateTargetHealth: evaluateTargetHealth, + domainFilter: domainFilter, + zoneIDFilter: zoneIDFilter, + zoneTypeFilter: zoneTypeFilter, + dryRun: false, } createAWSZone(t, provider, &route53.HostedZone{ diff --git a/provider/azure.go b/provider/azure.go index e044761d0..82077139e 100644 --- a/provider/azure.go +++ b/provider/azure.go @@ -40,13 +40,14 @@ const ( ) type config struct { - Cloud string `json:"cloud" yaml:"cloud"` - TenantID string `json:"tenantId" yaml:"tenantId"` - SubscriptionID string `json:"subscriptionId" yaml:"subscriptionId"` - ResourceGroup string `json:"resourceGroup" yaml:"resourceGroup"` - Location string `json:"location" yaml:"location"` - ClientID string `json:"aadClientId" yaml:"aadClientId"` - ClientSecret string `json:"aadClientSecret" yaml:"aadClientSecret"` + Cloud string `json:"cloud" yaml:"cloud"` + TenantID string `json:"tenantId" yaml:"tenantId"` + SubscriptionID string `json:"subscriptionId" yaml:"subscriptionId"` + ResourceGroup string `json:"resourceGroup" yaml:"resourceGroup"` + Location string `json:"location" yaml:"location"` + ClientID string `json:"aadClientId" yaml:"aadClientId"` + ClientSecret string `json:"aadClientSecret" yaml:"aadClientSecret"` + UseManagedIdentityExtension bool `json:"useManagedIdentityExtension" yaml:"useManagedIdentityExtension"` } // ZonesClient is an interface of dns.ZoneClient that can be stubbed for testing. @@ -102,14 +103,9 @@ func NewAzureProvider(configFile string, domainFilter DomainFilter, zoneIDFilter } } - oauthConfig, err := adal.NewOAuthConfig(environment.ActiveDirectoryEndpoint, cfg.TenantID) + token, err := getAccessToken(cfg, environment) if err != nil { - return nil, fmt.Errorf("failed to retrieve OAuth config: %v", err) - } - - token, err := adal.NewServicePrincipalToken(*oauthConfig, cfg.ClientID, cfg.ClientSecret, environment.ResourceManagerEndpoint) - if err != nil { - return nil, fmt.Errorf("failed to create service principal token: %v", err) + return nil, fmt.Errorf("failed to get token: %v", err) } zonesClient := dns.NewZonesClientWithBaseURI(environment.ResourceManagerEndpoint, cfg.SubscriptionID) @@ -128,6 +124,41 @@ func NewAzureProvider(configFile string, domainFilter DomainFilter, zoneIDFilter return provider, nil } +// getAccessToken retrieves Azure API access token. +func getAccessToken(cfg config, environment azure.Environment) (*adal.ServicePrincipalToken, error) { + // Try to retrive token with MSI. + if cfg.UseManagedIdentityExtension { + log.Info("Using managed identity extension to retrieve access token for Azure API.") + msiEndpoint, err := adal.GetMSIVMEndpoint() + if err != nil { + return nil, fmt.Errorf("failed to get the managed service identity endpoint: %v", err) + } + + token, err := adal.NewServicePrincipalTokenFromMSI(msiEndpoint, environment.ServiceManagementEndpoint) + if err != nil { + return nil, fmt.Errorf("failed to create the managed service identity token: %v", err) + } + return token, nil + } + + // Try to retrieve token with service principal credentials. + if len(cfg.ClientID) > 0 && len(cfg.ClientSecret) > 0 { + log.Info("Using client_id+client_secret to retrieve access token for Azure API.") + oauthConfig, err := adal.NewOAuthConfig(environment.ActiveDirectoryEndpoint, cfg.TenantID) + if err != nil { + return nil, fmt.Errorf("failed to retrieve OAuth config: %v", err) + } + + token, err := adal.NewServicePrincipalToken(*oauthConfig, cfg.ClientID, cfg.ClientSecret, environment.ResourceManagerEndpoint) + if err != nil { + return nil, fmt.Errorf("failed to create service principal token: %v", err) + } + return token, nil + } + + return nil, fmt.Errorf("no credentials provided for Azure API") +} + // Records gets the current records. // // Returns the current records or an error if the operation failed. diff --git a/provider/azure_test.go b/provider/azure_test.go index bc0c601f5..bdae835e8 100644 --- a/provider/azure_test.go +++ b/provider/azure_test.go @@ -21,6 +21,7 @@ import ( "github.com/Azure/azure-sdk-for-go/arm/dns" "github.com/Azure/go-autorest/autorest" + "github.com/Azure/go-autorest/autorest/azure" "github.com/Azure/go-autorest/autorest/to" "github.com/kubernetes-incubator/external-dns/endpoint" @@ -302,3 +303,18 @@ func testAzureApplyChangesInternal(t *testing.T, dryRun bool, client RecordsClie t.Fatal(err) } } + +func TestAzureGetAccessToken(t *testing.T) { + env := azure.PublicCloud + cfg := config{ + ClientID: "", + ClientSecret: "", + TenantID: "", + UseManagedIdentityExtension: false, + } + + _, err := getAccessToken(cfg, env) + if err == nil { + t.Fatalf("expected to fail, but got no error") + } +} diff --git a/provider/coredns.go b/provider/coredns.go new file mode 100644 index 000000000..7454e1691 --- /dev/null +++ b/provider/coredns.go @@ -0,0 +1,398 @@ +/* +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 ( + "container/list" + "crypto/tls" + "crypto/x509" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "math/rand" + "net" + "net/http" + "os" + "strings" + "time" + + etcd "github.com/coreos/etcd/client" + log "github.com/sirupsen/logrus" + "golang.org/x/net/context" + + "github.com/kubernetes-incubator/external-dns/endpoint" + "github.com/kubernetes-incubator/external-dns/plan" +) + +func init() { + rand.Seed(time.Now().UnixNano()) +} + +// skyDNSClient is an interface to work with SkyDNS service records in etcd +type skyDNSClient interface { + GetServices(prefix string) ([]*Service, error) + SaveService(value *Service) error + DeleteService(key string) error +} + +type coreDNSProvider struct { + dryRun bool + domainFilter DomainFilter + client skyDNSClient +} + +// Service represents SkyDNS/CoreDNS etcd record +type Service struct { + Host string `json:"host,omitempty"` + Port int `json:"port,omitempty"` + Priority int `json:"priority,omitempty"` + Weight int `json:"weight,omitempty"` + Text string `json:"text,omitempty"` + Mail bool `json:"mail,omitempty"` // Be an MX record. Priority becomes Preference. + TTL uint32 `json:"ttl,omitempty"` + + // When a SRV record with a "Host: IP-address" is added, we synthesize + // a srv.Target domain name. Normally we convert the full Key where + // the record lives to a DNS name and use this as the srv.Target. When + // TargetStrip > 0 we strip the left most TargetStrip labels from the + // DNS name. + TargetStrip int `json:"targetstrip,omitempty"` + + // Group is used to group (or *not* to group) different services + // together. Services with an identical Group are returned in the same + // answer. + Group string `json:"group,omitempty"` + + // Etcd key where we found this service and ignored from json un-/marshalling + Key string `json:"-"` +} + +type etcdClient struct { + api etcd.KeysAPI +} + +var _ skyDNSClient = etcdClient{} + +// GetService return all Service records stored in etcd stored anywhere under the given key (recursively) +func (c etcdClient) GetServices(prefix string) ([]*Service, error) { + var result []*Service + opts := &etcd.GetOptions{Recursive: true} + data, err := c.api.Get(context.Background(), prefix, opts) + if err != nil { + if etcd.IsKeyNotFound(err) { + return nil, nil + } + return nil, err + } + + queue := list.New() + queue.PushFront(data.Node) + for queueNode := queue.Front(); queueNode != nil; queueNode = queueNode.Next() { + node := queueNode.Value.(*etcd.Node) + if node.Dir { + for _, childNode := range node.Nodes { + queue.PushBack(childNode) + } + continue + } + service := &Service{} + err = json.Unmarshal([]byte(node.Value), service) + if err != nil { + log.Error("Cannot parse JSON value ", node.Value) + continue + } + service.Key = node.Key + result = append(result, service) + } + return result, nil +} + +// SaveService persists service data into etcd +func (c etcdClient) SaveService(service *Service) error { + value, err := json.Marshal(&service) + if err != nil { + return err + } + _, err = c.api.Set(context.Background(), service.Key, string(value), nil) + if err != nil { + return err + } + return nil +} + +// DeleteService deletes service record from etcd +func (c etcdClient) DeleteService(key string) error { + _, err := c.api.Delete(context.Background(), key, nil) + return err + +} + +// loads TLS artifacts and builds tls.Clonfig object +func newTLSConfig(certPath, keyPath, caPath, serverName string, insecure bool) (*tls.Config, error) { + if certPath != "" && keyPath == "" || certPath == "" && keyPath != "" { + return nil, errors.New("either both cert and key or none must be provided") + } + var certificates []tls.Certificate + if certPath != "" { + cert, err := tls.LoadX509KeyPair(certPath, keyPath) + if err != nil { + return nil, fmt.Errorf("could not load TLS cert: %s", err) + } + certificates = append(certificates, cert) + } + roots, err := loadRoots(caPath) + if err != nil { + return nil, err + } + + return &tls.Config{ + Certificates: certificates, + RootCAs: roots, + InsecureSkipVerify: insecure, + ServerName: serverName, + }, nil +} + +// loads CA cert +func loadRoots(caPath string) (*x509.CertPool, error) { + if caPath == "" { + return nil, nil + } + + roots := x509.NewCertPool() + pem, err := ioutil.ReadFile(caPath) + if err != nil { + return nil, fmt.Errorf("error reading %s: %s", caPath, err) + } + ok := roots.AppendCertsFromPEM(pem) + if !ok { + return nil, fmt.Errorf("could not read root certs: %s", err) + } + return roots, nil +} + +// constructs http.Transport object for https protocol +func newHTTPSTransport(cc *tls.Config) *http.Transport { + return &http.Transport{ + Proxy: http.ProxyFromEnvironment, + Dial: (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + }).Dial, + TLSHandshakeTimeout: 10 * time.Second, + TLSClientConfig: cc, + } +} + +// builds etcd client config depending on connection scheme and TLS parameters +func getETCDConfig() (*etcd.Config, error) { + etcdURLsStr := os.Getenv("ETCD_URLS") + if etcdURLsStr == "" { + etcdURLsStr = "http://localhost:2379" + } + etcdURLs := strings.Split(etcdURLsStr, ",") + firstURL := strings.ToLower(etcdURLs[0]) + if strings.HasPrefix(firstURL, "http://") { + return &etcd.Config{Endpoints: etcdURLs}, nil + } else if strings.HasPrefix(firstURL, "https://") { + caFile := os.Getenv("ETCD_CA_FILE") + certFile := os.Getenv("ETCD_CERT_FILE") + keyFile := os.Getenv("ETCD_KEY_FILE") + serverName := os.Getenv("ETCD_TLS_SERVER_NAME") + isInsecureStr := strings.ToLower(os.Getenv("ETCD_TLS_INSECURE")) + isInsecure := isInsecureStr == "true" || isInsecureStr == "yes" || isInsecureStr == "1" + tlsConfig, err := newTLSConfig(certFile, keyFile, caFile, serverName, isInsecure) + if err != nil { + return nil, err + } + return &etcd.Config{ + Endpoints: etcdURLs, + Transport: newHTTPSTransport(tlsConfig), + }, nil + } else { + return nil, errors.New("etcd URLs must start with either http:// or https://") + } +} + +//newETCDClient is an etcd client constructor +func newETCDClient() (skyDNSClient, error) { + cfg, err := getETCDConfig() + if err != nil { + return nil, err + } + c, err := etcd.New(*cfg) + if err != nil { + return nil, err + } + return etcdClient{etcd.NewKeysAPI(c)}, nil +} + +// NewCoreDNSProvider is a CoreDNS provider constructor +func NewCoreDNSProvider(domainFilter DomainFilter, dryRun bool) (Provider, error) { + client, err := newETCDClient() + if err != nil { + return nil, err + } + return coreDNSProvider{ + client: client, + dryRun: dryRun, + domainFilter: domainFilter, + }, nil +} + +// Records returns all DNS records found in SkyDNS/CoreDNS etcd backend. Depending on the record fields +// it may be mapped to one or two records of type A, CNAME, TXT, A+TXT, CNAME+TXT +func (p coreDNSProvider) Records() ([]*endpoint.Endpoint, error) { + var result []*endpoint.Endpoint + services, err := p.client.GetServices("/skydns") + if err != nil { + return nil, err + } + for _, service := range services { + domains := strings.Split(strings.TrimPrefix(service.Key, "/skydns/"), "/") + reverse(domains) + dnsName := strings.Join(domains[service.TargetStrip:], ".") + if !p.domainFilter.Match(dnsName) { + continue + } + prefix := strings.Join(domains[:service.TargetStrip], ".") + if service.Host != "" { + ep := endpoint.NewEndpoint( + dnsName, + guessRecordType(service.Host), + service.Host, + ) + ep.Labels["originalText"] = service.Text + ep.Labels["prefix"] = prefix + result = append(result, ep) + } + if service.Text != "" { + ep := endpoint.NewEndpoint( + dnsName, + endpoint.RecordTypeTXT, + service.Text, + ) + ep.Labels["prefix"] = prefix + result = append(result, ep) + } + } + return result, nil +} + +// ApplyChanges stores changes back to etcd converting them to SkyDNS format and aggregating A/CNAME and TXT records +func (p coreDNSProvider) ApplyChanges(changes *plan.Changes) error { + grouped := map[string][]*endpoint.Endpoint{} + for _, ep := range changes.Create { + grouped[ep.DNSName] = append(grouped[ep.DNSName], ep) + } + for _, ep := range changes.UpdateNew { + grouped[ep.DNSName] = append(grouped[ep.DNSName], ep) + } + for dnsName, group := range grouped { + if !p.domainFilter.Match(dnsName) { + log.Debugf("Skipping record %s because it was filtered out by the specified --domain-filter", dnsName) + continue + } + var services []Service + for _, ep := range group { + if ep.RecordType == endpoint.RecordTypeTXT { + continue + } + prefix := ep.Labels["prefix"] + if prefix == "" { + prefix = fmt.Sprintf("%08x", rand.Int31()) + } + service := Service{ + Host: ep.Targets[0], + Text: ep.Labels["originalText"], + Key: etcdKeyFor(prefix + "." + dnsName), + TargetStrip: strings.Count(prefix, ".") + 1, + } + services = append(services, service) + } + index := 0 + for _, ep := range group { + if ep.RecordType != "TXT" { + continue + } + if index >= len(services) { + prefix := ep.Labels["prefix"] + if prefix == "" { + prefix = fmt.Sprintf("%08x", rand.Int31()) + } + services = append(services, Service{ + Key: etcdKeyFor(prefix + "." + dnsName), + TargetStrip: strings.Count(prefix, ".") + 1, + }) + } + services[index].Text = ep.Targets[0] + index++ + } + + for i := index; index > 0 && i < len(services); i++ { + services[i].Text = "" + } + + for _, service := range services { + log.Infof("Add/set key %s to Host=%s, Text=%s", service.Key, service.Host, service.Text) + if !p.dryRun { + err := p.client.SaveService(&service) + if err != nil { + return err + } + } + } + } + + for _, ep := range changes.Delete { + dnsName := ep.DNSName + if ep.Labels["prefix"] != "" { + dnsName = ep.Labels["prefix"] + "." + dnsName + } + key := etcdKeyFor(dnsName) + log.Infof("Delete key %s", key) + if !p.dryRun { + err := p.client.DeleteService(key) + if err != nil { + return err + } + } + } + + return nil +} + +func guessRecordType(target string) string { + if net.ParseIP(target) != nil { + return endpoint.RecordTypeA + } + return endpoint.RecordTypeCNAME +} + +func etcdKeyFor(dnsName string) string { + domains := strings.Split(dnsName, ".") + reverse(domains) + return "/skydns/" + strings.Join(domains, "/") +} + +func reverse(slice []string) { + for i := 0; i < len(slice)/2; i++ { + j := len(slice) - i - 1 + slice[i], slice[j] = slice[j], slice[i] + } +} diff --git a/provider/coredns_test.go b/provider/coredns_test.go new file mode 100644 index 000000000..9c4b90ce1 --- /dev/null +++ b/provider/coredns_test.go @@ -0,0 +1,316 @@ +/* +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" + "testing" + + "github.com/kubernetes-incubator/external-dns/endpoint" + "github.com/kubernetes-incubator/external-dns/plan" +) + +type fakeETCDClient struct { + services map[string]*Service +} + +func (c fakeETCDClient) GetServices(prefix string) ([]*Service, error) { + var result []*Service + for key, value := range c.services { + if strings.HasPrefix(key, prefix) { + value.Key = key + result = append(result, value) + } + } + return result, nil +} + +func (c fakeETCDClient) SaveService(service *Service) error { + c.services[service.Key] = service + return nil +} + +func (c fakeETCDClient) DeleteService(key string) error { + delete(c.services, key) + return nil +} + +func TestAServiceTranslation(t *testing.T) { + expectedTarget := "1.2.3.4" + expectedDNSName := "example.com" + expectedRecordType := endpoint.RecordTypeA + + client := fakeETCDClient{ + map[string]*Service{ + "/skydns/com/example": {Host: expectedTarget}, + }, + } + provider := coreDNSProvider{client: client} + endpoints, err := provider.Records() + if err != nil { + t.Fatal(err) + } + if len(endpoints) != 1 { + t.Fatalf("got unexpected number of endpoints: %d", len(endpoints)) + } + if endpoints[0].DNSName != expectedDNSName { + t.Errorf("got unexpected DNS name: %s != %s", endpoints[0].DNSName, expectedDNSName) + } + if endpoints[0].Targets[0] != expectedTarget { + t.Errorf("got unexpected DNS target: %s != %s", endpoints[0].Targets[0], expectedTarget) + } + if endpoints[0].RecordType != expectedRecordType { + t.Errorf("got unexpected DNS record type: %s != %s", endpoints[0].RecordType, expectedRecordType) + } +} + +func TestCNAMEServiceTranslation(t *testing.T) { + expectedTarget := "example.net" + expectedDNSName := "example.com" + expectedRecordType := endpoint.RecordTypeCNAME + + client := fakeETCDClient{ + map[string]*Service{ + "/skydns/com/example": {Host: expectedTarget}, + }, + } + provider := coreDNSProvider{client: client} + endpoints, err := provider.Records() + if err != nil { + t.Fatal(err) + } + if len(endpoints) != 1 { + t.Fatalf("got unexpected number of endpoints: %d", len(endpoints)) + } + if endpoints[0].DNSName != expectedDNSName { + t.Errorf("got unexpected DNS name: %s != %s", endpoints[0].DNSName, expectedDNSName) + } + if endpoints[0].Targets[0] != expectedTarget { + t.Errorf("got unexpected DNS target: %s != %s", endpoints[0].Targets[0], expectedTarget) + } + if endpoints[0].RecordType != expectedRecordType { + t.Errorf("got unexpected DNS record type: %s != %s", endpoints[0].RecordType, expectedRecordType) + } +} + +func TestTXTServiceTranslation(t *testing.T) { + expectedTarget := "string" + expectedDNSName := "example.com" + expectedRecordType := endpoint.RecordTypeTXT + + client := fakeETCDClient{ + map[string]*Service{ + "/skydns/com/example": {Text: expectedTarget}, + }, + } + provider := coreDNSProvider{client: client} + endpoints, err := provider.Records() + if err != nil { + t.Fatal(err) + } + if len(endpoints) != 1 { + t.Fatalf("got unexpected number of endpoints: %d", len(endpoints)) + } + if endpoints[0].DNSName != expectedDNSName { + t.Errorf("got unexpected DNS name: %s != %s", endpoints[0].DNSName, expectedDNSName) + } + if endpoints[0].Targets[0] != expectedTarget { + t.Errorf("got unexpected DNS target: %s != %s", endpoints[0].Targets[0], expectedTarget) + } + if endpoints[0].RecordType != expectedRecordType { + t.Errorf("got unexpected DNS record type: %s != %s", endpoints[0].RecordType, expectedRecordType) + } +} + +func TestAWithTXTServiceTranslation(t *testing.T) { + expectedTargets := map[string]string{ + endpoint.RecordTypeA: "1.2.3.4", + endpoint.RecordTypeTXT: "string", + } + expectedDNSName := "example.com" + + client := fakeETCDClient{ + map[string]*Service{ + "/skydns/com/example": {Host: "1.2.3.4", Text: "string"}, + }, + } + provider := coreDNSProvider{client: client} + endpoints, err := provider.Records() + if err != nil { + t.Fatal(err) + } + if len(endpoints) != len(expectedTargets) { + t.Fatalf("got unexpected number of endpoints: %d", len(endpoints)) + } + + for _, ep := range endpoints { + expectedTarget := expectedTargets[ep.RecordType] + if expectedTarget == "" { + t.Errorf("got unexpected DNS record type: %s", ep.RecordType) + continue + } + delete(expectedTargets, ep.RecordType) + + if ep.DNSName != expectedDNSName { + t.Errorf("got unexpected DNS name: %s != %s", ep.DNSName, expectedDNSName) + } + + if ep.Targets[0] != expectedTarget { + t.Errorf("got unexpected DNS target: %s != %s", ep.Targets[0], expectedTarget) + } + } +} + +func TestCNAMEWithTXTServiceTranslation(t *testing.T) { + expectedTargets := map[string]string{ + endpoint.RecordTypeCNAME: "example.net", + endpoint.RecordTypeTXT: "string", + } + expectedDNSName := "example.com" + + client := fakeETCDClient{ + map[string]*Service{ + "/skydns/com/example": {Host: "example.net", Text: "string"}, + }, + } + provider := coreDNSProvider{client: client} + endpoints, err := provider.Records() + if err != nil { + t.Fatal(err) + } + if len(endpoints) != len(expectedTargets) { + t.Fatalf("got unexpected number of endpoints: %d", len(endpoints)) + } + + for _, ep := range endpoints { + expectedTarget := expectedTargets[ep.RecordType] + if expectedTarget == "" { + t.Errorf("got unexpected DNS record type: %s", ep.RecordType) + continue + } + delete(expectedTargets, ep.RecordType) + + if ep.DNSName != expectedDNSName { + t.Errorf("got unexpected DNS name: %s != %s", ep.DNSName, expectedDNSName) + } + + if ep.Targets[0] != expectedTarget { + t.Errorf("got unexpected DNS target: %s != %s", ep.Targets[0], expectedTarget) + } + } +} + +func TestCoreDNSApplyChanges(t *testing.T) { + client := fakeETCDClient{ + map[string]*Service{}, + } + coredns := coreDNSProvider{client: client} + + changes1 := &plan.Changes{ + Create: []*endpoint.Endpoint{ + endpoint.NewEndpoint("domain1.local", endpoint.RecordTypeA, "5.5.5.5"), + endpoint.NewEndpoint("domain1.local", endpoint.RecordTypeTXT, "string1"), + endpoint.NewEndpoint("domain2.local", endpoint.RecordTypeCNAME, "site.local"), + }, + } + coredns.ApplyChanges(changes1) + + expectedServices1 := map[string]*Service{ + "/skydns/local/domain1": {Host: "5.5.5.5", Text: "string1"}, + "/skydns/local/domain2": {Host: "site.local"}, + } + validateServices(client.services, expectedServices1, t, 1) + + updatedEp := endpoint.NewEndpoint("domain1.local", endpoint.RecordTypeA, "6.6.6.6") + updatedEp.Labels["originalText"] = "string1" + changes2 := &plan.Changes{ + Create: []*endpoint.Endpoint{ + endpoint.NewEndpoint("domain3.local", endpoint.RecordTypeA, "7.7.7.7"), + }, + UpdateNew: []*endpoint.Endpoint{ + endpoint.NewEndpoint("domain1.local", "A", "6.6.6.6"), + }, + } + applyServiceChanges(coredns, changes2) + + expectedServices2 := map[string]*Service{ + "/skydns/local/domain1": {Host: "6.6.6.6", Text: "string1"}, + "/skydns/local/domain2": {Host: "site.local"}, + "/skydns/local/domain3": {Host: "7.7.7.7"}, + } + validateServices(client.services, expectedServices2, t, 2) + + changes3 := &plan.Changes{ + Delete: []*endpoint.Endpoint{ + endpoint.NewEndpoint("domain1.local", endpoint.RecordTypeA, "6.6.6.6"), + endpoint.NewEndpoint("domain1.local", endpoint.RecordTypeTXT, "string"), + endpoint.NewEndpoint("domain3.local", endpoint.RecordTypeA, "7.7.7.7"), + }, + } + + applyServiceChanges(coredns, changes3) + + expectedServices3 := map[string]*Service{ + "/skydns/local/domain2": {Host: "site.local"}, + } + validateServices(client.services, expectedServices3, t, 3) +} + +func applyServiceChanges(provider coreDNSProvider, changes *plan.Changes) { + records, _ := provider.Records() + for _, col := range [][]*endpoint.Endpoint{changes.Create, changes.UpdateNew, changes.Delete} { + for _, record := range col { + for _, existingRecord := range records { + if existingRecord.DNSName == record.DNSName && existingRecord.RecordType == record.RecordType { + mergeLabels(record, existingRecord.Labels) + } + } + } + } + provider.ApplyChanges(changes) +} + +func validateServices(services, expectedServices map[string]*Service, t *testing.T, step int) { + if len(services) != len(expectedServices) { + t.Errorf("wrong number of records on step %d: %d != %d", step, len(services), len(expectedServices)) + } + for key, value := range services { + keyParts := strings.Split(key, "/") + expectedKey := strings.Join(keyParts[:len(keyParts)-value.TargetStrip], "/") + expectedService := expectedServices[expectedKey] + if expectedService == nil { + t.Errorf("unexpected service %s", key) + continue + } + delete(expectedServices, key) + if value.Host != expectedService.Host { + t.Errorf("wrong host for service %s: %s != %s on step %d", key, value.Host, expectedService.Host, step) + } + if value.Text != expectedService.Text { + t.Errorf("wrong text for service %s: %s != %s on step %d", key, value.Text, expectedService.Text, step) + } + } +} + +// mergeLabels adds keys to labels if not defined for the endpoint +func mergeLabels(e *endpoint.Endpoint, labels map[string]string) { + for k, v := range labels { + if e.Labels[k] == "" { + e.Labels[k] = v + } + } +} diff --git a/provider/digital_ocean.go b/provider/digital_ocean.go index 2b6694d0f..eb141c296 100644 --- a/provider/digital_ocean.go +++ b/provider/digital_ocean.go @@ -214,6 +214,12 @@ func (p *DigitalOceanProvider) submitChanges(changes []*DigitalOceanChange) erro change.ResourceRecordSet.Name = "@" } + // for some reason the DO API requires the '.' at the end of "data" in case of CNAME request + // Example: {"type":"CNAME","name":"hello","data":"www.example.com."} + if change.ResourceRecordSet.Type == endpoint.RecordTypeCNAME { + change.ResourceRecordSet.Data += "." + } + switch change.Action { case DigitalOceanCreate: _, _, err = p.Client.CreateRecord(context.TODO(), zoneName, diff --git a/provider/digital_ocean_test.go b/provider/digital_ocean_test.go index b8dc21c09..890add02b 100644 --- a/provider/digital_ocean_test.go +++ b/provider/digital_ocean_test.go @@ -436,7 +436,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"}}} + changes.UpdateNew = []*endpoint.Endpoint{{DNSName: "foobar.ext-dns-test.foo.com", Targets: endpoint.Targets{"target-new"}, RecordType: "CNAME"}} err := provider.ApplyChanges(changes) if err != nil { t.Errorf("should not fail, %s", err) diff --git a/provider/dyn.go b/provider/dyn.go index a5a948c63..aeaf34960 100644 --- a/provider/dyn.go +++ b/provider/dyn.go @@ -91,7 +91,7 @@ func (c *cache) Get(link string) *endpoint.Endpoint { return result.ep } -// DynConfig hold connection parameters to dyn.com and interanl state +// DynConfig hold connection parameters to dyn.com and internal state type DynConfig struct { DomainFilter DomainFilter ZoneIDFilter ZoneIDFilter diff --git a/provider/exoscale.go b/provider/exoscale.go new file mode 100644 index 000000000..f4e7866ca --- /dev/null +++ b/provider/exoscale.go @@ -0,0 +1,255 @@ +/* +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" + + "github.com/exoscale/egoscale" + "github.com/kubernetes-incubator/external-dns/endpoint" + "github.com/kubernetes-incubator/external-dns/plan" + log "github.com/sirupsen/logrus" +) + +// EgoscaleClientI for replaceable implementation +type EgoscaleClientI interface { + GetRecords(string) ([]egoscale.DNSRecord, error) + GetDomains() ([]egoscale.DNSDomain, error) + CreateRecord(string, egoscale.DNSRecord) (*egoscale.DNSRecord, error) + DeleteRecord(string, int64) error + UpdateRecord(string, egoscale.UpdateDNSRecord) (*egoscale.DNSRecord, error) +} + +// ExoscaleProvider initialized as dns provider with no records +type ExoscaleProvider struct { + domain DomainFilter + client EgoscaleClientI + filter *zoneFilter + OnApplyChanges func(changes *plan.Changes) + dryRun bool +} + +// ExoscaleOption for Provider options +type ExoscaleOption func(*ExoscaleProvider) + +// NewExoscaleProvider returns ExoscaleProvider DNS provider interface implementation +func NewExoscaleProvider(endpoint, apiKey, apiSecret string, dryRun bool, opts ...ExoscaleOption) *ExoscaleProvider { + client := egoscale.NewClient(endpoint, apiKey, apiSecret) + return NewExoscaleProviderWithClient(endpoint, apiKey, apiSecret, client, dryRun, opts...) +} + +// NewExoscaleProviderWithClient returns ExoscaleProvider DNS provider interface implementation (Client provided) +func NewExoscaleProviderWithClient(endpoint, apiKey, apiSecret string, client EgoscaleClientI, dryRun bool, opts ...ExoscaleOption) *ExoscaleProvider { + ep := &ExoscaleProvider{ + filter: &zoneFilter{}, + OnApplyChanges: func(changes *plan.Changes) {}, + domain: NewDomainFilter([]string{""}), + client: client, + dryRun: dryRun, + } + for _, opt := range opts { + opt(ep) + } + return ep +} + +func (ep *ExoscaleProvider) getZones() (map[int64]string, error) { + dom, err := ep.client.GetDomains() + if err != nil { + return nil, err + } + + zones := map[int64]string{} + for _, d := range dom { + zones[d.ID] = d.Name + } + return zones, nil +} + +// ApplyChanges simply modifies DNS via exoscale API +func (ep *ExoscaleProvider) ApplyChanges(changes *plan.Changes) error { + ep.OnApplyChanges(changes) + + if ep.dryRun { + log.Infof("Will NOT delete these records: %+v", changes.Delete) + log.Infof("Will NOT create these records: %+v", changes.Create) + log.Infof("Will NOT update these records: %+v", merge(changes.UpdateOld, changes.UpdateNew)) + return nil + } + + zones, err := ep.getZones() + if err != nil { + return err + } + + for _, epoint := range changes.Create { + if ep.domain.Match(epoint.DNSName) { + if zoneID, name := ep.filter.EndpointZoneID(epoint, zones); zoneID != 0 { + rec := egoscale.DNSRecord{ + Name: name, + RecordType: epoint.RecordType, + TTL: int(epoint.RecordTTL), + Content: epoint.Targets[0], + } + _, err := ep.client.CreateRecord(zones[zoneID], rec) + if err != nil { + return err + } + } + } + } + for _, epoint := range changes.UpdateNew { + if ep.domain.Match(epoint.DNSName) { + if zoneID, name := ep.filter.EndpointZoneID(epoint, zones); zoneID != 0 { + records, err := ep.client.GetRecords(zones[zoneID]) + if err != nil { + return err + } + for _, r := range records { + if r.Name == name { + rec := egoscale.UpdateDNSRecord{ + ID: r.ID, + DomainID: r.DomainID, + Name: name, + RecordType: epoint.RecordType, + TTL: int(epoint.RecordTTL), + Content: epoint.Targets[0], + Prio: r.Prio, + } + if _, err := ep.client.UpdateRecord(zones[zoneID], rec); err != nil { + return err + } + break + } + } + } + } + } + for _, epoint := range changes.UpdateOld { + // Since Exoscale "Patches", we ignore UpdateOld + // We leave this logging here for information + log.Debugf("UPDATE-OLD (ignored) for epoint: %+v", epoint) + } + for _, epoint := range changes.Delete { + if ep.domain.Match(epoint.DNSName) { + if zoneID, name := ep.filter.EndpointZoneID(epoint, zones); zoneID != 0 { + records, err := ep.client.GetRecords(zones[zoneID]) + if err != nil { + return err + } + + for _, r := range records { + if r.Name == name { + if err := ep.client.DeleteRecord(zones[zoneID], r.ID); err != nil { + return err + } + break + } + } + } + } + } + + return nil +} + +// Records returns the list of endpoints +func (ep *ExoscaleProvider) Records() ([]*endpoint.Endpoint, error) { + endpoints := make([]*endpoint.Endpoint, 0) + + dom, err := ep.client.GetDomains() + if err != nil { + return nil, err + } + + for _, d := range dom { + record, err := ep.client.GetRecords(d.Name) + if err != nil { + return nil, err + } + for _, r := range record { + switch r.RecordType { + case "A", "CNAME", "TXT": + break + default: + continue + } + ep := endpoint.NewEndpointWithTTL(r.Name+"."+d.Name, r.RecordType, endpoint.TTL(r.TTL), r.Content) + endpoints = append(endpoints, ep) + } + } + + log.Infof("called Records() with %d items", len(endpoints)) + return endpoints, nil +} + +// ExoscaleWithDomain modifies the domain on which dns zones are filtered +func ExoscaleWithDomain(domainFilter DomainFilter) ExoscaleOption { + return func(p *ExoscaleProvider) { + p.domain = domainFilter + } +} + +// ExoscaleWithLogging injects logging when ApplyChanges is called +func ExoscaleWithLogging() ExoscaleOption { + return func(p *ExoscaleProvider) { + p.OnApplyChanges = func(changes *plan.Changes) { + for _, v := range changes.Create { + log.Infof("CREATE: %v", v) + } + for _, v := range changes.UpdateOld { + log.Infof("UPDATE (old): %v", v) + } + for _, v := range changes.UpdateNew { + log.Infof("UPDATE (new): %v", v) + } + for _, v := range changes.Delete { + log.Infof("DELETE: %v", v) + } + } + } +} + +type zoneFilter struct { + domain string +} + +// Zones filters map[zoneID]zoneName for names having f.domain as suffix +func (f *zoneFilter) Zones(zones map[int64]string) map[int64]string { + result := map[int64]string{} + for zoneID, zoneName := range zones { + if strings.HasSuffix(zoneName, f.domain) { + result[zoneID] = zoneName + } + } + return result +} + +// EndpointZoneID determines zoneID for endpoint from map[zoneID]zoneName by taking longest suffix zoneName match in endpoint DNSName +// returns 0 if no match found +func (f *zoneFilter) EndpointZoneID(endpoint *endpoint.Endpoint, zones map[int64]string) (zoneID int64, name string) { + var matchZoneID int64 + var matchZoneName string + for zoneID, zoneName := range zones { + if strings.HasSuffix(endpoint.DNSName, "."+zoneName) && len(zoneName) > len(matchZoneName) { + matchZoneName = zoneName + matchZoneID = zoneID + name = strings.TrimSuffix(endpoint.DNSName, "."+zoneName) + } + } + return matchZoneID, name +} diff --git a/provider/exoscale_test.go b/provider/exoscale_test.go new file mode 100644 index 000000000..4c0c5bcbd --- /dev/null +++ b/provider/exoscale_test.go @@ -0,0 +1,189 @@ +/* +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" + "testing" + + "github.com/exoscale/egoscale" + "github.com/kubernetes-incubator/external-dns/endpoint" + "github.com/kubernetes-incubator/external-dns/plan" + "github.com/stretchr/testify/assert" +) + +type createRecordExoscale struct { + name string + rec egoscale.DNSRecord +} + +type deleteRecordExoscale struct { + name string + recordID int64 +} + +type updateRecordExoscale struct { + name string + updateDNSRecord egoscale.UpdateDNSRecord +} + +var createExoscale []createRecordExoscale +var deleteExoscale []deleteRecordExoscale +var updateExoscale []updateRecordExoscale + +type ExoscaleClientStub struct { +} + +func NewExoscaleClientStub() EgoscaleClientI { + ep := &ExoscaleClientStub{} + return ep +} + +func (ep *ExoscaleClientStub) DeleteRecord(name string, recordID int64) error { + deleteExoscale = append(deleteExoscale, deleteRecordExoscale{name: name, recordID: recordID}) + return nil +} +func (ep *ExoscaleClientStub) GetRecords(name string) ([]egoscale.DNSRecord, error) { + init := []egoscale.DNSRecord{ + {ID: 0, Name: "v4.barfoo.com", RecordType: "ALIAS"}, + {ID: 1, Name: "v1.foo.com", RecordType: "TXT"}, + {ID: 2, Name: "v2.bar.com", RecordType: "A"}, + {ID: 3, Name: "v3.bar.com", RecordType: "ALIAS"}, + {ID: 4, Name: "v2.foo.com", RecordType: "CNAME"}, + {ID: 5, Name: "v1.foobar.com", RecordType: "TXT"}, + } + + rec := make([]egoscale.DNSRecord, 0) + for _, r := range init { + if strings.HasSuffix(r.Name, "."+name) { + r.Name = strings.TrimSuffix(r.Name, "."+name) + rec = append(rec, r) + } + } + + return rec, nil +} +func (ep *ExoscaleClientStub) UpdateRecord(name string, rec egoscale.UpdateDNSRecord) (*egoscale.DNSRecord, error) { + updateExoscale = append(updateExoscale, updateRecordExoscale{name: name, updateDNSRecord: rec}) + return nil, nil +} +func (ep *ExoscaleClientStub) CreateRecord(name string, rec egoscale.DNSRecord) (*egoscale.DNSRecord, error) { + createExoscale = append(createExoscale, createRecordExoscale{name: name, rec: rec}) + return nil, nil +} +func (ep *ExoscaleClientStub) GetDomains() ([]egoscale.DNSDomain, error) { + dom := []egoscale.DNSDomain{ + {ID: 1, Name: "foo.com"}, + {ID: 2, Name: "bar.com"}, + } + return dom, nil +} + +func contains(arr []*endpoint.Endpoint, name string) bool { + for _, a := range arr { + if a.DNSName == name { + return true + } + } + return false +} + +func TestExoscaleGetRecords(t *testing.T) { + provider := NewExoscaleProviderWithClient("", "", "", NewExoscaleClientStub(), false) + + if recs, err := provider.Records(); err == nil { + assert.Equal(t, 3, len(recs)) + assert.True(t, contains(recs, "v1.foo.com")) + assert.True(t, contains(recs, "v2.bar.com")) + assert.True(t, contains(recs, "v2.foo.com")) + assert.False(t, contains(recs, "v3.bar.com")) + assert.False(t, contains(recs, "v1.foobar.com")) + } else { + assert.Error(t, err) + } +} + +func TestExoscaleApplyChanges(t *testing.T) { + provider := NewExoscaleProviderWithClient("", "", "", NewExoscaleClientStub(), false) + + plan := &plan.Changes{ + Create: []*endpoint.Endpoint{ + { + DNSName: "v1.foo.com", + RecordType: "A", + Targets: []string{""}, + }, + { + DNSName: "v1.foobar.com", + RecordType: "TXT", + Targets: []string{""}, + }, + }, + Delete: []*endpoint.Endpoint{ + { + DNSName: "v1.foo.com", + RecordType: "A", + Targets: []string{""}, + }, + { + DNSName: "v1.foobar.com", + RecordType: "TXT", + Targets: []string{""}, + }, + }, + UpdateOld: []*endpoint.Endpoint{ + { + DNSName: "v1.foo.com", + RecordType: "A", + Targets: []string{""}, + }, + { + DNSName: "v1.foobar.com", + RecordType: "TXT", + Targets: []string{""}, + }, + }, + UpdateNew: []*endpoint.Endpoint{ + { + DNSName: "v1.foo.com", + RecordType: "A", + Targets: []string{""}, + }, + { + DNSName: "v1.foobar.com", + RecordType: "TXT", + Targets: []string{""}, + }, + }, + } + createExoscale = make([]createRecordExoscale, 0) + deleteExoscale = make([]deleteRecordExoscale, 0) + + provider.ApplyChanges(plan) + + assert.Equal(t, 1, len(createExoscale)) + assert.Equal(t, "foo.com", createExoscale[0].name) + assert.Equal(t, "v1", createExoscale[0].rec.Name) + + assert.Equal(t, 1, len(deleteExoscale)) + assert.Equal(t, "foo.com", deleteExoscale[0].name) + assert.Equal(t, int64(1), deleteExoscale[0].recordID) + + assert.Equal(t, 1, len(updateExoscale)) + assert.Equal(t, "foo.com", updateExoscale[0].name) + assert.Equal(t, int64(1), updateExoscale[0].updateDNSRecord.ID) +} diff --git a/provider/google.go b/provider/google.go index 017b22da4..47dab7334 100644 --- a/provider/google.go +++ b/provider/google.go @@ -143,10 +143,10 @@ func NewGoogleProvider(project string, domainFilter DomainFilter, zoneIDFilter Z } provider := &GoogleProvider{ - project: project, - domainFilter: domainFilter, - zoneIDFilter: zoneIDFilter, - dryRun: dryRun, + project: project, + domainFilter: domainFilter, + zoneIDFilter: zoneIDFilter, + dryRun: dryRun, resourceRecordSetsClient: resourceRecordSetsService{dnsClient.ResourceRecordSets}, managedZonesClient: managedZonesService{dnsClient.ManagedZones}, changesClient: changesService{dnsClient.Changes}, diff --git a/provider/google_test.go b/provider/google_test.go index 102debfbf..8791a1b9b 100644 --- a/provider/google_test.go +++ b/provider/google_test.go @@ -569,10 +569,10 @@ 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", - domainFilter: domainFilter, - zoneIDFilter: zoneIDFilter, - dryRun: false, + project: "zalando-external-dns-test", + domainFilter: domainFilter, + zoneIDFilter: zoneIDFilter, + dryRun: false, resourceRecordSetsClient: &mockResourceRecordSetsClient{}, managedZonesClient: &mockManagedZonesClient{}, changesClient: &mockChangesClient{}, diff --git a/provider/infoblox.go b/provider/infoblox.go index 8c2a6e682..0d93a8342 100644 --- a/provider/infoblox.go +++ b/provider/infoblox.go @@ -114,9 +114,9 @@ func (p *InfobloxProvider) Records() (endpoints []*endpoint.Endpoint, err error) } // Include Host records since they should be treated synonymously with A records - var resH []ibclient.RecordHost - objH := ibclient.NewRecordHost( - ibclient.RecordHost{ + var resH []ibclient.HostRecord + objH := ibclient.NewHostRecord( + ibclient.HostRecord{ Zone: zone.Fqdn, }, ) diff --git a/provider/infoblox_test.go b/provider/infoblox_test.go index 24619d0ef..6ae80eb92 100644 --- a/provider/infoblox_test.go +++ b/provider/infoblox_test.go @@ -62,18 +62,18 @@ func (client *mockIBConnector) CreateObject(obj ibclient.IBObject) (ref string, ref = fmt.Sprintf("%s/%s:%s/default", obj.ObjectType(), base64.StdEncoding.EncodeToString([]byte(obj.(*ibclient.RecordCNAME).Name)), obj.(*ibclient.RecordCNAME).Name) obj.(*ibclient.RecordCNAME).Ref = ref case "record:host": - for _, i := range obj.(*ibclient.RecordHost).Ipv4Addrs { + for _, i := range obj.(*ibclient.HostRecord).Ipv4Addrs { client.createdEndpoints = append( client.createdEndpoints, endpoint.NewEndpoint( - obj.(*ibclient.RecordHost).Name, + obj.(*ibclient.HostRecord).Name, endpoint.RecordTypeA, i.Ipv4Addr, ), ) } - ref = fmt.Sprintf("%s/%s:%s/default", obj.ObjectType(), base64.StdEncoding.EncodeToString([]byte(obj.(*ibclient.RecordHost).Name)), obj.(*ibclient.RecordHost).Name) - obj.(*ibclient.RecordHost).Ref = ref + ref = fmt.Sprintf("%s/%s:%s/default", obj.ObjectType(), base64.StdEncoding.EncodeToString([]byte(obj.(*ibclient.HostRecord).Name)), obj.(*ibclient.HostRecord).Name) + obj.(*ibclient.HostRecord).Ref = ref case "record:txt": client.createdEndpoints = append( client.createdEndpoints, @@ -128,21 +128,21 @@ func (client *mockIBConnector) GetObject(obj ibclient.IBObject, ref string, res } *res.(*[]ibclient.RecordCNAME) = result case "record:host": - var result []ibclient.RecordHost + var result []ibclient.HostRecord for _, object := range *client.mockInfobloxObjects { if object.ObjectType() == "record:host" { if ref != "" && - ref != object.(*ibclient.RecordHost).Ref { + ref != object.(*ibclient.HostRecord).Ref { continue } - if obj.(*ibclient.RecordHost).Name != "" && - obj.(*ibclient.RecordHost).Name != object.(*ibclient.RecordHost).Name { + if obj.(*ibclient.HostRecord).Name != "" && + obj.(*ibclient.HostRecord).Name != object.(*ibclient.HostRecord).Name { continue } - result = append(result, *object.(*ibclient.RecordHost)) + result = append(result, *object.(*ibclient.HostRecord)) } } - *res.(*[]ibclient.RecordHost) = result + *res.(*[]ibclient.HostRecord) = result case "record:txt": var result []ibclient.RecordTXT for _, object := range *client.mockInfobloxObjects { @@ -207,9 +207,9 @@ func (client *mockIBConnector) DeleteObject(ref string) (refRes string, err erro ) } case "record:host": - var records []ibclient.RecordHost - obj := ibclient.NewRecordHost( - ibclient.RecordHost{ + var records []ibclient.HostRecord + obj := ibclient.NewHostRecord( + ibclient.HostRecord{ Name: result[2], }, ) @@ -267,11 +267,11 @@ func (client *mockIBConnector) UpdateObject(obj ibclient.IBObject, ref string) ( ), ) case "record:host": - for _, i := range obj.(*ibclient.RecordHost).Ipv4Addrs { + for _, i := range obj.(*ibclient.HostRecord).Ipv4Addrs { client.updatedEndpoints = append( client.updatedEndpoints, endpoint.NewEndpoint( - obj.(*ibclient.RecordHost).Name, + obj.(*ibclient.HostRecord).Name, i.Ipv4Addr, endpoint.RecordTypeA, ), diff --git a/provider/oci.go b/provider/oci.go new file mode 100644 index 000000000..5ff1ba8f8 --- /dev/null +++ b/provider/oci.go @@ -0,0 +1,301 @@ +/* +Copyright 2018 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" + "io/ioutil" + "strings" + + "github.com/oracle/oci-go-sdk/common" + "github.com/oracle/oci-go-sdk/dns" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + yaml "gopkg.in/yaml.v2" + + "github.com/kubernetes-incubator/external-dns/endpoint" + "github.com/kubernetes-incubator/external-dns/plan" +) + +const ociRecordTTL = 300 + +// OCIAuthConfig holds connection parameters for the OCI API. +type OCIAuthConfig struct { + Region string `yaml:"region"` + TenancyID string `yaml:"tenancy"` + UserID string `yaml:"user"` + PrivateKey string `yaml:"key"` + Fingerprint string `yaml:"fingerprint"` + Passphrase string `yaml:"passphrase"` +} + +// OCIConfig holds the configuration for the OCI Provider. +type OCIConfig struct { + Auth OCIAuthConfig `yaml:"auth"` + CompartmentID string `yaml:"compartment"` +} + +// OCIProvider is an implementation of Provider for Oracle Cloud Infrastructure +// (OCI) DNS. +type OCIProvider struct { + client ociDNSClient + cfg OCIConfig + + domainFilter DomainFilter + zoneIDFilter ZoneIDFilter + dryRun bool +} + +// ociDNSClient is the subset of the OCI DNS API required by the OCI Provider. +type ociDNSClient interface { + ListZones(ctx context.Context, request dns.ListZonesRequest) (response dns.ListZonesResponse, err error) + GetZoneRecords(ctx context.Context, request dns.GetZoneRecordsRequest) (response dns.GetZoneRecordsResponse, err error) + PatchZoneRecords(ctx context.Context, request dns.PatchZoneRecordsRequest) (response dns.PatchZoneRecordsResponse, err error) +} + +// LoadOCIConfig reads and parses the OCI ExternalDNS config file at the given +// path. +func LoadOCIConfig(path string) (*OCIConfig, error) { + contents, err := ioutil.ReadFile(path) + if err != nil { + return nil, errors.Wrapf(err, "reading OCI config file %q", path) + } + + cfg := OCIConfig{} + if err := yaml.Unmarshal(contents, &cfg); err != nil { + return nil, errors.Wrapf(err, "parsing OCI config file %q", path) + } + return &cfg, nil +} + +// NewOCIProvider initialises a new OCI DNS based Provider. +func NewOCIProvider(cfg OCIConfig, domainFilter DomainFilter, zoneIDFilter ZoneIDFilter, dryRun bool) (*OCIProvider, error) { + var client ociDNSClient + client, err := dns.NewDnsClientWithConfigurationProvider(common.NewRawConfigurationProvider( + cfg.Auth.TenancyID, + cfg.Auth.UserID, + cfg.Auth.Region, + cfg.Auth.Fingerprint, + cfg.Auth.PrivateKey, + &cfg.Auth.Passphrase, + )) + if err != nil { + return nil, errors.Wrap(err, "initialising OCI DNS API client") + } + + return &OCIProvider{ + client: client, + cfg: cfg, + domainFilter: domainFilter, + zoneIDFilter: zoneIDFilter, + dryRun: dryRun, + }, nil +} + +func (p *OCIProvider) zones(ctx context.Context) (map[string]*dns.ZoneSummary, error) { + zones := make(map[string]*dns.ZoneSummary) + + log.Debugf("Matching zones against domain filters: %v", p.domainFilter.filters) + var page *string + for { + resp, err := p.client.ListZones(ctx, dns.ListZonesRequest{ + CompartmentId: &p.cfg.CompartmentID, + ZoneType: dns.ListZonesZoneTypePrimary, + Page: page, + }) + if err != nil { + return nil, errors.Wrapf(err, "listing zones in %q", p.cfg.CompartmentID) + } + + for _, zone := range resp.Items { + if p.domainFilter.Match(*zone.Name) && p.zoneIDFilter.Match(*zone.Id) { + zones[*zone.Name] = &zone + log.Debugf("Matched %q (%q)", *zone.Name, *zone.Id) + } else { + log.Debugf("Filtered %q (%q)", *zone.Name, *zone.Id) + } + } + + if page = resp.OpcNextPage; resp.OpcNextPage == nil { + break + } + } + + if len(zones) == 0 { + if p.domainFilter.IsConfigured() { + log.Warnf("No zones in compartment %q match domain filters %v", p.cfg.CompartmentID, p.domainFilter.filters) + } else { + log.Warnf("No zones found in compartment %q", p.cfg.CompartmentID) + } + } + + return zones, nil +} + +func (p *OCIProvider) newFilteredRecordOperations(endpoints []*endpoint.Endpoint, opType dns.RecordOperationOperationEnum) []dns.RecordOperation { + ops := []dns.RecordOperation{} + for _, endpoint := range endpoints { + if p.domainFilter.Match(endpoint.DNSName) { + ops = append(ops, newRecordOperation(endpoint, opType)) + } + } + return ops +} + +// Records returns the list of records in a given hosted zone. +func (p *OCIProvider) Records() ([]*endpoint.Endpoint, error) { + ctx := context.Background() + zones, err := p.zones(ctx) + if err != nil { + return nil, errors.Wrap(err, "getting zones") + } + + endpoints := []*endpoint.Endpoint{} + for _, zone := range zones { + var page *string + for { + resp, err := p.client.GetZoneRecords(ctx, dns.GetZoneRecordsRequest{ + ZoneNameOrId: zone.Id, + Page: page, + CompartmentId: &p.cfg.CompartmentID, + }) + if err != nil { + return nil, errors.Wrapf(err, "getting records for zone %q", *zone.Id) + } + + for _, record := range resp.Items { + if !supportedRecordType(*record.Rtype) { + continue + } + endpoints = append(endpoints, + endpoint.NewEndpointWithTTL( + *record.Domain, + *record.Rtype, + endpoint.TTL(*record.Ttl), + *record.Rdata, + ), + ) + } + + if page = resp.OpcNextPage; resp.OpcNextPage == nil { + break + } + } + } + + return endpoints, nil +} + +// ApplyChanges applies a given set of changes to a given zone. +func (p *OCIProvider) ApplyChanges(changes *plan.Changes) error { + log.Debugf("Processing chages: %+v", changes) + + ops := []dns.RecordOperation{} + ops = append(ops, p.newFilteredRecordOperations(changes.Create, dns.RecordOperationOperationAdd)...) + + ops = append(ops, p.newFilteredRecordOperations(changes.UpdateNew, dns.RecordOperationOperationAdd)...) + ops = append(ops, p.newFilteredRecordOperations(changes.UpdateOld, dns.RecordOperationOperationRemove)...) + + ops = append(ops, p.newFilteredRecordOperations(changes.Delete, dns.RecordOperationOperationRemove)...) + + if len(ops) == 0 { + log.Info("All records are already up to date") + return nil + } + + ctx := context.Background() + zones, err := p.zones(ctx) + if err != nil { + return errors.Wrap(err, "fetching zones") + } + + // Separate into per-zone change sets to be passed to OCI API. + opsByZone := operationsByZone(zones, ops) + for zoneID, ops := range opsByZone { + log.Infof("Change zone: %q", zoneID) + for _, op := range ops { + log.Info(op) + } + } + + if p.dryRun { + return nil + } + + for zoneID, ops := range opsByZone { + if _, err := p.client.PatchZoneRecords(ctx, dns.PatchZoneRecordsRequest{ + CompartmentId: &p.cfg.CompartmentID, + ZoneNameOrId: &zoneID, + PatchZoneRecordsDetails: dns.PatchZoneRecordsDetails{Items: ops}, + }); err != nil { + return err + } + } + + return nil +} + +// newRecordOperation returns a RecordOperation based on a given endpoint. +func newRecordOperation(ep *endpoint.Endpoint, opType dns.RecordOperationOperationEnum) dns.RecordOperation { + targets := make([]string, len(ep.Targets)) + copy(targets, []string(ep.Targets)) + if ep.RecordType == endpoint.RecordTypeCNAME { + targets[0] = ensureTrailingDot(targets[0]) + } + rdata := strings.Join(targets, " ") + + ttl := ociRecordTTL + if ep.RecordTTL.IsConfigured() { + ttl = int(ep.RecordTTL) + } + + return dns.RecordOperation{ + Domain: &ep.DNSName, + Rdata: &rdata, + Ttl: &ttl, + Rtype: &ep.RecordType, + Operation: opType, + } +} + +// operationsByZone segments a slice of RecordOperations by their zone. +func operationsByZone(zones map[string]*dns.ZoneSummary, ops []dns.RecordOperation) map[string][]dns.RecordOperation { + changes := make(map[string][]dns.RecordOperation) + + zoneNameIDMapper := zoneIDName{} + for _, z := range zones { + zoneNameIDMapper.Add(*z.Id, *z.Name) + changes[*z.Id] = []dns.RecordOperation{} + } + + for _, op := range ops { + if zoneID, _ := zoneNameIDMapper.FindZone(*op.Domain); zoneID != "" { + changes[zoneID] = append(changes[zoneID], op) + } else { + log.Warnf("No matching zone for record operation %s", op) + } + } + + // Remove zones that don't have have any changes. + for zone, ops := range changes { + if len(ops) == 0 { + delete(changes, zone) + } + } + + return changes +} diff --git a/provider/oci_test.go b/provider/oci_test.go new file mode 100644 index 000000000..89056812c --- /dev/null +++ b/provider/oci_test.go @@ -0,0 +1,839 @@ +/* +Copyright 2018 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" + "sort" + "testing" + + "github.com/oracle/oci-go-sdk/common" + "github.com/oracle/oci-go-sdk/dns" + "github.com/pkg/errors" + "github.com/stretchr/testify/require" + + "github.com/kubernetes-incubator/external-dns/endpoint" + "github.com/kubernetes-incubator/external-dns/plan" +) + +type mockOCIDNSClient struct{} + +func (c *mockOCIDNSClient) ListZones(ctx context.Context, request dns.ListZonesRequest) (response dns.ListZonesResponse, err error) { + if request.Page == nil || *request.Page == "0" { + return dns.ListZonesResponse{ + Items: []dns.ZoneSummary{ + { + Id: common.String("ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959"), + Name: common.String("foo.com"), + }, + }, + OpcNextPage: common.String("1"), + }, nil + } + return dns.ListZonesResponse{ + Items: []dns.ZoneSummary{ + { + Id: common.String("ocid1.dns-zone.oc1..502aeddba262b92fd13ed7874f6f1404"), + Name: common.String("bar.com"), + }, + }, + }, nil +} + +func (c *mockOCIDNSClient) GetZoneRecords(ctx context.Context, request dns.GetZoneRecordsRequest) (response dns.GetZoneRecordsResponse, err error) { + if request.ZoneNameOrId == nil { + return + } + + switch *request.ZoneNameOrId { + case "ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959": + if request.Page == nil || *request.Page == "0" { + response.Items = []dns.Record{{ + Domain: common.String("foo.foo.com"), + Rdata: common.String("127.0.0.1"), + Rtype: common.String(endpoint.RecordTypeA), + Ttl: common.Int(ociRecordTTL), + }, { + Domain: common.String("foo.foo.com"), + Rdata: common.String("heritage=external-dns,external-dns/owner=default,external-dns/resource=service/default/my-svc"), + Rtype: common.String(endpoint.RecordTypeTXT), + Ttl: common.Int(ociRecordTTL), + }} + response.OpcNextPage = common.String("1") + } else { + response.Items = []dns.Record{{Domain: common.String("bar.foo.com"), + Rdata: common.String("bar.com."), + Rtype: common.String(endpoint.RecordTypeCNAME), + Ttl: common.Int(ociRecordTTL), + }} + } + case "ocid1.dns-zone.oc1..502aeddba262b92fd13ed7874f6f1404": + if request.Page == nil || *request.Page == "0" { + response.Items = []dns.Record{{ + Domain: common.String("foo.bar.com"), + Rdata: common.String("127.0.0.1"), + Rtype: common.String(endpoint.RecordTypeA), + Ttl: common.Int(ociRecordTTL), + }} + } + } + + return +} + +func (c *mockOCIDNSClient) PatchZoneRecords(ctx context.Context, request dns.PatchZoneRecordsRequest) (response dns.PatchZoneRecordsResponse, err error) { + return // Provider does not use the response so nothing to do here. +} + +// newOCIProvider creates an OCI provider with API calls mocked out. +func newOCIProvider(client ociDNSClient, domainFilter DomainFilter, zoneIDFilter ZoneIDFilter, dryRun bool) *OCIProvider { + return &OCIProvider{ + client: client, + cfg: OCIConfig{ + CompartmentID: "ocid1.compartment.oc1..aaaaaaaaujjg4lf3v6uaqeml7xfk7stzvrxeweaeyolhh75exuoqxpqjb4qq", + }, + domainFilter: domainFilter, + zoneIDFilter: zoneIDFilter, + dryRun: dryRun, + } +} + +func validateOCIZones(t *testing.T, actual, expected map[string]*dns.ZoneSummary) { + require.Len(t, actual, len(expected)) + + for k, a := range actual { + e, ok := expected[k] + require.True(t, ok, "unexpected zone %q (%q)", *a.Name, *a.Id) + require.Equal(t, e, a) + } +} + +func TestNewOCIProvider(t *testing.T) { + testCases := map[string]struct { + config OCIConfig + err error + }{ + "valid": { + config: OCIConfig{ + Auth: OCIAuthConfig{ + TenancyID: "ocid1.tenancy.oc1..aaaaaaaaxf3fuazosc6xng7l75rj6uist5jb6ken64t3qltimxnkymddqbma", + UserID: "ocid1.user.oc1..aaaaaaaahx2vpvm4of5nqq3t274ike7ygyk2aexvokk3gyv4eyumzqajcrvq", + Region: "us-ashburn-1", + Fingerprint: "48:ba:d4:21:63:53:db:10:65:20:d4:09:ce:01:f5:97", + PrivateKey: `-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAv2JspZyO14kqcO/X4iz3ZdcyAf1GQJqYsBb6wyrlU0PB9Fee +H23/HLtMSqeqo+2KQHmdV1OHFQ/S6tx7zcBaby/+2b+z3/gJO4PGxohe2812AJ/J +W8Fp/4EnwbaRqDhoLN7ms0/e566zE3z40kCSW0NAIzv/F+0nNaka1xrypBqzvaNm +N49dAGvqWRpzFFUb8CbvKmgE6c/H4a2zVNW3G7/K6Og4HQGeEP3NKSVvi0BiQlvd +tVJTg7084kKcrngsS2N3qI3pzsr5wgpzPPefuPHWRKokZ20kpu8tXdFt+mAC2NHh +eWbtY3jsR6JFaXCyZLMXInwDvRgdP0T5+uh8WwIDAQABAoIBAG0rr94omDLKw7L4 +naUfEWC+iIAqAdEIXuDTuudpqLb+h7zh3gj/re6tyK8tRWGNNrfgp6gQtZWGGUJv +0w9jEjMqpa2AdRLlYh7Y5KKLV9D6Or3QaAQ3KEffXNZbVmsnAgXWgLL4dKakOPJ8 +71LAEryMeCGhL7puRVeOxwi9Dnwc4pcloimdggw/uwVHMK9eY5ylyt5ziiiWfhAo +cnNJNPHRSTqSiCoEhk/8BLZT5gxf1YX0hVSEdQh2WNyxmPmVSC9uuzKOqcEBfHf5 +hmLnsUET1REM9IxCLqC9ebW263lIO/KdGiCu+YgIdwIi3wrLhaKXAZQmp4oMvWlE +n5eYlcECgYEA5AhctPWCQBCJhcD39pSWgnSq1O9bt8yQi2P2stqlxKV9ZBepCK49 +OT42OYPUgWn7/y//6/LLzsPY58VTDHF3xZN1qu+fU0IM22D3Jqc19pnfVEb6TXSc +0jJIiaYCWTdqRQ4p2DuDcI+EzRB+V1Z7tFWxshZWXwNvtMXNoYPOYaUCgYEA1ttn +R3pCuGYJ5XbBwPzD5J+hvdZ6TQf8oTDraUBPxjtFOr7ea42T6KeYRFvnK2AQDnKL +Mw3I55lNO4I2W9gahUFG28dhxEuxeyvXGqXEJvPCUYePstab/BkUrm7/jkS3CLcJ +dlRXjqOfGwi5+NPUZMoOkZ54ZR4ZpdhIAeEpBf8CgYEAyMyMRlVCowNs9jkcoSfq ++Wme3O8BhvI9/mDCZnCfNHC94Bvtn1U/WF7uBOuPf35Ch05PQAiHa8WOBVn/bZ+l +ZngZT7K+S+SHyc6zFHh9zm9k96Og2f/r8DSTJ5Ll0oY3sCNuuZh+f+oBeUoi1umy ++PPVDAsbd4NhJIBiOO4GGHkCgYA1p4i9Es0Cm4ixItzzwqtwtmR/scXM4se1wS+o +kwTY7gg1yWBl328mVGPz/jdWX6Di2rvkPfcDzwa4a6YDfY3x5QE69Sl3CagCqEoJ +P4giahEGpyG9eVZuuBywCswKzSIgLQVR5XIQDtA2whEfEFcj7EmDF93c8o1ZGw+w +WHgUJQKBgEXr0HgxGG+v8bsXdrJ87Avx/nuA2rrFfECDPa4zuPkEK+cSFibdAq/H +u6OIV+z59AD2s84gxR+KLzEDfQAqBt7cVA5ZH6hrO+bkCtK9ycLL+koOuB+1EV+Y +hKRtDhmSdWBo3tJK12RrAe4t7CUe8gMgTvU7ExlcA3xQkseFPx9K +-----END RSA PRIVATE KEY----- +`, + }, + }, + }, + "invalid": { + config: OCIConfig{ + Auth: OCIAuthConfig{ + TenancyID: "ocid1.tenancy.oc1..aaaaaaaaxf3fuazosc6xng7l75rj6uist5jb6ken64t3qltimxnkymddqbma", + UserID: "ocid1.user.oc1..aaaaaaaahx2vpvm4of5nqq3t274ike7ygyk2aexvokk3gyv4eyumzqajcrvq", + Region: "us-ashburn-1", + Fingerprint: "48:ba:d4:21:63:53:db:10:65:20:d4:09:ce:01:f5:97", + PrivateKey: `-----BEGIN RSA PRIVATE KEY----- +`, + }, + }, + err: errors.New("initialising OCI DNS API client: can not create client, bad configuration: PEM data was not found in buffer"), + }, + } + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + _, err := NewOCIProvider( + tc.config, + NewDomainFilter([]string{"com"}), + NewZoneIDFilter([]string{""}), + false, + ) + if err == nil { + require.NoError(t, err) + } else { + require.Equal(t, tc.err.Error(), err.Error()) + } + }) + } +} + +func TestOCIZones(t *testing.T) { + testCases := []struct { + name string + domainFilter DomainFilter + zoneIDFilter ZoneIDFilter + expected map[string]*dns.ZoneSummary + }{ + { + name: "DomainFilter_com", + domainFilter: NewDomainFilter([]string{"com"}), + zoneIDFilter: NewZoneIDFilter([]string{""}), + expected: map[string]*dns.ZoneSummary{ + "foo.com": { + Id: common.String("ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959"), + Name: common.String("foo.com"), + }, + "bar.com": { + Id: common.String("ocid1.dns-zone.oc1..502aeddba262b92fd13ed7874f6f1404"), + Name: common.String("bar.com"), + }, + }, + }, { + name: "DomainFilter_foo.com", + domainFilter: NewDomainFilter([]string{"foo.com"}), + zoneIDFilter: NewZoneIDFilter([]string{""}), + expected: map[string]*dns.ZoneSummary{ + "foo.com": { + Id: common.String("ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959"), + Name: common.String("foo.com"), + }, + }, + }, { + name: "ZoneIDFilter_ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959", + domainFilter: NewDomainFilter([]string{""}), + zoneIDFilter: NewZoneIDFilter([]string{"ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959"}), + expected: map[string]*dns.ZoneSummary{ + "foo.com": { + Id: common.String("ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959"), + Name: common.String("foo.com"), + }, + }, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + provider := newOCIProvider(&mockOCIDNSClient{}, tc.domainFilter, tc.zoneIDFilter, false) + zones, err := provider.zones(context.Background()) + require.NoError(t, err) + validateOCIZones(t, zones, tc.expected) + }) + } +} + +func TestOCIRecords(t *testing.T) { + testCases := []struct { + name string + domainFilter DomainFilter + zoneIDFilter ZoneIDFilter + expected []*endpoint.Endpoint + }{ + { + name: "unfiltered", + domainFilter: NewDomainFilter([]string{""}), + zoneIDFilter: NewZoneIDFilter([]string{""}), + expected: []*endpoint.Endpoint{ + endpoint.NewEndpointWithTTL("foo.foo.com", endpoint.RecordTypeA, endpoint.TTL(ociRecordTTL), "127.0.0.1"), + endpoint.NewEndpointWithTTL("foo.foo.com", endpoint.RecordTypeTXT, endpoint.TTL(ociRecordTTL), "heritage=external-dns,external-dns/owner=default,external-dns/resource=service/default/my-svc"), + endpoint.NewEndpointWithTTL("bar.foo.com", endpoint.RecordTypeCNAME, endpoint.TTL(ociRecordTTL), "bar.com."), + endpoint.NewEndpointWithTTL("foo.bar.com", endpoint.RecordTypeA, endpoint.TTL(ociRecordTTL), "127.0.0.1"), + }, + }, { + name: "DomainFilter_foo.com", + domainFilter: NewDomainFilter([]string{"foo.com"}), + zoneIDFilter: NewZoneIDFilter([]string{""}), + expected: []*endpoint.Endpoint{ + endpoint.NewEndpointWithTTL("foo.foo.com", endpoint.RecordTypeA, endpoint.TTL(ociRecordTTL), "127.0.0.1"), + endpoint.NewEndpointWithTTL("foo.foo.com", endpoint.RecordTypeTXT, endpoint.TTL(ociRecordTTL), "heritage=external-dns,external-dns/owner=default,external-dns/resource=service/default/my-svc"), + endpoint.NewEndpointWithTTL("bar.foo.com", endpoint.RecordTypeCNAME, endpoint.TTL(ociRecordTTL), "bar.com."), + }, + }, { + name: "ZoneIDFilter_ocid1.dns-zone.oc1..502aeddba262b92fd13ed7874f6f1404", + domainFilter: NewDomainFilter([]string{""}), + zoneIDFilter: NewZoneIDFilter([]string{"ocid1.dns-zone.oc1..502aeddba262b92fd13ed7874f6f1404"}), + expected: []*endpoint.Endpoint{ + endpoint.NewEndpointWithTTL("foo.bar.com", endpoint.RecordTypeA, endpoint.TTL(ociRecordTTL), "127.0.0.1"), + }, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + provider := newOCIProvider(&mockOCIDNSClient{}, tc.domainFilter, tc.zoneIDFilter, false) + endpoints, err := provider.Records() + require.NoError(t, err) + require.ElementsMatch(t, tc.expected, endpoints) + }) + } +} + +func TestNewRecordOperation(t *testing.T) { + testCases := []struct { + name string + ep *endpoint.Endpoint + opType dns.RecordOperationOperationEnum + expected dns.RecordOperation + }{ + { + name: "A_record", + opType: dns.RecordOperationOperationAdd, + ep: endpoint.NewEndpointWithTTL( + "foo.foo.com", + endpoint.RecordTypeA, + endpoint.TTL(ociRecordTTL), + "127.0.0.1"), + expected: dns.RecordOperation{ + Domain: common.String("foo.foo.com"), + Rdata: common.String("127.0.0.1"), + Rtype: common.String("A"), + Ttl: common.Int(300), + Operation: dns.RecordOperationOperationAdd, + }, + }, { + name: "TXT_record", + opType: dns.RecordOperationOperationAdd, + ep: endpoint.NewEndpointWithTTL( + "foo.foo.com", + endpoint.RecordTypeTXT, + endpoint.TTL(ociRecordTTL), + "heritage=external-dns,external-dns/owner=default,external-dns/resource=service/default/my-svc"), + expected: dns.RecordOperation{ + Domain: common.String("foo.foo.com"), + Rdata: common.String("heritage=external-dns,external-dns/owner=default,external-dns/resource=service/default/my-svc"), + Rtype: common.String("TXT"), + Ttl: common.Int(300), + Operation: dns.RecordOperationOperationAdd, + }, + }, { + name: "CNAME_record", + opType: dns.RecordOperationOperationAdd, + ep: endpoint.NewEndpointWithTTL( + "foo.foo.com", + endpoint.RecordTypeCNAME, + endpoint.TTL(ociRecordTTL), + "bar.com."), + expected: dns.RecordOperation{ + Domain: common.String("foo.foo.com"), + Rdata: common.String("bar.com."), + Rtype: common.String("CNAME"), + Ttl: common.Int(300), + Operation: dns.RecordOperationOperationAdd, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + op := newRecordOperation(tc.ep, tc.opType) + require.Equal(t, tc.expected, op) + }) + } +} + +func TestOperationsByZone(t *testing.T) { + testCases := []struct { + name string + zones map[string]*dns.ZoneSummary + ops []dns.RecordOperation + expected map[string][]dns.RecordOperation + }{ + { + name: "basic", + zones: map[string]*dns.ZoneSummary{ + "foo": { + Id: common.String("foo"), + Name: common.String("foo.com"), + }, + "bar": { + Id: common.String("bar"), + Name: common.String("bar.com"), + }, + }, + ops: []dns.RecordOperation{ + { + Domain: common.String("foo.foo.com"), + Rdata: common.String("127.0.0.1"), + Rtype: common.String("A"), + Ttl: common.Int(300), + Operation: dns.RecordOperationOperationAdd, + }, + { + Domain: common.String("foo.bar.com"), + Rdata: common.String("127.0.0.1"), + Rtype: common.String("A"), + Ttl: common.Int(300), + Operation: dns.RecordOperationOperationAdd, + }, + }, + expected: map[string][]dns.RecordOperation{ + "foo": { + { + Domain: common.String("foo.foo.com"), + Rdata: common.String("127.0.0.1"), + Rtype: common.String("A"), + Ttl: common.Int(300), + Operation: dns.RecordOperationOperationAdd, + }, + }, + "bar": { + { + Domain: common.String("foo.bar.com"), + Rdata: common.String("127.0.0.1"), + Rtype: common.String("A"), + Ttl: common.Int(300), + Operation: dns.RecordOperationOperationAdd, + }, + }, + }, + }, { + name: "does_not_include_zones_with_no_changes", + zones: map[string]*dns.ZoneSummary{ + "foo": { + Id: common.String("foo"), + Name: common.String("foo.com"), + }, + "bar": { + Id: common.String("bar"), + Name: common.String("bar.com"), + }, + }, + ops: []dns.RecordOperation{ + { + Domain: common.String("foo.foo.com"), + Rdata: common.String("127.0.0.1"), + Rtype: common.String("A"), + Ttl: common.Int(300), + Operation: dns.RecordOperationOperationAdd, + }, + }, + expected: map[string][]dns.RecordOperation{ + "foo": { + { + Domain: common.String("foo.foo.com"), + Rdata: common.String("127.0.0.1"), + Rtype: common.String("A"), + Ttl: common.Int(300), + Operation: dns.RecordOperationOperationAdd, + }, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := operationsByZone(tc.zones, tc.ops) + require.Equal(t, tc.expected, result) + }) + } +} + +type mutableMockOCIDNSClient struct { + zones map[string]dns.ZoneSummary + records map[string]map[string]dns.Record +} + +func newMutableMockOCIDNSClient(zones []dns.ZoneSummary, recordsByZone map[string][]dns.Record) *mutableMockOCIDNSClient { + c := &mutableMockOCIDNSClient{ + zones: make(map[string]dns.ZoneSummary), + records: make(map[string]map[string]dns.Record), + } + + for _, zone := range zones { + c.zones[*zone.Id] = zone + c.records[*zone.Id] = make(map[string]dns.Record) + } + + for zoneID, records := range recordsByZone { + for _, record := range records { + c.records[zoneID][ociRecordKey(*record.Rtype, *record.Domain)] = record + } + } + + return c +} + +func (c *mutableMockOCIDNSClient) ListZones(ctx context.Context, request dns.ListZonesRequest) (response dns.ListZonesResponse, err error) { + var zones []dns.ZoneSummary + for _, v := range c.zones { + zones = append(zones, v) + } + return dns.ListZonesResponse{Items: zones}, nil +} + +func (c *mutableMockOCIDNSClient) GetZoneRecords(ctx context.Context, request dns.GetZoneRecordsRequest) (response dns.GetZoneRecordsResponse, err error) { + if request.ZoneNameOrId == nil { + err = errors.New("no name or id") + return + } + + records, ok := c.records[*request.ZoneNameOrId] + if !ok { + err = errors.New("zone not found") + return + } + + var items []dns.Record + for _, v := range records { + items = append(items, v) + } + + response.Items = items + return +} + +func ociRecordKey(rType, domain string) string { + return rType + "/" + domain +} + +func (c *mutableMockOCIDNSClient) PatchZoneRecords(ctx context.Context, request dns.PatchZoneRecordsRequest) (response dns.PatchZoneRecordsResponse, err error) { + if request.ZoneNameOrId == nil { + err = errors.New("no name or id") + return + } + + records, ok := c.records[*request.ZoneNameOrId] + if !ok { + err = errors.New("zone not found") + return + } + + // Ensure that ADD operations occur after REMOVE. + sort.Slice(request.Items, func(i, j int) bool { + return request.Items[i].Operation > request.Items[j].Operation + }) + + for _, op := range request.Items { + k := ociRecordKey(*op.Rtype, *op.Domain) + switch op.Operation { + case dns.RecordOperationOperationAdd: + records[k] = dns.Record{ + Domain: op.Domain, + Rtype: op.Rtype, + Rdata: op.Rdata, + Ttl: op.Ttl, + } + case dns.RecordOperationOperationRemove: + delete(records, k) + default: + err = errors.Errorf("unsupported operation %q", op.Operation) + return + } + } + return +} + +// TestMutableMockOCIDNSClient exists because one must always test one's tests +// right...? +func TestMutableMockOCIDNSClient(t *testing.T) { + zones := []dns.ZoneSummary{{ + Id: common.String("ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959"), + Name: common.String("foo.com"), + }} + records := map[string][]dns.Record{ + "ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959": {{ + Domain: common.String("foo.foo.com"), + Rdata: common.String("127.0.0.1"), + Rtype: common.String(endpoint.RecordTypeA), + Ttl: common.Int(ociRecordTTL), + }, { + Domain: common.String("foo.foo.com"), + Rdata: common.String("heritage=external-dns,external-dns/owner=default,external-dns/resource=service/default/my-svc"), + Rtype: common.String(endpoint.RecordTypeTXT), + Ttl: common.Int(ociRecordTTL), + }}, + } + client := newMutableMockOCIDNSClient(zones, records) + + // First ListZones. + zonesResponse, err := client.ListZones(context.Background(), dns.ListZonesRequest{}) + require.NoError(t, err) + require.Len(t, zonesResponse.Items, 1) + require.Equal(t, zonesResponse.Items, zones) + + // GetZoneRecords for that zone. + recordsResponse, err := client.GetZoneRecords(context.Background(), dns.GetZoneRecordsRequest{ + ZoneNameOrId: zones[0].Id, + }) + require.NoError(t, err) + require.Len(t, recordsResponse.Items, 2) + require.ElementsMatch(t, recordsResponse.Items, records["ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959"]) + + // Remove the A record. + _, err = client.PatchZoneRecords(context.Background(), dns.PatchZoneRecordsRequest{ + ZoneNameOrId: common.String("ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959"), + PatchZoneRecordsDetails: dns.PatchZoneRecordsDetails{ + Items: []dns.RecordOperation{{ + Domain: common.String("foo.foo.com"), + Rdata: common.String("127.0.0.1"), + Rtype: common.String("A"), + Ttl: common.Int(300), + Operation: dns.RecordOperationOperationRemove, + }}, + }, + }) + require.NoError(t, err) + + // GetZoneRecords again and check the A record was removed. + recordsResponse, err = client.GetZoneRecords(context.Background(), dns.GetZoneRecordsRequest{ + ZoneNameOrId: zones[0].Id, + }) + require.NoError(t, err) + require.Len(t, recordsResponse.Items, 1) + require.Equal(t, recordsResponse.Items[0], records["ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959"][1]) + + // Add the A record back. + _, err = client.PatchZoneRecords(context.Background(), dns.PatchZoneRecordsRequest{ + ZoneNameOrId: common.String("ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959"), + PatchZoneRecordsDetails: dns.PatchZoneRecordsDetails{ + Items: []dns.RecordOperation{{ + Domain: common.String("foo.foo.com"), + Rdata: common.String("127.0.0.1"), + Rtype: common.String("A"), + Ttl: common.Int(300), + Operation: dns.RecordOperationOperationAdd, + }}, + }, + }) + require.NoError(t, err) + + // GetZoneRecords and check we're back in the origional state + recordsResponse, err = client.GetZoneRecords(context.Background(), dns.GetZoneRecordsRequest{ + ZoneNameOrId: zones[0].Id, + }) + require.NoError(t, err) + require.Len(t, recordsResponse.Items, 2) + require.ElementsMatch(t, recordsResponse.Items, records["ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959"]) +} + +func TestOCIApplyChanges(t *testing.T) { + testCases := []struct { + name string + zones []dns.ZoneSummary + records map[string][]dns.Record + changes *plan.Changes + dryRun bool + err error + expectedEndpoints []*endpoint.Endpoint + }{ + { + name: "add", + zones: []dns.ZoneSummary{{ + Id: common.String("ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959"), + Name: common.String("foo.com"), + }}, + changes: &plan.Changes{ + Create: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL( + "foo.foo.com", + endpoint.RecordTypeA, + endpoint.TTL(ociRecordTTL), + "127.0.0.1", + )}, + }, + expectedEndpoints: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL( + "foo.foo.com", + endpoint.RecordTypeA, + endpoint.TTL(ociRecordTTL), + "127.0.0.1", + )}, + }, { + name: "remove", + zones: []dns.ZoneSummary{{ + Id: common.String("ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959"), + Name: common.String("foo.com"), + }}, + records: map[string][]dns.Record{ + "ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959": {{ + Domain: common.String("foo.foo.com"), + Rdata: common.String("127.0.0.1"), + Rtype: common.String(endpoint.RecordTypeA), + Ttl: common.Int(ociRecordTTL), + }, { + Domain: common.String("foo.foo.com"), + Rdata: common.String("heritage=external-dns,external-dns/owner=default,external-dns/resource=service/default/my-svc"), + Rtype: common.String(endpoint.RecordTypeTXT), + Ttl: common.Int(ociRecordTTL), + }}, + }, + changes: &plan.Changes{ + Delete: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL( + "foo.foo.com", + endpoint.RecordTypeTXT, + endpoint.TTL(ociRecordTTL), + "127.0.0.1", + )}, + }, + expectedEndpoints: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL( + "foo.foo.com", + endpoint.RecordTypeA, + endpoint.TTL(ociRecordTTL), + "127.0.0.1", + )}, + }, { + name: "update", + zones: []dns.ZoneSummary{{ + Id: common.String("ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959"), + Name: common.String("foo.com"), + }}, + records: map[string][]dns.Record{ + "ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959": {{ + Domain: common.String("foo.foo.com"), + Rdata: common.String("127.0.0.1"), + Rtype: common.String(endpoint.RecordTypeA), + Ttl: common.Int(ociRecordTTL), + }}, + }, + changes: &plan.Changes{ + UpdateOld: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL( + "foo.foo.com", + endpoint.RecordTypeA, + endpoint.TTL(ociRecordTTL), + "127.0.0.1", + )}, + UpdateNew: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL( + "foo.foo.com", + endpoint.RecordTypeA, + endpoint.TTL(ociRecordTTL), + "10.0.0.1", + )}, + }, + expectedEndpoints: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL( + "foo.foo.com", + endpoint.RecordTypeA, + endpoint.TTL(ociRecordTTL), + "10.0.0.1", + )}, + }, { + name: "dry_run_no_changes", + zones: []dns.ZoneSummary{{ + Id: common.String("ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959"), + Name: common.String("foo.com"), + }}, + records: map[string][]dns.Record{ + "ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959": {{ + Domain: common.String("foo.foo.com"), + Rdata: common.String("127.0.0.1"), + Rtype: common.String(endpoint.RecordTypeA), + Ttl: common.Int(ociRecordTTL), + }}, + }, + changes: &plan.Changes{ + Delete: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL( + "foo.foo.com", + endpoint.RecordTypeA, + endpoint.TTL(ociRecordTTL), + "127.0.0.1", + )}, + }, + dryRun: true, + expectedEndpoints: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL( + "foo.foo.com", + endpoint.RecordTypeA, + endpoint.TTL(ociRecordTTL), + "127.0.0.1", + )}, + }, { + name: "add_remove_update", + zones: []dns.ZoneSummary{{ + Id: common.String("ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959"), + Name: common.String("foo.com"), + }}, + records: map[string][]dns.Record{ + "ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959": {{ + Domain: common.String("foo.foo.com"), + Rdata: common.String("127.0.0.1"), + Rtype: common.String(endpoint.RecordTypeA), + Ttl: common.Int(ociRecordTTL), + }, { + Domain: common.String("bar.foo.com"), + Rdata: common.String("bar.com."), + Rtype: common.String(endpoint.RecordTypeCNAME), + Ttl: common.Int(ociRecordTTL), + }}, + }, + changes: &plan.Changes{ + Delete: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL( + "foo.foo.com", + endpoint.RecordTypeA, + endpoint.TTL(ociRecordTTL), + "baz.com.", + )}, + UpdateOld: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL( + "bar.foo.com", + endpoint.RecordTypeCNAME, + endpoint.TTL(ociRecordTTL), + "baz.com.", + )}, + UpdateNew: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL( + "bar.foo.com", + endpoint.RecordTypeCNAME, + endpoint.TTL(ociRecordTTL), + "foo.bar.com.", + )}, + Create: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL( + "baz.foo.com", + endpoint.RecordTypeA, + endpoint.TTL(ociRecordTTL), + "127.0.0.1", + )}, + }, + expectedEndpoints: []*endpoint.Endpoint{ + endpoint.NewEndpointWithTTL( + "bar.foo.com", + endpoint.RecordTypeCNAME, + endpoint.TTL(ociRecordTTL), + "foo.bar.com.", + ), + endpoint.NewEndpointWithTTL( + "baz.foo.com", + endpoint.RecordTypeA, + endpoint.TTL(ociRecordTTL), + "127.0.0.1"), + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + client := newMutableMockOCIDNSClient(tc.zones, tc.records) + provider := newOCIProvider( + client, + NewDomainFilter([]string{""}), + NewZoneIDFilter([]string{""}), + tc.dryRun, + ) + err := provider.ApplyChanges(tc.changes) + require.Equal(t, tc.err, err) + endpoints, err := provider.Records() + require.NoError(t, err) + require.ElementsMatch(t, tc.expectedEndpoints, endpoints) + }) + } +} diff --git a/provider/pdns.go b/provider/pdns.go index 04fa234c5..d560c38c6 100644 --- a/provider/pdns.go +++ b/provider/pdns.go @@ -29,9 +29,12 @@ import ( log "github.com/sirupsen/logrus" + "crypto/tls" 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 @@ -57,6 +60,60 @@ const ( retryAfterTime = 250 * time.Millisecond ) +// PDNSConfig is comprised of the fields necessary to create a new PDNSProvider +type PDNSConfig struct { + DomainFilter DomainFilter + DryRun bool + Server string + APIKey string + TLSConfig TLSConfig +} + +// TLSConfig is comprised of the TLS-related fields necessary to create a new PDNSProvider +type TLSConfig struct { + TLSEnabled bool + CAFilePath string + ClientCertFilePath string + ClientCertKeyFilePath string +} + +func (tlsConfig *TLSConfig) setHTTPClient(pdnsClientConfig *pgo.Configuration) error { + if !tlsConfig.TLSEnabled { + log.Debug("Skipping TLS for PDNS Provider.") + return nil + } + + log.Debug("Configuring TLS for PDNS Provider.") + if tlsConfig.CAFilePath == "" { + return errors.New("certificate authority file path must be specified if TLS is enabled") + } + + tlsClientConfig, err := tlsutils.NewTLSConfig(tlsConfig.ClientCertFilePath, tlsConfig.ClientCertKeyFilePath, tlsConfig.CAFilePath, "", false, tls.VersionTLS12) + if err != nil { + return err + } + + // Timeouts taken from net.http.DefaultTransport + transporter := &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + DualStack: true, + }).DialContext, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + TLSClientConfig: tlsClientConfig, + } + pdnsClientConfig.HTTPClient = &http.Client{ + Transport: transporter, + } + + return nil +} + // Function for debug printing func stringifyHTTPResponseBody(r *http.Response) (body string) { @@ -151,37 +208,40 @@ type PDNSProvider struct { } // NewPDNSProvider initializes a new PowerDNS based Provider. -func NewPDNSProvider(server string, apikey string, domainFilter DomainFilter, dryRun bool) (*PDNSProvider, error) { +func NewPDNSProvider(config PDNSConfig) (*PDNSProvider, error) { // Do some input validation - if apikey == "" { + if config.APIKey == "" { return nil, errors.New("Missing API Key for PDNS. Specify using --pdns-api-key=") } // The default for when no --domain-filter is passed is [""], instead of [], so we check accordingly. - if len(domainFilter.filters) != 1 && domainFilter.filters[0] != "" { + if len(config.DomainFilter.filters) != 1 && config.DomainFilter.filters[0] != "" { return nil, errors.New("PDNS Provider does not support domain filter") } // We do not support dry running, exit safely instead of surprising the user // TODO: Add Dry Run support - if dryRun { + if config.DryRun { return nil, errors.New("PDNS Provider does not currently support dry-run") } - if server == "localhost" { + if config.Server == "localhost" { log.Warnf("PDNS Server is set to localhost, this may not be what you want. Specify using --pdns-server=") } - cfg := pgo.NewConfiguration() - cfg.Host = server - cfg.BasePath = server + apiBase + pdnsClientConfig := pgo.NewConfiguration() + pdnsClientConfig.Host = config.Server + pdnsClientConfig.BasePath = config.Server + apiBase + if err := config.TLSConfig.setHTTPClient(pdnsClientConfig); err != nil { + return nil, err + } provider := &PDNSProvider{ client: &PDNSAPIClient{ - dryRun: dryRun, - authCtx: context.WithValue(context.TODO(), pgo.ContextAPIKey, pgo.APIKey{Key: apikey}), - client: pgo.NewAPIClient(cfg), + dryRun: config.DryRun, + authCtx: context.WithValue(context.TODO(), pgo.ContextAPIKey, pgo.APIKey{Key: config.APIKey}), + client: pgo.NewAPIClient(pdnsClientConfig), }, } diff --git a/provider/pdns_test.go b/provider/pdns_test.go index 9572040c8..0351d3e06 100644 --- a/provider/pdns_test.go +++ b/provider/pdns_test.go @@ -476,22 +476,128 @@ type NewPDNSProviderTestSuite struct { } func (suite *NewPDNSProviderTestSuite) TestPDNSProviderCreate() { - // Function definition: NewPDNSProvider(server string, apikey string, domainFilter DomainFilter, dryRun bool) (*PDNSProvider, error) - _, err := NewPDNSProvider("http://localhost:8081", "", NewDomainFilter([]string{""}), false) + _, err := NewPDNSProvider(PDNSConfig{ + Server: "http://localhost:8081", + DomainFilter: NewDomainFilter([]string{""}), + }) assert.Error(suite.T(), err, "--pdns-api-key should be specified") - _, err = NewPDNSProvider("http://localhost:8081", "foo", NewDomainFilter([]string{"example.com", "example.org"}), false) + _, err = NewPDNSProvider(PDNSConfig{ + Server: "http://localhost:8081", + APIKey: "foo", + DomainFilter: NewDomainFilter([]string{"example.com", "example.org"}), + }) assert.Error(suite.T(), err, "--domainfilter should raise an error") - _, err = NewPDNSProvider("http://localhost:8081", "foo", NewDomainFilter([]string{""}), true) + _, err = NewPDNSProvider(PDNSConfig{ + Server: "http://localhost:8081", + APIKey: "foo", + DomainFilter: NewDomainFilter([]string{""}), + DryRun: true, + }) assert.Error(suite.T(), err, "--dry-run should raise an error") // This is our "regular" code path, no error should be thrown - _, err = NewPDNSProvider("http://localhost:8081", "foo", NewDomainFilter([]string{""}), false) + _, err = NewPDNSProvider(PDNSConfig{ + Server: "http://localhost:8081", + APIKey: "foo", + DomainFilter: NewDomainFilter([]string{""}), + }) assert.Nil(suite.T(), err, "Regular case should raise no error") } +func (suite *NewPDNSProviderTestSuite) TestPDNSProviderCreateTLS() { + + _, err := NewPDNSProvider(PDNSConfig{ + Server: "http://localhost:8081", + APIKey: "foo", + DomainFilter: NewDomainFilter([]string{""}), + }) + assert.Nil(suite.T(), err, "Omitted TLS Config case should raise no error") + + _, err = NewPDNSProvider(PDNSConfig{ + Server: "http://localhost:8081", + APIKey: "foo", + DomainFilter: NewDomainFilter([]string{""}), + TLSConfig: TLSConfig{ + TLSEnabled: false, + }, + }) + assert.Nil(suite.T(), err, "Disabled TLS Config should raise no error") + + _, err = NewPDNSProvider(PDNSConfig{ + Server: "http://localhost:8081", + APIKey: "foo", + DomainFilter: NewDomainFilter([]string{""}), + TLSConfig: TLSConfig{ + TLSEnabled: false, + CAFilePath: "/path/to/ca.crt", + ClientCertFilePath: "/path/to/cert.pem", + ClientCertKeyFilePath: "/path/to/cert-key.pem", + }, + }) + assert.Nil(suite.T(), err, "Disabled TLS Config with additional flags should raise no error") + + _, err = NewPDNSProvider(PDNSConfig{ + Server: "http://localhost:8081", + APIKey: "foo", + DomainFilter: NewDomainFilter([]string{""}), + TLSConfig: TLSConfig{ + TLSEnabled: true, + }, + }) + assert.Error(suite.T(), err, "Enabled TLS Config without --tls-ca should raise an error") + + _, err = NewPDNSProvider(PDNSConfig{ + Server: "http://localhost:8081", + APIKey: "foo", + DomainFilter: NewDomainFilter([]string{""}), + TLSConfig: TLSConfig{ + TLSEnabled: true, + CAFilePath: "../internal/testresources/ca.pem", + }, + }) + assert.Nil(suite.T(), err, "Enabled TLS Config with --tls-ca should raise no error") + + _, err = NewPDNSProvider(PDNSConfig{ + Server: "http://localhost:8081", + APIKey: "foo", + DomainFilter: NewDomainFilter([]string{""}), + TLSConfig: TLSConfig{ + TLSEnabled: true, + CAFilePath: "../internal/testresources/ca.pem", + ClientCertFilePath: "../internal/testresources/client-cert.pem", + }, + }) + assert.Error(suite.T(), err, "Enabled TLS Config with --tls-client-cert only should raise an error") + + _, err = NewPDNSProvider(PDNSConfig{ + Server: "http://localhost:8081", + APIKey: "foo", + DomainFilter: NewDomainFilter([]string{""}), + TLSConfig: TLSConfig{ + TLSEnabled: true, + CAFilePath: "../internal/testresources/ca.pem", + ClientCertKeyFilePath: "../internal/testresources/client-cert-key.pem", + }, + }) + assert.Error(suite.T(), err, "Enabled TLS Config with --tls-client-cert-key only should raise an error") + + _, err = NewPDNSProvider(PDNSConfig{ + Server: "http://localhost:8081", + APIKey: "foo", + DomainFilter: NewDomainFilter([]string{""}), + TLSConfig: TLSConfig{ + TLSEnabled: true, + CAFilePath: "../internal/testresources/ca.pem", + ClientCertFilePath: "../internal/testresources/client-cert.pem", + ClientCertKeyFilePath: "../internal/testresources/client-cert-key.pem", + }, + }) + assert.Nil(suite.T(), err, "Enabled TLS Config with all flags should raise no error") +} + func (suite *NewPDNSProviderTestSuite) TestPDNSRRSetToEndpoints() { // Function definition: convertRRSetToEndpoints(rr pgo.RrSet) (endpoints []*endpoint.Endpoint, _ error) diff --git a/provider/recordfilter.go b/provider/recordfilter.go index ca757b6db..8b5c31178 100644 --- a/provider/recordfilter.go +++ b/provider/recordfilter.go @@ -17,10 +17,10 @@ limitations under the License. package provider // supportedRecordType returns true only for supported record types. -// Currently only A, CNAME and TXT record types are supported. +// Currently A, CNAME, SRV, and TXT record types are supported. func supportedRecordType(recordType string) bool { switch recordType { - case "A", "CNAME", "TXT": + case "A", "CNAME", "SRV", "TXT": return true default: return false diff --git a/registry/txt.go b/registry/txt.go index d66d82dd8..a75b4afaa 100644 --- a/registry/txt.go +++ b/registry/txt.go @@ -18,12 +18,14 @@ package registry import ( "errors" + "time" "strings" "github.com/kubernetes-incubator/external-dns/endpoint" "github.com/kubernetes-incubator/external-dns/plan" "github.com/kubernetes-incubator/external-dns/provider" + log "github.com/sirupsen/logrus" ) // TXTRegistry implements registry interface with ownership implemented via associated TXT records @@ -31,10 +33,15 @@ type TXTRegistry struct { provider provider.Provider ownerID string //refers to the owner id of the current instance mapper nameMapper + + // cache the records in memory and update on an interval instead. + recordsCache []*endpoint.Endpoint + recordsCacheRefreshTime time.Time + cacheInterval time.Duration } // NewTXTRegistry returns new TXTRegistry object -func NewTXTRegistry(provider provider.Provider, txtPrefix, ownerID string) (*TXTRegistry, error) { +func NewTXTRegistry(provider provider.Provider, txtPrefix, ownerID string, cacheInterval time.Duration) (*TXTRegistry, error) { if ownerID == "" { return nil, errors.New("owner id cannot be empty") } @@ -42,9 +49,10 @@ func NewTXTRegistry(provider provider.Provider, txtPrefix, ownerID string) (*TXT mapper := newPrefixNameMapper(txtPrefix) return &TXTRegistry{ - provider: provider, - ownerID: ownerID, - mapper: mapper, + provider: provider, + ownerID: ownerID, + mapper: mapper, + cacheInterval: cacheInterval, }, nil } @@ -52,6 +60,13 @@ func NewTXTRegistry(provider provider.Provider, txtPrefix, ownerID string) (*TXT // If TXT records was created previously to indicate ownership its corresponding value // will be added to the endpoints Labels map func (im *TXTRegistry) Records() ([]*endpoint.Endpoint, error) { + // If we have the zones cached AND we have refreshed the cache since the + // last given interval, then just use the cached results. + if im.recordsCache != nil && time.Since(im.recordsCacheRefreshTime) < im.cacheInterval { + log.Debug("Using cached records.") + return im.recordsCache, nil + } + records, err := im.provider.Records() if err != nil { return nil, err @@ -91,6 +106,12 @@ func (im *TXTRegistry) Records() ([]*endpoint.Endpoint, error) { } } + // Update the cache. + if im.cacheInterval > 0 { + im.recordsCache = endpoints + im.recordsCacheRefreshTime = time.Now() + } + return endpoints, nil } @@ -107,6 +128,10 @@ func (im *TXTRegistry) ApplyChanges(changes *plan.Changes) error { r.Labels[endpoint.OwnerLabelKey] = im.ownerID txt := endpoint.NewEndpoint(im.mapper.toTXTName(r.DNSName), endpoint.RecordTypeTXT, r.Labels.Serialize(true)) filteredChanges.Create = append(filteredChanges.Create, txt) + + if im.cacheInterval > 0 { + im.addToCache(r) + } } for _, r := range filteredChanges.Delete { @@ -115,19 +140,32 @@ func (im *TXTRegistry) ApplyChanges(changes *plan.Changes) error { // when we delete TXT records for which value has changed (due to new label) this would still work because // !!! TXT record value is uniquely generated from the Labels of the endpoint. Hence old TXT record can be uniquely reconstructed filteredChanges.Delete = append(filteredChanges.Delete, txt) + + if im.cacheInterval > 0 { + im.removeFromCache(r) + } } - // make sure TXT records are consistently updated as well - for _, r := range filteredChanges.UpdateNew { - txt := endpoint.NewEndpoint(im.mapper.toTXTName(r.DNSName), endpoint.RecordTypeTXT, r.Labels.Serialize(true)) - filteredChanges.UpdateNew = append(filteredChanges.UpdateNew, txt) - } // make sure TXT records are consistently updated as well for _, r := range filteredChanges.UpdateOld { txt := endpoint.NewEndpoint(im.mapper.toTXTName(r.DNSName), endpoint.RecordTypeTXT, r.Labels.Serialize(true)) // when we updateOld TXT records for which value has changed (due to new label) this would still work because // !!! TXT record value is uniquely generated from the Labels of the endpoint. Hence old TXT record can be uniquely reconstructed filteredChanges.UpdateOld = append(filteredChanges.UpdateOld, txt) + // remove old version of record from cache + if im.cacheInterval > 0 { + im.removeFromCache(r) + } + } + + // make sure TXT records are consistently updated as well + for _, r := range filteredChanges.UpdateNew { + txt := endpoint.NewEndpoint(im.mapper.toTXTName(r.DNSName), endpoint.RecordTypeTXT, r.Labels.Serialize(true)) + filteredChanges.UpdateNew = append(filteredChanges.UpdateNew, txt) + // add new version of record to cache + if im.cacheInterval > 0 { + im.addToCache(r) + } } return im.provider.ApplyChanges(filteredChanges) @@ -167,3 +205,24 @@ func (pr prefixNameMapper) toEndpointName(txtDNSName string) string { func (pr prefixNameMapper) toTXTName(endpointDNSName string) string { return pr.prefix + endpointDNSName } + +func (im *TXTRegistry) addToCache(ep *endpoint.Endpoint) { + if im.recordsCache != nil { + im.recordsCache = append(im.recordsCache, ep) + } +} + +func (im *TXTRegistry) removeFromCache(ep *endpoint.Endpoint) { + if im.recordsCache == nil || ep == nil { + // return early. + return + } + + for i, e := range im.recordsCache { + if e.DNSName == ep.DNSName && e.RecordType == ep.RecordType && e.Targets.Same(ep.Targets) { + // We found a match delete the endpoint from the cache. + im.recordsCache = append(im.recordsCache[:i], im.recordsCache[i+1:]...) + return + } + } +} diff --git a/registry/txt_test.go b/registry/txt_test.go index 2b711db7e..6ead32165 100644 --- a/registry/txt_test.go +++ b/registry/txt_test.go @@ -17,7 +17,9 @@ limitations under the License. package registry import ( + "reflect" "testing" + "time" "github.com/kubernetes-incubator/external-dns/endpoint" "github.com/kubernetes-incubator/external-dns/internal/testutils" @@ -40,10 +42,10 @@ func TestTXTRegistry(t *testing.T) { func testTXTRegistryNew(t *testing.T) { p := provider.NewInMemoryProvider() - _, err := NewTXTRegistry(p, "txt", "") + _, err := NewTXTRegistry(p, "txt", "", time.Hour) require.Error(t, err) - r, err := NewTXTRegistry(p, "txt", "owner") + r, err := NewTXTRegistry(p, "txt", "owner", time.Hour) require.NoError(t, err) _, ok := r.mapper.(prefixNameMapper) @@ -51,7 +53,7 @@ func testTXTRegistryNew(t *testing.T) { assert.Equal(t, "owner", r.ownerID) assert.Equal(t, p, r.provider) - r, err = NewTXTRegistry(p, "", "owner") + r, err = NewTXTRegistry(p, "", "owner", time.Hour) require.NoError(t, err) _, ok = r.mapper.(prefixNameMapper) @@ -130,7 +132,7 @@ func testTXTRegistryRecordsPrefixed(t *testing.T) { }, } - r, _ := NewTXTRegistry(p, "txt.", "owner") + r, _ := NewTXTRegistry(p, "txt.", "owner", time.Hour) records, _ := r.Records() assert.True(t, testutils.SameEndpoints(records, expectedRecords)) @@ -204,7 +206,7 @@ func testTXTRegistryRecordsNoPrefix(t *testing.T) { }, } - r, _ := NewTXTRegistry(p, "", "owner") + r, _ := NewTXTRegistry(p, "", "owner", time.Hour) records, _ := r.Records() assert.True(t, testutils.SameEndpoints(records, expectedRecords)) @@ -231,7 +233,7 @@ func testTXTRegistryApplyChangesWithPrefix(t *testing.T) { newEndpointWithOwner("txt.foobar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), }, }) - r, _ := NewTXTRegistry(p, "txt.", "owner") + r, _ := NewTXTRegistry(p, "txt.", "owner", time.Hour) changes := &plan.Changes{ Create: []*endpoint.Endpoint{ @@ -300,7 +302,7 @@ func testTXTRegistryApplyChangesNoPrefix(t *testing.T) { newEndpointWithOwner("foobar.test-zone.example.org", "\"heritage=external-dns,external-dns/owner=owner\"", endpoint.RecordTypeTXT, ""), }, }) - r, _ := NewTXTRegistry(p, "", "owner") + r, _ := NewTXTRegistry(p, "", "owner", time.Hour) changes := &plan.Changes{ Create: []*endpoint.Endpoint{ @@ -347,6 +349,67 @@ func testTXTRegistryApplyChangesNoPrefix(t *testing.T) { require.NoError(t, err) } +func TestCacheMethods(t *testing.T) { + cache := []*endpoint.Endpoint{ + newEndpointWithOwner("thing.com", "1.2.3.4", "A", "owner"), + newEndpointWithOwner("thing1.com", "1.2.3.6", "A", "owner"), + newEndpointWithOwner("thing2.com", "1.2.3.4", "CNAME", "owner"), + newEndpointWithOwner("thing3.com", "1.2.3.4", "A", "owner"), + newEndpointWithOwner("thing4.com", "1.2.3.4", "A", "owner"), + } + registry := &TXTRegistry{ + recordsCache: cache, + cacheInterval: time.Hour, + } + + expectedCacheAfterAdd := []*endpoint.Endpoint{ + newEndpointWithOwner("thing.com", "1.2.3.4", "A", "owner"), + newEndpointWithOwner("thing1.com", "1.2.3.6", "A", "owner"), + newEndpointWithOwner("thing2.com", "1.2.3.4", "CNAME", "owner"), + newEndpointWithOwner("thing3.com", "1.2.3.4", "A", "owner"), + newEndpointWithOwner("thing4.com", "1.2.3.4", "A", "owner"), + newEndpointWithOwner("thing5.com", "1.2.3.5", "A", "owner"), + } + + expectedCacheAfterUpdate := []*endpoint.Endpoint{ + newEndpointWithOwner("thing1.com", "1.2.3.6", "A", "owner"), + newEndpointWithOwner("thing2.com", "1.2.3.4", "CNAME", "owner"), + newEndpointWithOwner("thing3.com", "1.2.3.4", "A", "owner"), + newEndpointWithOwner("thing4.com", "1.2.3.4", "A", "owner"), + newEndpointWithOwner("thing5.com", "1.2.3.5", "A", "owner"), + newEndpointWithOwner("thing.com", "1.2.3.6", "A", "owner2"), + } + + expectedCacheAfterDelete := []*endpoint.Endpoint{ + newEndpointWithOwner("thing1.com", "1.2.3.6", "A", "owner"), + newEndpointWithOwner("thing2.com", "1.2.3.4", "CNAME", "owner"), + newEndpointWithOwner("thing3.com", "1.2.3.4", "A", "owner"), + newEndpointWithOwner("thing4.com", "1.2.3.4", "A", "owner"), + newEndpointWithOwner("thing5.com", "1.2.3.5", "A", "owner"), + } + // test add cache + registry.addToCache(newEndpointWithOwner("thing5.com", "1.2.3.5", "A", "owner")) + + if !reflect.DeepEqual(expectedCacheAfterAdd, registry.recordsCache) { + t.Fatalf("expected endpoints should match endpoints from cache: expected %v, but got %v", expectedCacheAfterAdd, registry.recordsCache) + } + + // test update cache + registry.removeFromCache(newEndpointWithOwner("thing.com", "1.2.3.4", "A", "owner")) + registry.addToCache(newEndpointWithOwner("thing.com", "1.2.3.6", "A", "owner2")) + // ensure it was updated + if !reflect.DeepEqual(expectedCacheAfterUpdate, registry.recordsCache) { + t.Fatalf("expected endpoints should match endpoints from cache: expected %v, but got %v", expectedCacheAfterUpdate, registry.recordsCache) + } + + // test deleting a record + registry.removeFromCache(newEndpointWithOwner("thing.com", "1.2.3.6", "A", "owner2")) + // ensure it was deleted + if !reflect.DeepEqual(expectedCacheAfterDelete, registry.recordsCache) { + t.Fatalf("expected endpoints should match endpoints from cache: expected %v, but got %v", expectedCacheAfterDelete, registry.recordsCache) + } +} + /** helper methods diff --git a/source/ingress.go b/source/ingress.go index 2fe82e8b3..902eed7ee 100644 --- a/source/ingress.go +++ b/source/ingress.go @@ -233,6 +233,15 @@ func endpointsFromIngress(ing *v1beta1.Ingress) []*endpoint.Endpoint { endpoints = append(endpoints, endpointsForHostname(rule.Host, targets, ttl)...) } + for _, tls := range ing.Spec.TLS { + for _, host := range tls.Hosts { + if host == "" { + continue + } + endpoints = append(endpoints, endpointsForHostname(host, targets, ttl)...) + } + } + hostnameList := getHostnamesFromAnnotations(ing.Annotations) for _, hostname := range hostnameList { endpoints = append(endpoints, endpointsForHostname(hostname, targets, ttl)...) diff --git a/source/ingress_test.go b/source/ingress_test.go index 359c6560f..3c6772130 100644 --- a/source/ingress_test.go +++ b/source/ingress_test.go @@ -615,6 +615,83 @@ func testIngressEndpoints(t *testing.T) { }, }, }, + { + title: "ingress rules with single tls having single hostname", + targetNamespace: "", + ingressItems: []fakeIngress{ + { + name: "fake1", + namespace: namespace, + tlsdnsnames: [][]string{{"example.org"}}, + ips: []string{"1.2.3.4"}, + }, + }, + expected: []*endpoint.Endpoint{ + { + DNSName: "example.org", + Targets: endpoint.Targets{"1.2.3.4"}, + RecordType: endpoint.RecordTypeA, + }, + }, + }, + { + title: "ingress rules with single tls having multiple hostnames", + targetNamespace: "", + ingressItems: []fakeIngress{ + { + name: "fake1", + namespace: namespace, + tlsdnsnames: [][]string{{"example.org", "example2.org"}}, + ips: []string{"1.2.3.4"}, + }, + }, + expected: []*endpoint.Endpoint{ + { + DNSName: "example.org", + Targets: endpoint.Targets{"1.2.3.4"}, + RecordType: endpoint.RecordTypeA, + }, + { + DNSName: "example2.org", + Targets: endpoint.Targets{"1.2.3.4"}, + RecordType: endpoint.RecordTypeA, + }, + }, + }, + { + title: "ingress rules with multiple tls having multiple hostnames", + targetNamespace: "", + ingressItems: []fakeIngress{ + { + name: "fake1", + namespace: namespace, + tlsdnsnames: [][]string{{"example.org", "example2.org"}, {"example3.org", "example4.org"}}, + ips: []string{"1.2.3.4"}, + }, + }, + expected: []*endpoint.Endpoint{ + { + DNSName: "example.org", + Targets: endpoint.Targets{"1.2.3.4"}, + RecordType: endpoint.RecordTypeA, + }, + { + DNSName: "example2.org", + Targets: endpoint.Targets{"1.2.3.4"}, + RecordType: endpoint.RecordTypeA, + }, + { + DNSName: "example3.org", + Targets: endpoint.Targets{"1.2.3.4"}, + RecordType: endpoint.RecordTypeA, + }, + { + DNSName: "example4.org", + Targets: endpoint.Targets{"1.2.3.4"}, + RecordType: endpoint.RecordTypeA, + }, + }, + }, { title: "ingress rules with hostname annotation", targetNamespace: "", @@ -828,6 +905,7 @@ func testIngressEndpoints(t *testing.T) { // ingress specific helper functions type fakeIngress struct { dnsnames []string + tlsdnsnames [][]string ips []string hostnames []string namespace string @@ -856,6 +934,11 @@ func (ing fakeIngress) Ingress() *v1beta1.Ingress { Host: dnsname, }) } + for _, hosts := range ing.tlsdnsnames { + ingress.Spec.TLS = append(ingress.Spec.TLS, v1beta1.IngressTLS{ + Hosts: hosts, + }) + } for _, ip := range ing.ips { ingress.Status.LoadBalancer.Ingress = append(ingress.Status.LoadBalancer.Ingress, v1.LoadBalancerIngress{ IP: ip, diff --git a/source/service.go b/source/service.go index ce4710371..825797632 100644 --- a/source/service.go +++ b/source/service.go @@ -90,6 +90,12 @@ func (sc *serviceSource) Endpoints() ([]*endpoint.Endpoint, error) { return nil, err } + // get the ip addresses of all the nodes and cache them for this run + nodeTargets, err := sc.extractNodeTargets() + if err != nil { + return nil, err + } + endpoints := []*endpoint.Endpoint{} for _, svc := range services.Items { @@ -101,7 +107,7 @@ func (sc *serviceSource) Endpoints() ([]*endpoint.Endpoint, error) { continue } - svcEndpoints := sc.endpoints(&svc) + svcEndpoints := sc.endpoints(&svc, nodeTargets) // process legacy annotations if no endpoints were returned and compatibility mode is enabled. if len(svcEndpoints) == 0 && sc.compatibility != "" { @@ -110,7 +116,7 @@ func (sc *serviceSource) Endpoints() ([]*endpoint.Endpoint, error) { // apply template if none of the above is found if (sc.combineFQDNAnnotation || len(svcEndpoints) == 0) && sc.fqdnTemplate != nil { - sEndpoints, err := sc.endpointsFromTemplate(&svc) + sEndpoints, err := sc.endpointsFromTemplate(&svc, nodeTargets) if err != nil { return nil, err } @@ -169,7 +175,8 @@ func (sc *serviceSource) extractHeadlessEndpoints(svc *v1.Service, hostname stri return endpoints } -func (sc *serviceSource) endpointsFromTemplate(svc *v1.Service) ([]*endpoint.Endpoint, error) { + +func (sc *serviceSource) endpointsFromTemplate(svc *v1.Service, nodeTargets endpoint.Targets) ([]*endpoint.Endpoint, error) { var endpoints []*endpoint.Endpoint // Process the whole template string @@ -181,19 +188,19 @@ func (sc *serviceSource) endpointsFromTemplate(svc *v1.Service) ([]*endpoint.End hostnameList := strings.Split(strings.Replace(buf.String(), " ", "", -1), ",") for _, hostname := range hostnameList { - endpoints = append(endpoints, sc.generateEndpoints(svc, hostname)...) + endpoints = append(endpoints, sc.generateEndpoints(svc, hostname, nodeTargets)...) } return endpoints, nil } // endpointsFromService extracts the endpoints from a service object -func (sc *serviceSource) endpoints(svc *v1.Service) []*endpoint.Endpoint { +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)...) + endpoints = append(endpoints, sc.generateEndpoints(svc, hostname, nodeTargets)...) } return endpoints @@ -236,7 +243,7 @@ func (sc *serviceSource) setResourceLabel(service v1.Service, endpoints []*endpo } } -func (sc *serviceSource) generateEndpoints(svc *v1.Service, hostname string) []*endpoint.Endpoint { +func (sc *serviceSource) generateEndpoints(svc *v1.Service, hostname string, nodeTargets endpoint.Targets) []*endpoint.Endpoint { hostname = strings.TrimSuffix(hostname, ".") ttl, err := getTTLFromAnnotations(svc.Annotations) if err != nil { @@ -272,7 +279,10 @@ func (sc *serviceSource) generateEndpoints(svc *v1.Service, hostname string) []* if svc.Spec.ClusterIP == v1.ClusterIPNone { endpoints = append(endpoints, sc.extractHeadlessEndpoints(svc, hostname, ttl)...) } - + case v1.ServiceTypeNodePort: + // add the nodeTargets and extract an SRV endpoint + targets = append(targets, nodeTargets...) + endpoints = append(endpoints, sc.extractNodePortEndpoints(svc, nodeTargets, hostname, ttl)...) } for _, t := range targets { @@ -316,3 +326,68 @@ func extractLoadBalancerTargets(svc *v1.Service) endpoint.Targets { return targets } + +func (sc *serviceSource) extractNodeTargets() (endpoint.Targets, error) { + var ( + internalIPs endpoint.Targets + externalIPs endpoint.Targets + ) + + nodes, err := sc.client.CoreV1().Nodes().List(metav1.ListOptions{}) + if err != nil { + return nil, err + } + + for _, node := range nodes.Items { + for _, address := range node.Status.Addresses { + switch address.Type { + case v1.NodeExternalIP: + externalIPs = append(externalIPs, address.Address) + case v1.NodeInternalIP: + internalIPs = append(internalIPs, address.Address) + } + } + } + + if len(externalIPs) > 0 { + return externalIPs, nil + } + + return internalIPs, nil +} + +func (sc *serviceSource) extractNodePortEndpoints(svc *v1.Service, nodeTargets endpoint.Targets, hostname string, ttl endpoint.TTL) []*endpoint.Endpoint { + var endpoints []*endpoint.Endpoint + + for _, port := range svc.Spec.Ports { + if port.NodePort > 0 { + // build a target with a priority of 0, weight of 0, and pointing the given port on the given host + target := fmt.Sprintf("0 50 %d %s", port.NodePort, hostname) + + // figure out the portname + portName := port.Name + if portName == "" { + portName = fmt.Sprintf("%d", port.NodePort) + } + + // figure out the protocol + protocol := strings.ToLower(string(port.Protocol)) + if protocol == "" { + protocol = "tcp" + } + + recordName := fmt.Sprintf("_%s._%s.%s", portName, protocol, hostname) + + var ep *endpoint.Endpoint + if ttl.IsConfigured() { + ep = endpoint.NewEndpointWithTTL(recordName, endpoint.RecordTypeSRV, ttl, target) + } else { + ep = endpoint.NewEndpoint(recordName, endpoint.RecordTypeSRV, target) + } + + endpoints = append(endpoints, ep) + } + } + + return endpoints +} diff --git a/source/service_test.go b/source/service_test.go index 47f68b4c7..8ce4db13a 100644 --- a/source/service_test.go +++ b/source/service_test.go @@ -1022,6 +1022,201 @@ 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 + }{ + { + "annotated NodePort services return an endpoint with IP addresses of the cluster's nodes", + "", + "", + "testing", + "foo", + v1.ServiceTypeNodePort, + "", + "", + map[string]string{}, + map[string]string{ + hostnameAnnotationKey: "foo.example.org.", + }, + nil, + []*endpoint.Endpoint{ + {DNSName: "_30192._tcp.foo.example.org", Targets: endpoint.Targets{"0 50 30192 foo.example.org"}, RecordType: endpoint.RecordTypeSRV}, + {DNSName: "foo.example.org", Targets: endpoint.Targets{"54.10.11.1", "54.10.11.2"}, RecordType: endpoint.RecordTypeA}, + }, + 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", + "", + "", + "testing", + "foo", + v1.ServiceTypeNodePort, + "", + "{{.Name}}.bar.example.com", + map[string]string{}, + map[string]string{}, + nil, + []*endpoint.Endpoint{ + {DNSName: "_30192._tcp.foo.bar.example.com", Targets: endpoint.Targets{"0 50 30192 foo.bar.example.com"}, RecordType: endpoint.RecordTypeSRV}, + {DNSName: "foo.bar.example.com", Targets: endpoint.Targets{"54.10.11.1", "54.10.11.2"}, RecordType: endpoint.RecordTypeA}, + }, + 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"}, + }, + }, + }}, + }, + { + "annotated NodePort services return an endpoint with IP addresses of the private cluster's nodes", + "", + "", + "testing", + "foo", + v1.ServiceTypeNodePort, + "", + "", + map[string]string{}, + map[string]string{ + hostnameAnnotationKey: "foo.example.org.", + }, + nil, + []*endpoint.Endpoint{ + {DNSName: "_30192._tcp.foo.example.org", Targets: endpoint.Targets{"0 50 30192 foo.example.org"}, RecordType: endpoint.RecordTypeSRV}, + {DNSName: "foo.example.org", Targets: endpoint.Targets{"10.0.1.1", "10.0.1.2"}, RecordType: endpoint.RecordTypeA}, + }, + false, + []*v1.Node{{ + ObjectMeta: metav1.ObjectMeta{ + Name: "node1", + }, + Status: v1.NodeStatus{ + Addresses: []v1.NodeAddress{ + {Type: v1.NodeInternalIP, Address: "10.0.1.1"}, + }, + }, + }, { + ObjectMeta: metav1.ObjectMeta{ + Name: "node2", + }, + Status: v1.NodeStatus{ + Addresses: []v1.NodeAddress{ + {Type: v1.NodeInternalIP, Address: "10.0.1.2"}, + }, + }, + }}, + }, + } { + t.Run(tc.title, func(t *testing.T) { + // Create a Kubernetes testing client + kubernetes := fake.NewSimpleClientset() + + // Create the nodes + for _, node := range tc.nodes { + if _, err := kubernetes.Core().Nodes().Create(node); err != nil { + t.Fatal(err) + } + } + + // Create a service to test against + service := &v1.Service{ + Spec: v1.ServiceSpec{ + Type: tc.svcType, + Ports: []v1.ServicePort{ + { + NodePort: 30192, + }, + }, + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: tc.svcNamespace, + Name: tc.svcName, + Labels: tc.labels, + Annotations: tc.annotations, + }, + } + + _, err := kubernetes.CoreV1().Services(service.Namespace).Create(service) + require.NoError(t, err) + + // Create our object under test and get the endpoints. + client, _ := NewServiceSource( + kubernetes, + tc.targetNamespace, + tc.annotationFilter, + tc.fqdnTemplate, + false, + tc.compatibility, + true, + ) + require.NoError(t, err) + + endpoints, err := client.Endpoints() + if tc.expectError { + require.Error(t, err) + } else { + require.NoError(t, err) + } + + // Validate returned endpoints against desired endpoints. + validateEndpoints(t, endpoints, tc.expected) + }) + } +} + // TestHeadlessServices tests that headless services generate the correct endpoints. func TestHeadlessServices(t *testing.T) { for _, tc := range []struct {