Merge branch 'master' into txtRecordHandling

This commit is contained in:
Martin Linkhorst 2018-07-23 13:52:22 +02:00 committed by GitHub
commit b5b8d618e9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
58 changed files with 4091 additions and 356 deletions

View File

@ -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)

View File

@ -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

69
Gopkg.lock generated
View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -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"

View File

@ -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: ...
```

View File

@ -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

View File

@ -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
```
```

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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.

View File

@ -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.
Ingress resources and external-dns will delete the records.

158
docs/tutorials/exoscale.md Normal file
View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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

155
docs/tutorials/oracle.md Normal file
View File

@ -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

View File

@ -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

View File

@ -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
```

View File

@ -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

View File

@ -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-----

View File

@ -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-----

View File

@ -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-----

38
main.go
View File

@ -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:

View File

@ -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)

View File

@ -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"))
}

View File

@ -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,

View File

@ -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))
}

View File

@ -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)
}
}

View File

@ -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)) {

View File

@ -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{

View File

@ -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.

View File

@ -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")
}
}

398
provider/coredns.go Normal file
View File

@ -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]
}
}

316
provider/coredns_test.go Normal file
View File

@ -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
}
}
}

View File

@ -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,

View File

@ -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)

View File

@ -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

255
provider/exoscale.go Normal file
View File

@ -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
}

189
provider/exoscale_test.go Normal file
View File

@ -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)
}

View File

@ -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},

View File

@ -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{},

View File

@ -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,
},
)

View File

@ -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,
),

301
provider/oci.go Normal file
View File

@ -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
}

839
provider/oci_test.go Normal file
View File

@ -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)
})
}
}

View File

@ -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),
},
}

View File

@ -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)

View File

@ -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

View File

@ -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
}
}
}

View File

@ -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

View File

@ -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)...)

View File

@ -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,

View File

@ -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
}

View File

@ -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 {