Merge branch 'master' into fix-1906

This commit is contained in:
Rob Selway 2021-02-14 14:29:01 +00:00
commit 469f07eb8b
44 changed files with 3902 additions and 1013 deletions

View File

@ -16,6 +16,8 @@
- Fix NodePort with externaltrafficpolicy targets duplication @codearky
- Update contributing section in README (#1760) @seanmalloy
- Option to cache AWS zones list @bpineau
- Refactor, enhance and test Akamai provider and documentation (#1846) @edglynes
- Fix: only use absolute CNAMEs in Scaleway provider (#1859) @Sh4d1
## v0.7.3 - 2020-08-05

View File

@ -48,6 +48,8 @@ ExternalDNS' current release is `v0.7`. This version allows you to keep selected
* [Vultr](https://www.vultr.com)
* [OVH](https://www.ovh.com)
* [Scaleway](https://www.scaleway.com)
* [Akamai Edge DNS](https://learn.akamai.com/en-us/products/cloud_security/edge_dns.html)
* [GoDaddy](https://www.godaddy.com)
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.
@ -78,6 +80,7 @@ The following table clarifies the current status of the providers according to t
| Google Cloud DNS | Stable | |
| AWS Route 53 | Stable | |
| AWS Cloud Map | Beta | |
| Akamai Edge DNS | Beta | |
| AzureDNS | Beta | |
| CloudFlare | Beta | |
| RcodeZero | Alpha | |
@ -97,11 +100,11 @@ The following table clarifies the current status of the providers according to t
| TransIP | Alpha | |
| VinylDNS | Alpha | |
| RancherDNS | Alpha | |
| Akamai FastDNS | Alpha | |
| OVH | Alpha | |
| Scaleway DNS | Alpha | @Sh4d1 |
| Vultr | Alpha | |
| UltraDNS | Alpha | |
| GoDaddy | Alpha | |
## Running ExternalDNS:
@ -154,6 +157,7 @@ The following tutorials are provided:
* [Scaleway](docs/tutorials/scaleway.md)
* [Vultr](docs/tutorials/vultr.md)
* [UltraDNS](docs/tutorials/ultradns.md)
* [GoDaddy](docs/tutorials/godaddy.md)
### Running Locally

View File

@ -117,6 +117,8 @@ type Controller struct {
nextRunAt time.Time
// The nextRunAtMux is for atomic updating of nextRunAt
nextRunAtMux sync.Mutex
// DNS record types that will be considered for management
ManagedRecordTypes []string
}
// RunOnce runs a single iteration of a reconciliation loop.
@ -147,6 +149,7 @@ func (c *Controller) RunOnce(ctx context.Context) error {
Desired: endpoints,
DomainFilter: c.DomainFilter,
PropertyComparator: c.Registry.PropertyValuesEqual,
ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},
}
plan = plan.Calculate()
@ -165,7 +168,7 @@ func (c *Controller) RunOnce(ctx context.Context) error {
// MinInterval is used as window for batching events
const MinInterval = 5 * time.Second
// RunOnceThrottled makes sure execution happens at most once per interval.
// ScheduleRunOnce makes sure execution happens at most once per interval.
func (c *Controller) ScheduleRunOnce(now time.Time) {
c.nextRunAtMux.Lock()
defer c.nextRunAtMux.Unlock()

View File

@ -0,0 +1,251 @@
# Setting up External-DNS for Services on Akamai Edge DNS
## Prerequisites
Akamai Edge DNS (formally known as Fast DNS) provider support was first released in External-DNS v0.5.18
### Zones
External-DNS manages service endpoints in existing DNS zones. The Akamai provider does not add, remove or configure new zones in anyway. Edge DNS zones can be created and managed thru the [Akamai Control Center](https://control.akamai.com) or [Akamai DevOps Tools](https://developer.akamai.com/devops), [Akamai CLI](https://developer.akamai.com/cli) and [Akamai Terraform Provider](https://developer.akamai.com/tools/integrations/terraform)
### Akamai Edge DNS Authentication
The Akamai Edge DNS provider requires valid Akamai Edgegrid API authentication credentials to access zones and manage associated DNS records.
Credentials can be provided to the provider either directly by key or indirectly via a file. The Akamai credential keys and mappings to the Akamai provider utilizing different presentation methods are:
| Edgegrid Auth Key | External-DNS Cmd Line Key | Environment/ConfigMap Key | Description |
| ----------------- | ------------------------- | ------------------------- | ----------- |
| host | akamai-serviceconsumerdomain | EXTERNAL_DNS_AKAMAI_SERVICECONSUMERDOMAIN | Akamai Edgegrid API server |
| access_token | akamai-access-token | EXTERNAL_DNS_AKAMAI_ACCESS_TOKEN | Akamai Edgegrid API access token |
| client_token | akamai-client-token | EXTERNAL_DNS_AKAMAI_CLIENT_TOKEN |Akamai Edgegrid API client token |
| client-secret | akamai-client-secret | EXTERNAL_DNS_AKAMAI_CLIENT_SECRET |Akamai Edgegrid API client secret |
In addition to specifying auth credentials individually, the credentials may be referenced indirectly by using the Akamai Edgegrid .edgerc file convention.
| External-DNS Cmd Line | Environment/ConfigMap | Description |
| --------------------- | --------------------- | ----------- |
| akamai-edgerc-path | EXTERNAL_DNS_AKAMAI_EDGERC_PATH | Accessible path to Edgegrid credentials file, e.g /home/test/.edgerc |
| akamai-edgerc-section | EXTERNAL_DNS_AKAMAI_EDGERC_SECTION | Section in Edgegrid credentials file containing credentials |
Note: akamai-edgerc-path and akamai-edgerc-section are present in External-DNS versions after v0.7.5
[Akamai API Authentication](https://developer.akamai.com/getting-started/edgegrid) provides an overview and further information pertaining to the generation of auth credentials for API base applications and tools.
The following example defines and references a Kubernetes ConfigMap secret, applied by referencing the secret and its keys in the env section of the deployment.
## Deploy External-DNS
An operational External-DNS deployment consists of an External-DNS container and service. The following sections demonstrate the ConfigMap objects that would make up an example functional external DNS kubernetes configuration utilizing NGINX as the exposed service.
Connect your `kubectl` client to the cluster with which you want to test External-DNS, and then apply one of the following manifest files for deployment:
### Manifest (for clusters without RBAC enabled)
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: external-dns
spec:
strategy:
type: Recreate
selector:
matchLabels:
app: external-dns
template:
metadata:
labels:
app: external-dns
spec:
containers:
- name: external-dns
image: k8s.gcr.io/external-dns/external-dns:v0.7.5
args:
- --source=service # or ingress or both
- --provider=akamai
- --domain-filter=example.com
# zone-id-filter may be specified as well to filter on contract ID
- --registry=txt
- --txt-owner-id={{ owner-id-for-this-external-dns }}
env:
- name: EXTERNAL_DNS_AKAMAI_SERVICECONSUMERDOMAIN
valueFrom:
secretKeyRef:
name: external-dns
key: EXTERNAL_DNS_AKAMAI_SERVICECONSUMERDOMAIN
- name: EXTERNAL_DNS_AKAMAI_CLIENT_TOKEN
valueFrom:
secretKeyRef:
name: external-dns
key: EXTERNAL_DNS_AKAMAI_CLIENT_TOKEN
- name: EXTERNAL_DNS_AKAMAI_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: external-dns
key: EXTERNAL_DNS_AKAMAI_CLIENT_SECRET
- name: EXTERNAL_DNS_AKAMAI_ACCESS_TOKEN
valueFrom:
secretKeyRef:
name: external-dns
key: EXTERNAL_DNS_AKAMAI_ACCESS_TOKEN
```
### Manifest (for clusters with RBAC enabled)
```yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: external-dns
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: external-dns
rules:
- apiGroups: [""]
resources: ["services","endpoints","pods"]
verbs: ["get","watch","list"]
- apiGroups: ["extensions","networking.k8s.io"]
resources: ["ingresses"]
verbs: ["get","watch","list"]
- apiGroups: [""]
resources: ["nodes"]
verbs: ["watch", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
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: apps/v1
kind: Deployment
metadata:
name: external-dns
spec:
strategy:
type: Recreate
selector:
matchLabels:
app: external-dns
template:
metadata:
labels:
app: external-dns
spec:
containers:
- name: external-dns
image: k8s.gcr.io/external-dns/external-dns:v0.7.5
args:
- --source=service # or ingress or both
- --provider=akamai
- --domain-filter=example.com
# zone-id-filter may be specified as well to filter on contract ID
- --registry=txt
- --txt-owner-id={{ owner-id-for-this-external-dns }}
env:
- name: EXTERNAL_DNS_AKAMAI_SERVICECONSUMERDOMAIN
valueFrom:
secretKeyRef:
name: external-dns
key: EXTERNAL_DNS_AKAMAI_SERVICECONSUMERDOMAIN
- name: EXTERNAL_DNS_AKAMAI_CLIENT_TOKEN
valueFrom:
secretKeyRef:
name: external-dns
key: EXTERNAL_DNS_AKAMAI_CLIENT_TOKEN
- name: EXTERNAL_DNS_AKAMAI_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: external-dns
key: EXTERNAL_DNS_AKAMAI_CLIENT_SECRET
- name: EXTERNAL_DNS_AKAMAI_ACCESS_TOKEN
valueFrom:
secretKeyRef:
name: external-dns
key: EXTERNAL_DNS_AKAMAI_ACCESS_TOKEN
```
Create the deployment for External-DNS:
```
$ kubectl create -f externaldns.yaml
```
## Deploying an Nginx Service
Create a service file called 'nginx.yaml' with the following contents:
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx
spec:
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- image: nginx
name: nginx
ports:
- containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
name: nginx
annotations:
external-dns.alpha.kubernetes.io/hostname: nginx.example.com
external-dns.alpha.kubernetes.io/ttl: "600" #optional
spec:
selector:
app: nginx
type: LoadBalancer
ports:
- protocol: TCP
port: 80
targetPort: 80
```
Create the deployment, service and ingress object:
```
$ kubectl create -f nginx.yaml
```
## Verify Akamai Edge DNS Records
It is recommended to wait 3-5 minutes before validating the records to allow the record changes to propagate to all the Akamai name servers worldwide.
The records can be validated using the [Akamai Control Center](http://control.akamai.com) or by executing a dig, nslookup or similar DNS command.
## Cleanup
Once you successfully configure and verify record management via External-DNS, you can delete the tutorial's example:
```
$ kubectl delete -f nginx.yaml
$ kubectl delete -f externaldns.yaml
```
## Additional Information
* The Akamai provider allows the administrative user to filter zones by both name (domain-filter) and contract Id (zone-id-filter). The Edge DNS API will return a '500 Internal Error' if an invalid contract Id is provided.
* The provider will substitute any embedded quotes in TXT records with `` ` `` (back tick) when writing the records to the API.

View File

@ -1,192 +0,0 @@
# Setting up Akamai FastDNS
## Prerequisites
Akamai FastDNS provider support was added via [this PR](https://github.com/kubernetes-sigs/external-dns/pull/1384), thus you need to use a release where this pr is included. This should be at least v0.5.18
The Akamai FastDNS provider expects that your 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 please refer to the [FastDNS documentation](https://developer.akamai.com/legacy/cli/packages/fast-dns.html).
Additional data you will have to provide:
* Service Consumer Domain
* Access token
* Client token
* Client Secret
Make these available to external DNS somehow. In the following example a secret is used by referencing the secret and its keys in the env section of the deployment.
If you happen to have questions regarding authentication, please refer to the [API Client Authentication documentation](https://developer.akamai.com/legacy/introduction/Client_Auth.html)
## Deployment
Deploying external DNS for Akamai is actually nearly identical to deploying
it for other providers. This is what a sample `deployment.yaml` looks like:
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: external-dns
labels:
app.kubernetes.io/name: external-dns
app.kubernetes.io/version: v0.6.0
spec:
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: external-dns
template:
metadata:
labels:
app.kubernetes.io/name: external-dns
app.kubernetes.io/version: v0.6.0
spec:
# Only use if you're also using RBAC
# serviceAccountName: external-dns
containers:
- name: external-dns
image: k8s.gcr.io/external-dns/external-dns:v0.7.3
args:
- --source=ingress # or service or both
- --provider=akamai
- --registry=txt
- --txt-owner-id={{ owner-id-for-this-external-dns }}
env:
- name: EXTERNAL_DNS_AKAMAI_SERVICECONSUMERDOMAIN
valueFrom:
secretKeyRef:
name: external-dns
key: EXTERNAL_DNS_AKAMAI_SERVICECONSUMERDOMAIN
- name: EXTERNAL_DNS_AKAMAI_CLIENT_TOKEN
valueFrom:
secretKeyRef:
name: external-dns
key: EXTERNAL_DNS_AKAMAI_CLIENT_TOKEN
- name: EXTERNAL_DNS_AKAMAI_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: external-dns
key: EXTERNAL_DNS_AKAMAI_CLIENT_SECRET
- name: EXTERNAL_DNS_AKAMAI_ACCESS_TOKEN
valueFrom:
secretKeyRef:
name: external-dns
key: EXTERNAL_DNS_AKAMAI_ACCESS_TOKEN
```
## 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","endpoints","pods"]
verbs: ["get","watch","list"]
- apiGroups: ["extensions","networking.k8s.io"]
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
```
## Verify ExternalDNS works (Ingress example)
Create an ingress resource manifest file.
> For ingress objects ExternalDNS will create a DNS record based on the host specified for the ingress object.
```yaml
apiVersion: networking.k8s.io/v1beta1
kind: Ingress
metadata:
name: foo
annotations:
kubernetes.io/ingress.class: "nginx" # use the one that corresponds to your ingress controller.
spec:
rules:
- host: foo.bar.com
http:
paths:
- backend:
serviceName: foo
servicePort: 80
```
## 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.
> If you want to give multiple names to service, you can set it to external-dns.alpha.kubernetes.io/hostname with a comma separator.
```yaml
apiVersion: v1
kind: Service
metadata:
name: nginx
annotations:
external-dns.alpha.kubernetes.io/hostname: nginx.external-dns-test.my-org.com
spec:
type: LoadBalancer
ports:
- port: 80
name: http
targetPort: 80
selector:
app: nginx
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx
spec:
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- image: nginx
name: nginx
ports:
- containerPort: 80
name: http
```
**Important!**: Don't run dig, nslookup or similar immediately. 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)

View File

@ -2,7 +2,7 @@
This tutorial describes how to use ExternalDNS with the [aws-alb-ingress-controller][1].
[1]: https://kubernetes-sigs.github.io/aws-alb-ingress-controller/
[1]: https://kubernetes-sigs.github.io/aws-load-balancer-controller
## Setting up ExternalDNS and aws-alb-ingress-controller
@ -14,12 +14,12 @@ this is not required.
For help setting up the ALB Ingress Controller, follow the [Setup Guide][2].
[2]: https://kubernetes-sigs.github.io/aws-alb-ingress-controller/guide/controller/setup/
[2]: https://kubernetes-sigs.github.io/aws-load-balancer-controller/latest/deploy/installation/
Note that the ALB ingress controller uses the same tags for [subnet auto-discovery][3]
as Kubernetes does with the AWS cloud provider.
[3]: https://kubernetes-sigs.github.io/aws-alb-ingress-controller/guide/controller/config/#subnet-auto-discovery
[3]: https://kubernetes-sigs.github.io/aws-load-balancer-controller/latest/deploy/subnet_discovery/
In the examples that follow, it is assumed that you configured the ALB Ingress
Controller with the `ingress-class=alb` argument (not to be confused with the

View File

@ -414,6 +414,39 @@ You can configure Route53 to associate DNS records with healthchecks for automat
Note: ExternalDNS does not support creating healthchecks, and assumes that `<health-check-id>` already exists.
## Govcloud caveats
Due to the special nature with how Route53 runs in Govcloud, there are a few tweaks in the deployment settings.
* An Environment variable with name of AWS_REGION set to either us-gov-west-1 or us-gov-east-1 is required. Otherwise it tries to lookup a region that does not exist in Govcloud and it errors out.
```yaml
env:
- name: AWS_REGION
value: us-gov-west-1
```
* Route53 in Govcloud does not allow aliases. Therefore, container args must be set so that it uses CNAMES and a txt-prefix must be set to something. Otherwise, it will try to create a TXT record with the same value than the CNAME itself, which is not allowed.
```yaml
args:
- --aws-prefer-cname
- --txt-prefix={{ YOUR_PREFIX }}
```
* The first two changes are needed if you use Route53 in Govcloud, which only supports private zones. There are also no cross account IAM whatsoever between Govcloud and commerical AWS accounts. If services and ingresses need to make Route 53 entries to an public zone in a commerical account, you will have set env variables of AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY with a key and secret to the commerical account that has the sufficient rights.
```yaml
env:
- name: AWS_ACCESS_KEY_ID
value: XXXXXXXXX
- name: AWS_SECRET_ACCESS_KEY
valueFrom:
secretKeyRef:
name: {{ YOUR_SECRET_NAME }}
key: {{ YOUR_SECRET_KEY }}
```
## Clean up
Make sure to delete all Service objects before terminating the cluster so all load balancers get cleaned up correctly.

View File

@ -34,17 +34,19 @@ This is crucial as ExternalDNS reads those endpoints records when creating DNS-R
In the subsequent parameter we will make use of this. If you don't want to work with ingress-resources in your later use, you can leave the parameter out.
Verify the correct propagation of the loadbalancer's ip by listing the ingresses.
```
$ kubectl get ingress
```
The address column should contain the ip for each ingress. ExternalDNS will pick up exactly this piece of information.
```
NAME HOSTS ADDRESS PORTS AGE
nginx1 sample1.aks.com 52.167.195.110 80 6d22h
nginx2 sample2.aks.com 52.167.195.110 80 6d21h
```
If you do not want to deploy the ingress controller with Helm, ensure to pass the following cmdline-flags to it through the mechanism of your choice:
```
@ -144,6 +146,8 @@ This is per default done through the file `~/.kube/config`.
For general background information on this see [kubernetes-docs](https://kubernetes.io/docs/tasks/access-application-cluster/access-cluster/).
Azure-CLI features functionality for automatically maintaining this file for AKS-Clusters. See [Azure-Docs](https://docs.microsoft.com/de-de/cli/azure/aks?view=azure-cli-latest#az-aks-get-credentials).
Follow the steps for [azure-dns provider](./azure.md#creating-configuration-file) to create a configuration file.
Then apply one of the following manifests depending on whether you use RBAC or not.
The credentials of the service principal are provided to ExternalDNS as environment-variables.
@ -175,13 +179,14 @@ spec:
- --provider=azure-private-dns
- --azure-resource-group=externaldns
- --azure-subscription-id=<use the id of your subscription>
env:
- name: AZURE_TENANT_ID
value: "<use the tenantId discovered during creation of service principal>"
- name: AZURE_CLIENT_ID
value: "<use the aadClientId discovered during creation of service principal>"
- name: AZURE_CLIENT_SECRET
value: "<use the aadClientSecret discovered during creation of service principal>"
volumeMounts:
- name: azure-config-file
mountPath: /etc/kubernetes
readOnly: true
volumes:
- name: azure-config-file
secret:
secretName: azure-config-file
```
### Manifest (for clusters with RBAC enabled, cluster access)
@ -245,13 +250,14 @@ spec:
- --provider=azure-private-dns
- --azure-resource-group=externaldns
- --azure-subscription-id=<use the id of your subscription>
env:
- name: AZURE_TENANT_ID
value: "<use the tenantId discovered during creation of service principal>"
- name: AZURE_CLIENT_ID
value: "<use the aadClientId discovered during creation of service principal>"
- name: AZURE_CLIENT_SECRET
value: "<use the aadClientSecret discovered during creation of service principal>"
volumeMounts:
- name: azure-config-file
mountPath: /etc/kubernetes
readOnly: true
volumes:
- name: azure-config-file
secret:
secretName: azure-config-file
```
### Manifest (for clusters with RBAC enabled, namespace access)
@ -315,13 +321,14 @@ spec:
- --provider=azure-private-dns
- --azure-resource-group=externaldns
- --azure-subscription-id=<use the id of your subscription>
env:
- name: AZURE_TENANT_ID
value: "<use the tenantId discovered during creation of service principal>"
- name: AZURE_CLIENT_ID
value: "<use the aadClientId discovered during creation of service principal>"
- name: AZURE_CLIENT_SECRET
value: "<use the aadClientSecret discovered during creation of service principal>"
volumeMounts:
- name: azure-config-file
mountPath: /etc/kubernetes
readOnly: true
volumes:
- name: azure-config-file
secret:
secretName: azure-config-file
```
Create the deployment for ExternalDNS:

197
docs/tutorials/godaddy.md Normal file
View File

@ -0,0 +1,197 @@
# Setting up ExternalDNS for Services on GoDaddy
This tutorial describes how to setup ExternalDNS for use within a
Kubernetes cluster using GoDaddy DNS.
Make sure to use **>=0.6** version of ExternalDNS for this tutorial.
## Creating a zone with GoDaddy DNS
If you are new to GoDaddy, we recommend you first read the following
instructions for creating a zone.
[Creating a zone using the GoDaddy web console](https://www.godaddy.com/)
[Creating a zone using the GoDaddy API](https://developer.godaddy.com/)
## Creating GoDaddy API key
You first need to create an API Key.
Using the [GoDaddy documentation](https://developer.godaddy.com/getstarted) you will have your `API key` and `API secret`
## Deploy ExternalDNS
Connect your `kubectl` client to the cluster with which you want to test ExternalDNS, and then apply one of the following manifest files for deployment:
### Manifest (for clusters without RBAC enabled)
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: external-dns
spec:
strategy:
type: Recreate
selector:
matchLabels:
app: external-dns
template:
metadata:
labels:
app: external-dns
spec:
containers:
- name: external-dns
image: k8s.gcr.io/external-dns/external-dns:v0.7.7
args:
- --source=service # ingress is also possible
- --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above.
- --provider=godaddy
- --txt-prefix=external-dns. # In case of multiple k8s cluster
- --txt-owner-id=owner-id # In case of multiple k8s cluster
- --godaddy-api-key=<Your API Key>
- --godaddy-api-secret=<Your API secret>
```
### Manifest (for clusters with RBAC enabled)
```yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: external-dns
---
apiVersion: rbac.authorization.k8s.io/v1beta1
kind: ClusterRole
metadata:
name: external-dns
rules:
- apiGroups: [""]
resources: ["services"]
verbs: ["get","watch","list"]
- apiGroups: [""]
resources: ["pods"]
verbs: ["get","watch","list"]
- apiGroups: ["extensions","networking.k8s.io"]
resources: ["ingresses"]
verbs: ["get","watch","list"]
- apiGroups: [""]
resources: ["nodes"]
verbs: ["list","watch"]
- apiGroups: [""]
resources: ["endpoints"]
verbs: ["get","watch","list"]
---
apiVersion: rbac.authorization.k8s.io/v1beta1
kind: ClusterRoleBinding
metadata:
name: external-dns-viewer
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: external-dns
subjects:
- kind: ServiceAccount
name: external-dns
namespace: default
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: external-dns
spec:
strategy:
type: Recreate
selector:
matchLabels:
app: external-dns
template:
metadata:
labels:
app: external-dns
spec:
serviceAccountName: external-dns
containers:
- name: external-dns
image: k8s.gcr.io/external-dns/external-dns:v0.7.7
args:
- --source=service # ingress is also possible
- --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above.
- --provider=godaddy
- --txt-prefix=external-dns. # In case of multiple k8s cluster
- --txt-owner-id=owner-id # In case of multiple k8s cluster
- --godaddy-api-key=<Your API Key>
- --godaddy-api-secret=<Your API secret>
```
## Deploying an Nginx Service
Create a service file called 'nginx.yaml' with the following contents:
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx
spec:
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- image: nginx
name: nginx
ports:
- containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
name: nginx
annotations:
external-dns.alpha.kubernetes.io/hostname: example.com
external-dns.alpha.kubernetes.io/ttl: "120" #optional
spec:
selector:
app: nginx
type: LoadBalancer
ports:
- protocol: TCP
port: 80
targetPort: 80
```
**A note about annotations**
Verify that the annotation on the service uses the same hostname as the GoDaddy DNS zone created above. The annotation may also be a subdomain of the DNS zone (e.g. 'www.example.com').
The TTL annotation can be used to configure the TTL on DNS records managed by ExternalDNS and is optional. If this annotation is not set, the TTL on records managed by ExternalDNS will default to 10.
ExternalDNS uses the hostname annotation to determine which services should be registered with DNS. Removing the hostname annotation will cause ExternalDNS to remove the corresponding DNS records.
### Create the deployment and service
```
$ kubectl create -f nginx.yaml
```
Depending on where you run your service, it may take some time for your cloud provider to create an external IP for the service. Once an external IP is assigned, ExternalDNS detects the new service IP address and synchronizes the GoDaddy DNS records.
## Verifying GoDaddy DNS records
Use the GoDaddy web console or API to verify that the A record for your domain shows the external IP address of the services.
## Cleanup
Once you successfully configure and verify record management via ExternalDNS, you can delete the tutorial's example:
```
$ kubectl delete -f nginx.yaml
$ kubectl delete -f externaldns.yaml
```

View File

@ -78,7 +78,7 @@ rules:
See also current RBAC yaml files:
- [kube-ingress-aws-controller](https://github.com/zalando-incubator/kubernetes-on-aws/blob/dev/cluster/manifests/ingress-controller/01-rbac.yaml)
- [skipper](https://github.com/zalando-incubator/kubernetes-on-aws/blob/dev/cluster/manifests/skipper/rbac.yaml)
- [external-dns](https://github.com/zalando-incubator/kubernetes-on-aws/blob/dev/cluster/manifests/external-dns/rbac.yaml)
- [external-dns](https://github.com/zalando-incubator/kubernetes-on-aws/blob/dev/cluster/manifests/external-dns/01-rbac.yaml)
[3]: https://opensource.zalando.com/skipper/kubernetes/routegroups/#routegroups
[4]: https://opensource.zalando.com/skipper

View File

@ -220,6 +220,8 @@ spec:
- name: external-dns
image: k8s.gcr.io/external-dns/external-dns:v0.7.3
args:
- --registry=txt
- --txt-prefix=external-dns-
- --txt-owner-id=k8s
- --provider=rfc2136
- --rfc2136-host=192.168.0.1
@ -260,6 +262,8 @@ spec:
- name: external-dns
image: k8s.gcr.io/external-dns/external-dns:v0.7.3
args:
- --registry=txt
- --txt-prefix=external-dns-
- --txt-owner-id=k8s
- --provider=rfc2136
- --rfc2136-host=192.168.0.1
@ -273,17 +277,19 @@ spec:
- --domain-filter=k8s.example.org
```
## Microsoft DNS
## Microsoft DNS (Insecure Updates)
While `external-dns` was not developed or tested against Microsoft DNS, it can be configured to work against it. YMMV.
### DNS-side configuration
### Insecure Updates
#### DNS-side configuration
1. Create a DNS zone
2. Enable insecure dynamic updates for the zone
3. Enable Zone Transfers from all servers
### `external-dns` configuration
#### `external-dns` configuration
You'll want to configure `external-dns` similarly to the following:
@ -298,4 +304,84 @@ You'll want to configure `external-dns` similarly to the following:
...
```
Since Microsoft DNS does not support secure updates via TSIG, this will let `external-dns` make insecure updates. Do this at your own risk.
### Secure Updates Using RFC3645 (GSS-TSIG)
### DNS-side configuration
1. Create a DNS zone
2. Enable secure dynamic updates for the zone
3. Enable Zone Transfers from all servers
#### Kerberos Configuration
DNS with secure updates relies upon a valid Kerberos configuration running within the `external-dns` container. At this time, you will need to create a ConfigMap for the `external-dns` container to use and mount it in your deployment. Below is an example of a working Kerberos configuration inside a ConfigMap definition. This may be different depending on many factors in your environment:
```yaml
apiVersion: v1
kind: ConfigMap
metadata:
creationTimestamp: null
name: krb5.conf
data:
krb5.conf: |
[logging]
default = FILE:/var/log/krb5libs.log
kdc = FILE:/var/log/krb5kdc.log
admin_server = FILE:/var/log/kadmind.log
[libdefaults]
dns_lookup_realm = false
ticket_lifetime = 24h
renew_lifetime = 7d
forwardable = true
rdns = false
pkinit_anchors = /etc/pki/tls/certs/ca-bundle.crt
default_ccache_name = KEYRING:persistent:%{uid}
default_realm = YOURDOMAIN.COM
[realms]
YOURDOMAIN.COM = {
kdc = dc1.yourdomain.com
admin_server = dc1.yourdomain.com
}
[domain_realm]
yourdomain.com = YOURDOMAIN.COM
.yourdomain.com = YOURDOMAIN.COM
```
Once the ConfigMap is created, the container `external-dns` container needs to be told to mount that ConfigMap as a volume at the default Kerberos configuration location. The pod spec should include a similar configuration to the following:
```yaml
...
volumeMounts:
- mountPath: /etc/krb5.conf
name: kerberos-config-volume
subPath: krb5.conf
...
volumes:
- configMap:
defaultMode: 420
name: krb5.conf
name: kerberos-config-volume
...
```
#### `external-dns` configuration
You'll want to configure `external-dns` similarly to the following:
```text
...
- --provider=rfc2136
- --rfc2136-gss-tsig
- --rfc2136-host=123.123.123.123
- --rfc2136-port=53
- --rfc2136-zone=your-domain.com
- --rfc2136-kerberos-username=your-domain-account
- --rfc2136-kerberos-password=your-domain-password
- --rfc2136-tsig-axfr # needed to enable zone transfers, which is required for deletion of records.
...
```

18
go.mod
View File

@ -8,17 +8,18 @@ require (
github.com/Azure/azure-sdk-for-go v45.1.0+incompatible
github.com/Azure/go-autorest/autorest v0.11.10
github.com/Azure/go-autorest/autorest/adal v0.9.5
github.com/Azure/go-autorest/autorest/azure/auth v0.5.3
github.com/Azure/go-autorest/autorest/to v0.4.0
github.com/akamai/AkamaiOPEN-edgegrid-golang v0.9.11
github.com/akamai/AkamaiOPEN-edgegrid-golang v1.0.0
github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38 // indirect
github.com/alecthomas/colour v0.1.0 // indirect
github.com/alecthomas/kingpin v2.2.5+incompatible
github.com/alecthomas/repr v0.0.0-20200325044227-4184120f674c // indirect
github.com/aliyun/alibaba-cloud-sdk-go v1.61.357
github.com/aws/aws-sdk-go v1.31.4
github.com/bodgit/tsig v0.0.2
github.com/cloudflare/cloudflare-go v0.10.1
github.com/cloudfoundry-community/go-cfclient v0.0.0-20190201205600-f136f9222381
github.com/datawire/ambassador v1.6.0
github.com/denverdino/aliyungo v0.0.0-20190125010748-a747050bb1ba
github.com/digitalocean/godo v1.36.0
github.com/dnsimple/dnsimple-go v0.60.0
@ -33,7 +34,7 @@ require (
github.com/linki/instrumented_http v0.2.0
github.com/linode/linodego v0.19.0
github.com/maxatome/go-testdeep v1.4.0
github.com/miekg/dns v1.1.30
github.com/miekg/dns v1.1.36-0.20210109083720-731b191cabd1
github.com/nesv/go-dynect v0.6.0
github.com/nic-at/rc0go v1.1.1
github.com/openshift/api v0.0.0-20200605231317-fb2a6ca106ae
@ -46,7 +47,8 @@ require (
github.com/sanyu/dynectsoap v0.0.0-20181203081243-b83de5edc4e0
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.6.0.20200623155123-84df6c4b5301
github.com/sirupsen/logrus v1.6.0
github.com/stretchr/testify v1.5.1
github.com/smartystreets/gunit v1.3.4 // indirect
github.com/stretchr/testify v1.6.1
github.com/terra-farm/udnssdk v1.3.5 // indirect
github.com/transip/gotransip v5.8.2+incompatible
github.com/ultradns/ultradns-sdk-go v0.0.0-20200616202852-e62052662f60
@ -54,16 +56,20 @@ require (
github.com/vultr/govultr v0.4.2
go.etcd.io/etcd v0.5.0-alpha.5.0.20200401174654-e694b7bb0875
go.uber.org/ratelimit v0.1.0
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e
golang.org/x/net v0.0.0-20201224014010-6772e930b67b
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208
golang.org/x/tools v0.0.0-20200708003708-134513de8882 // indirect
google.golang.org/api v0.15.0
gopkg.in/ns1/ns1-go.v2 v2.0.0-20190322154155-0dafb5275fd1
gopkg.in/yaml.v2 v2.2.8
gopkg.in/yaml.v2 v2.3.0
honnef.co/go/tools v0.0.1-2020.1.4 // indirect
istio.io/api v0.0.0-20200529165953-72dad51d4ffc
istio.io/client-go v0.0.0-20200529172309-31c16ea3f751
k8s.io/api v0.18.8
k8s.io/apimachinery v0.18.8
k8s.io/client-go v0.18.8
k8s.io/kubernetes v1.13.0
)
replace (

534
go.sum

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,7 @@ kind: Kustomization
images:
- name: k8s.gcr.io/external-dns/external-dns
newTag: v0.7.5
newTag: v0.7.6
resources:
- ./external-dns-deployment.yaml

15
main.go
View File

@ -46,6 +46,7 @@ import (
"sigs.k8s.io/external-dns/provider/dnsimple"
"sigs.k8s.io/external-dns/provider/dyn"
"sigs.k8s.io/external-dns/provider/exoscale"
"sigs.k8s.io/external-dns/provider/godaddy"
"sigs.k8s.io/external-dns/provider/google"
"sigs.k8s.io/external-dns/provider/hetzner"
"sigs.k8s.io/external-dns/provider/infoblox"
@ -151,7 +152,7 @@ func main() {
var p provider.Provider
switch cfg.Provider {
case "akamai":
p = akamai.NewAkamaiProvider(
p, err = akamai.NewAkamaiProvider(
akamai.AkamaiConfig{
DomainFilter: domainFilter,
ZoneIDFilter: zoneIDFilter,
@ -159,9 +160,10 @@ func main() {
ClientToken: cfg.AkamaiClientToken,
ClientSecret: cfg.AkamaiClientSecret,
AccessToken: cfg.AkamaiAccessToken,
EdgercPath: cfg.AkamaiEdgercPath,
EdgercSection: cfg.AkamaiEdgercSection,
DryRun: cfg.DryRun,
},
)
}, nil)
case "alibabacloud":
p, err = alibabacloud.NewAlibabaCloudProvider(cfg.AlibabaCloudConfigFile, domainFilter, zoneIDFilter, cfg.AlibabaCloudZoneType, cfg.DryRun)
case "aws":
@ -191,7 +193,7 @@ func main() {
case "azure-dns", "azure":
p, err = azure.NewAzureProvider(cfg.AzureConfigFile, domainFilter, zoneNameFilter, zoneIDFilter, cfg.AzureResourceGroup, cfg.AzureUserAssignedIdentityClientID, cfg.DryRun)
case "azure-private-dns":
p, err = azure.NewAzurePrivateDNSProvider(domainFilter, zoneIDFilter, cfg.AzureResourceGroup, cfg.AzureSubscriptionID, cfg.DryRun)
p, err = azure.NewAzurePrivateDNSProvider(cfg.AzureConfigFile, domainFilter, zoneIDFilter, cfg.AzureResourceGroup, cfg.AzureUserAssignedIdentityClientID, cfg.DryRun)
case "vinyldns":
p, err = vinyldns.NewVinylDNSProvider(domainFilter, zoneIDFilter, cfg.DryRun)
case "vultr":
@ -281,7 +283,7 @@ func main() {
p, err = oci.NewOCIProvider(*config, domainFilter, zoneIDFilter, cfg.DryRun)
}
case "rfc2136":
p, err = rfc2136.NewRfc2136Provider(cfg.RFC2136Host, cfg.RFC2136Port, cfg.RFC2136Zone, cfg.RFC2136Insecure, cfg.RFC2136TSIGKeyName, cfg.RFC2136TSIGSecret, cfg.RFC2136TSIGSecretAlg, cfg.RFC2136TAXFR, domainFilter, cfg.DryRun, cfg.RFC2136MinTTL, nil)
p, err = rfc2136.NewRfc2136Provider(cfg.RFC2136Host, cfg.RFC2136Port, cfg.RFC2136Zone, cfg.RFC2136Insecure, cfg.RFC2136TSIGKeyName, cfg.RFC2136TSIGSecret, cfg.RFC2136TSIGSecretAlg, cfg.RFC2136TAXFR, domainFilter, cfg.DryRun, cfg.RFC2136MinTTL, cfg.RFC2136GSSTSIG, cfg.RFC2136KerberosUsername, cfg.RFC2136KerberosPassword, nil)
case "ns1":
p, err = ns1.NewNS1Provider(
ns1.NS1Config{
@ -297,6 +299,8 @@ func main() {
p, err = transip.NewTransIPProvider(cfg.TransIPAccountName, cfg.TransIPPrivateKeyFile, domainFilter, cfg.DryRun)
case "scaleway":
p, err = scaleway.NewScalewayProvider(ctx, domainFilter, cfg.DryRun)
case "godaddy":
p, err = godaddy.NewGoDaddyProvider(ctx, domainFilter, cfg.GoDaddyTTL, cfg.GoDaddyAPIKey, cfg.GoDaddySecretKey, cfg.GoDaddyOTE, cfg.DryRun)
default:
log.Fatalf("unknown dns provider: %s", cfg.Provider)
}
@ -331,6 +335,7 @@ func main() {
Policy: policy,
Interval: cfg.Interval,
DomainFilter: domainFilter,
ManagedRecordTypes: cfg.ManagedDNSRecordTypes,
}
if cfg.Once {

View File

@ -22,6 +22,8 @@ import (
"strconv"
"time"
"sigs.k8s.io/external-dns/endpoint"
"github.com/alecthomas/kingpin"
"github.com/sirupsen/logrus"
@ -88,6 +90,8 @@ type Config struct {
AkamaiClientToken string
AkamaiClientSecret string
AkamaiAccessToken string
AkamaiEdgercPath string
AkamaiEdgercSection string
InfobloxGridHost string
InfobloxWapiPort int
InfobloxWapiUsername string
@ -137,6 +141,9 @@ type Config struct {
RFC2136Port int
RFC2136Zone string
RFC2136Insecure bool
RFC2136GSSTSIG bool
RFC2136KerberosUsername string
RFC2136KerberosPassword string
RFC2136TSIGKeyName string
RFC2136TSIGSecret string `secure:"yes"`
RFC2136TSIGSecretAlg string
@ -148,6 +155,11 @@ type Config struct {
TransIPAccountName string
TransIPPrivateKeyFile string
DigitalOceanAPIPageSize int
ManagedDNSRecordTypes []string
GoDaddyAPIKey string `secure:"yes"`
GoDaddySecretKey string `secure:"yes"`
GoDaddyTTL int64
GoDaddyOTE bool
}
var defaultConfig = &Config{
@ -195,6 +207,8 @@ var defaultConfig = &Config{
AkamaiClientToken: "",
AkamaiClientSecret: "",
AkamaiAccessToken: "",
AkamaiEdgercSection: "",
AkamaiEdgercPath: "",
InfobloxGridHost: "",
InfobloxWapiPort: 443,
InfobloxWapiUsername: "admin",
@ -240,6 +254,9 @@ var defaultConfig = &Config{
RFC2136Port: 0,
RFC2136Zone: "",
RFC2136Insecure: false,
RFC2136GSSTSIG: false,
RFC2136KerberosUsername: "",
RFC2136KerberosPassword: "",
RFC2136TSIGKeyName: "",
RFC2136TSIGSecret: "",
RFC2136TSIGSecretAlg: "",
@ -250,6 +267,11 @@ var defaultConfig = &Config{
TransIPAccountName: "",
TransIPPrivateKeyFile: "",
DigitalOceanAPIPageSize: 50,
ManagedDNSRecordTypes: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},
GoDaddyAPIKey: "",
GoDaddySecretKey: "",
GoDaddyTTL: 600,
GoDaddyOTE: false,
}
// NewConfig returns new Config object
@ -310,7 +332,7 @@ func (cfg *Config) ParseFlags(args []string) error {
app.Flag("skipper-routegroup-groupversion", "The resource version for skipper routegroup").Default(source.DefaultRoutegroupVersion).StringVar(&cfg.SkipperRouteGroupVersion)
// Flags related to processing sources
app.Flag("source", "The resource types that are queried for endpoints; specify multiple times for multiple sources (required, options: service, ingress, node, fake, connector, istio-gateway, istio-virtualservice, cloudfoundry, contour-ingressroute, contour-httpproxy, crd, empty, skipper-routegroup,openshift-route)").Required().PlaceHolder("source").EnumsVar(&cfg.Sources, "service", "ingress", "node", "istio-gateway", "istio-virtualservice", "cloudfoundry", "contour-ingressroute", "contour-httpproxy", "fake", "connector", "crd", "empty", "skipper-routegroup", "openshift-route")
app.Flag("source", "The resource types that are queried for endpoints; specify multiple times for multiple sources (required, options: service, ingress, node, fake, connector, istio-gateway, istio-virtualservice, cloudfoundry, contour-ingressroute, contour-httpproxy, crd, empty, skipper-routegroup, openshift-route, ambassador-host)").Required().PlaceHolder("source").EnumsVar(&cfg.Sources, "service", "ingress", "node", "istio-gateway", "istio-virtualservice", "cloudfoundry", "contour-ingressroute", "contour-httpproxy", "fake", "connector", "crd", "empty", "skipper-routegroup", "openshift-route", "ambassador-host")
app.Flag("namespace", "Limit sources of endpoints to a specific namespace (default: all namespaces)").Default(defaultConfig.Namespace).StringVar(&cfg.Namespace)
app.Flag("annotation-filter", "Filter sources managed by external-dns via annotation using label selector semantics (default: all sources)").Default(defaultConfig.AnnotationFilter).StringVar(&cfg.AnnotationFilter)
@ -327,9 +349,10 @@ func (cfg *Config) ParseFlags(args []string) error {
app.Flag("crd-source-apiversion", "API version of the CRD for crd source, e.g. `externaldns.k8s.io/v1alpha1`, valid only when using crd source").Default(defaultConfig.CRDSourceAPIVersion).StringVar(&cfg.CRDSourceAPIVersion)
app.Flag("crd-source-kind", "Kind of the CRD for the crd source in API group and version specified by crd-source-apiversion").Default(defaultConfig.CRDSourceKind).StringVar(&cfg.CRDSourceKind)
app.Flag("service-type-filter", "The service types to take care about (default: all, expected: ClusterIP, NodePort, LoadBalancer or ExternalName)").StringsVar(&cfg.ServiceTypeFilter)
app.Flag("managed-record-types", "Comma separated list of record types to manage (default: A, CNAME) (supported records: CNAME, A, NS").Default("A", "CNAME").StringsVar(&cfg.ManagedDNSRecordTypes)
// Flags related to providers
app.Flag("provider", "The DNS provider where the DNS records will be created (required, options: aws, aws-sd, google, azure, azure-dns, azure-private-dns, cloudflare, rcodezero, digitalocean, hetzner, dnsimple, akamai, infoblox, dyn, designate, coredns, skydns, inmemory, ovh, pdns, oci, exoscale, linode, rfc2136, ns1, transip, vinyldns, rdns, scaleway, vultr, ultradns)").Required().PlaceHolder("provider").EnumVar(&cfg.Provider, "aws", "aws-sd", "google", "azure", "azure-dns", "hetzner", "azure-private-dns", "alibabacloud", "cloudflare", "rcodezero", "digitalocean", "dnsimple", "akamai", "infoblox", "dyn", "designate", "coredns", "skydns", "inmemory", "ovh", "pdns", "oci", "exoscale", "linode", "rfc2136", "ns1", "transip", "vinyldns", "rdns", "scaleway", "vultr", "ultradns")
app.Flag("provider", "The DNS provider where the DNS records will be created (required, options: aws, aws-sd, godaddy, google, azure, azure-dns, azure-private-dns, cloudflare, rcodezero, digitalocean, hetzner, dnsimple, akamai, infoblox, dyn, designate, coredns, skydns, inmemory, ovh, pdns, oci, exoscale, linode, rfc2136, ns1, transip, vinyldns, rdns, scaleway, vultr, ultradns)").Required().PlaceHolder("provider").EnumVar(&cfg.Provider, "aws", "aws-sd", "google", "azure", "azure-dns", "hetzner", "azure-private-dns", "alibabacloud", "cloudflare", "rcodezero", "digitalocean", "dnsimple", "akamai", "infoblox", "dyn", "designate", "coredns", "skydns", "inmemory", "ovh", "pdns", "oci", "exoscale", "linode", "rfc2136", "ns1", "transip", "vinyldns", "rdns", "scaleway", "vultr", "ultradns", "godaddy")
app.Flag("domain-filter", "Limit possible target zones by a domain suffix; specify multiple times for multiple domains (optional)").Default("").StringsVar(&cfg.DomainFilter)
app.Flag("exclude-domains", "Exclude subdomains (optional)").Default("").StringsVar(&cfg.ExcludeDomains)
app.Flag("zone-name-filter", "Filter target zones by zone domain (For now, only AzureDNS provider is using this flag); specify multiple times for multiple zones (optional)").Default("").StringsVar(&cfg.ZoneNameFilter)
@ -355,10 +378,12 @@ func (cfg *Config) ParseFlags(args []string) error {
app.Flag("cloudflare-proxied", "When using the Cloudflare provider, specify if the proxy mode must be enabled (default: disabled)").BoolVar(&cfg.CloudflareProxied)
app.Flag("cloudflare-zones-per-page", "When using the Cloudflare provider, specify how many zones per page listed, max. possible 50 (default: 50)").Default(strconv.Itoa(defaultConfig.CloudflareZonesPerPage)).IntVar(&cfg.CloudflareZonesPerPage)
app.Flag("coredns-prefix", "When using the CoreDNS provider, specify the prefix name").Default(defaultConfig.CoreDNSPrefix).StringVar(&cfg.CoreDNSPrefix)
app.Flag("akamai-serviceconsumerdomain", "When using the Akamai provider, specify the base URL (required when --provider=akamai)").Default(defaultConfig.AkamaiServiceConsumerDomain).StringVar(&cfg.AkamaiServiceConsumerDomain)
app.Flag("akamai-client-token", "When using the Akamai provider, specify the client token (required when --provider=akamai)").Default(defaultConfig.AkamaiClientToken).StringVar(&cfg.AkamaiClientToken)
app.Flag("akamai-client-secret", "When using the Akamai provider, specify the client secret (required when --provider=akamai)").Default(defaultConfig.AkamaiClientSecret).StringVar(&cfg.AkamaiClientSecret)
app.Flag("akamai-access-token", "When using the Akamai provider, specify the access token (required when --provider=akamai)").Default(defaultConfig.AkamaiAccessToken).StringVar(&cfg.AkamaiAccessToken)
app.Flag("akamai-serviceconsumerdomain", "When using the Akamai provider, specify the base URL (required when --provider=akamai and edgerc-path not specified)").Default(defaultConfig.AkamaiServiceConsumerDomain).StringVar(&cfg.AkamaiServiceConsumerDomain)
app.Flag("akamai-client-token", "When using the Akamai provider, specify the client token (required when --provider=akamai and edgerc-path not specified)").Default(defaultConfig.AkamaiClientToken).StringVar(&cfg.AkamaiClientToken)
app.Flag("akamai-client-secret", "When using the Akamai provider, specify the client secret (required when --provider=akamai and edgerc-path not specified)").Default(defaultConfig.AkamaiClientSecret).StringVar(&cfg.AkamaiClientSecret)
app.Flag("akamai-access-token", "When using the Akamai provider, specify the access token (required when --provider=akamai and edgerc-path not specified)").Default(defaultConfig.AkamaiAccessToken).StringVar(&cfg.AkamaiAccessToken)
app.Flag("akamai-edgerc-path", "When using the Akamai provider, specify the .edgerc file path. Path must be reachable form invocation environment. (required when --provider=akamai and *-token, secret serviceconsumerdomain not specified)").Default(defaultConfig.AkamaiEdgercPath).StringVar(&cfg.AkamaiEdgercPath)
app.Flag("akamai-edgerc-section", "When using the Akamai provider, specify the .edgerc file path (Optional when edgerc-path is specified)").Default(defaultConfig.AkamaiEdgercSection).StringVar(&cfg.AkamaiEdgercSection)
app.Flag("infoblox-grid-host", "When using the Infoblox provider, specify the Grid Manager host (required when --provider=infoblox)").Default(defaultConfig.InfobloxGridHost).StringVar(&cfg.InfobloxGridHost)
app.Flag("infoblox-wapi-port", "When using the Infoblox provider, specify the WAPI port (default: 443)").Default(strconv.Itoa(defaultConfig.InfobloxWapiPort)).IntVar(&cfg.InfobloxWapiPort)
app.Flag("infoblox-wapi-username", "When using the Infoblox provider, specify the WAPI username (default: admin)").Default(defaultConfig.InfobloxWapiUsername).StringVar(&cfg.InfobloxWapiUsername)
@ -383,6 +408,11 @@ func (cfg *Config) ParseFlags(args []string) error {
app.Flag("ns1-ignoressl", "When using the NS1 provider, specify whether to verify the SSL certificate (default: false)").Default(strconv.FormatBool(defaultConfig.NS1IgnoreSSL)).BoolVar(&cfg.NS1IgnoreSSL)
app.Flag("ns1-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.NS1MinTTLSeconds)
app.Flag("digitalocean-api-page-size", "Configure the page size used when querying the DigitalOcean API.").Default(strconv.Itoa(defaultConfig.DigitalOceanAPIPageSize)).IntVar(&cfg.DigitalOceanAPIPageSize)
// GoDaddy flags
app.Flag("godaddy-api-key", "When using the GoDaddy provider, specify the API Key (required when --provider=godaddy)").Default(defaultConfig.GoDaddyAPIKey).StringVar(&cfg.GoDaddyAPIKey)
app.Flag("godaddy-api-secret", "When using the GoDaddy provider, specify the API secret (required when --provider=godaddy)").Default(defaultConfig.GoDaddySecretKey).StringVar(&cfg.GoDaddySecretKey)
app.Flag("godaddy-api-ttl", "TTL (in seconds) for records. This value will be used if the provided TTL for a service/ingress is not provided.").Int64Var(&cfg.GoDaddyTTL)
app.Flag("godaddy-api-ote", "When using the GoDaddy provider, use OTE api (optional, default: false, when --provider=godaddy)").BoolVar(&cfg.GoDaddyOTE)
// 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)
@ -403,6 +433,9 @@ func (cfg *Config) ParseFlags(args []string) error {
app.Flag("rfc2136-tsig-secret-alg", "When using the RFC2136 provider, specify the TSIG (base64) value to attached to DNS messages (required when --rfc2136-insecure=false)").Default(defaultConfig.RFC2136TSIGSecretAlg).StringVar(&cfg.RFC2136TSIGSecretAlg)
app.Flag("rfc2136-tsig-axfr", "When using the RFC2136 provider, specify the TSIG (base64) value to attached to DNS messages (required when --rfc2136-insecure=false)").BoolVar(&cfg.RFC2136TAXFR)
app.Flag("rfc2136-min-ttl", "When using the RFC2136 provider, specify minimal TTL (in duration format) for records. This value will be used if the provided TTL for a service/ingress is lower than this").Default(defaultConfig.RFC2136MinTTL.String()).DurationVar(&cfg.RFC2136MinTTL)
app.Flag("rfc2136-gss-tsig", "When using the RFC2136 provider, specify whether to use secure updates with GSS-TSIG using Kerberos (default: false, requires --rfc2136-kerberos-username and rfc2136-kerberos-password)").Default(strconv.FormatBool(defaultConfig.RFC2136GSSTSIG)).BoolVar(&cfg.RFC2136GSSTSIG)
app.Flag("rfc2136-kerberos-username", "When using the RFC2136 provider with GSS-TSIG, specify the username of the user with permissions to update DNS records (required when --rfc2136-gss-tsig=true)").Default(defaultConfig.RFC2136KerberosUsername).StringVar(&cfg.RFC2136KerberosUsername)
app.Flag("rfc2136-kerberos-password", "When using the RFC2136 provider with GSS-TSIG, specify the password of the user with permissions to update DNS records (required when --rfc2136-gss-tsig=true)").Default(defaultConfig.RFC2136KerberosPassword).StringVar(&cfg.RFC2136KerberosPassword)
// Flags related to TransIP provider
app.Flag("transip-account", "When using the TransIP provider, specify the account name (required when --provider=transip)").Default(defaultConfig.TransIPAccountName).StringVar(&cfg.TransIPAccountName)

View File

@ -22,6 +22,8 @@ import (
"testing"
"time"
"sigs.k8s.io/external-dns/endpoint"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@ -66,6 +68,8 @@ var (
AkamaiClientToken: "",
AkamaiClientSecret: "",
AkamaiAccessToken: "",
AkamaiEdgercPath: "",
AkamaiEdgercSection: "",
InfobloxGridHost: "",
InfobloxWapiPort: 443,
InfobloxWapiUsername: "admin",
@ -102,6 +106,7 @@ var (
TransIPAccountName: "",
TransIPPrivateKeyFile: "",
DigitalOceanAPIPageSize: 50,
ManagedDNSRecordTypes: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},
}
overriddenConfig = &Config{
@ -144,6 +149,8 @@ var (
AkamaiClientToken: "o184671d5307a388180fbf7f11dbdf46",
AkamaiClientSecret: "o184671d5307a388180fbf7f11dbdf46",
AkamaiAccessToken: "o184671d5307a388180fbf7f11dbdf46",
AkamaiEdgercPath: "/home/test/.edgerc",
AkamaiEdgercSection: "default",
InfobloxGridHost: "127.0.0.1",
InfobloxWapiPort: 8443,
InfobloxWapiUsername: "infoblox",
@ -186,6 +193,7 @@ var (
TransIPAccountName: "transip",
TransIPPrivateKeyFile: "/path/to/transip.key",
DigitalOceanAPIPageSize: 100,
ManagedDNSRecordTypes: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},
}
)
@ -235,6 +243,8 @@ func TestParseFlags(t *testing.T) {
"--akamai-client-token=o184671d5307a388180fbf7f11dbdf46",
"--akamai-client-secret=o184671d5307a388180fbf7f11dbdf46",
"--akamai-access-token=o184671d5307a388180fbf7f11dbdf46",
"--akamai-edgerc-path=/home/test/.edgerc",
"--akamai-edgerc-section=default",
"--infoblox-grid-host=127.0.0.1",
"--infoblox-wapi-port=8443",
"--infoblox-wapi-username=infoblox",
@ -328,6 +338,8 @@ func TestParseFlags(t *testing.T) {
"EXTERNAL_DNS_AKAMAI_CLIENT_TOKEN": "o184671d5307a388180fbf7f11dbdf46",
"EXTERNAL_DNS_AKAMAI_CLIENT_SECRET": "o184671d5307a388180fbf7f11dbdf46",
"EXTERNAL_DNS_AKAMAI_ACCESS_TOKEN": "o184671d5307a388180fbf7f11dbdf46",
"EXTERNAL_DNS_AKAMAI_EDGERC_PATH": "/home/test/.edgerc",
"EXTERNAL_DNS_AKAMAI_EDGERC_SECTION": "default",
"EXTERNAL_DNS_INFOBLOX_GRID_HOST": "127.0.0.1",
"EXTERNAL_DNS_INFOBLOX_WAPI_PORT": "8443",
"EXTERNAL_DNS_INFOBLOX_WAPI_USERNAME": "infoblox",

View File

@ -45,16 +45,16 @@ func ValidateConfig(cfg *externaldns.Config) error {
// Akamai provider specific validations
if cfg.Provider == "akamai" {
if cfg.AkamaiServiceConsumerDomain == "" {
if cfg.AkamaiServiceConsumerDomain == "" && cfg.AkamaiEdgercPath != "" {
return errors.New("no Akamai ServiceConsumerDomain specified")
}
if cfg.AkamaiClientToken == "" {
if cfg.AkamaiClientToken == "" && cfg.AkamaiEdgercPath != "" {
return errors.New("no Akamai client token specified")
}
if cfg.AkamaiClientSecret == "" {
if cfg.AkamaiClientSecret == "" && cfg.AkamaiEdgercPath != "" {
return errors.New("no Akamai client secret specified")
}
if cfg.AkamaiAccessToken == "" {
if cfg.AkamaiAccessToken == "" && cfg.AkamaiEdgercPath != "" {
return errors.New("no Akamai access token specified")
}
}
@ -86,6 +86,16 @@ func ValidateConfig(cfg *externaldns.Config) error {
if cfg.RFC2136MinTTL < 0 {
return errors.New("TTL specified for rfc2136 is negative")
}
if cfg.RFC2136Insecure && cfg.RFC2136GSSTSIG {
return errors.New("--rfc2136-insecure and --rfc2136-gss-tsig are mutually exclusive arguments")
}
if cfg.RFC2136GSSTSIG {
if cfg.RFC2136KerberosPassword == "" || cfg.RFC2136KerberosUsername == "" {
return errors.New("--rfc2136-kerberos-username and --rfc2136-kerberos-password both required when specifying --rfc2136-gss-tsig option")
}
}
}
if cfg.IgnoreHostnameAnnotation && cfg.FQDNTemplate == "" {

View File

@ -150,3 +150,63 @@ func TestValidateGoodRfc2136Config(t *testing.T) {
assert.Nil(t, err)
}
func TestValidateBadRfc2136GssTsigConfig(t *testing.T) {
var invalidRfc2136GssTsigConfigs = []*externaldns.Config{
{
LogFormat: "json",
Sources: []string{"test-source"},
Provider: "rfc2136",
RFC2136GSSTSIG: true,
RFC2136KerberosUsername: "test-user",
RFC2136KerberosPassword: "",
RFC2136MinTTL: 3600,
},
{
LogFormat: "json",
Sources: []string{"test-source"},
Provider: "rfc2136",
RFC2136GSSTSIG: true,
RFC2136KerberosUsername: "",
RFC2136KerberosPassword: "test-pass",
RFC2136MinTTL: 3600,
},
{
LogFormat: "json",
Sources: []string{"test-source"},
Provider: "rfc2136",
RFC2136GSSTSIG: true,
RFC2136Insecure: true,
RFC2136KerberosUsername: "test-user",
RFC2136KerberosPassword: "test-pass",
RFC2136MinTTL: 3600,
},
}
for _, cfg := range invalidRfc2136GssTsigConfigs {
err := ValidateConfig(cfg)
assert.NotNil(t, err)
}
}
func TestValidateGoodRfc2136GssTsigConfig(t *testing.T) {
var validRfc2136GssTsigConfigs = []*externaldns.Config{
{
LogFormat: "json",
Sources: []string{"test-source"},
Provider: "rfc2136",
RFC2136GSSTSIG: true,
RFC2136Insecure: false,
RFC2136KerberosUsername: "test-user",
RFC2136KerberosPassword: "test-pass",
RFC2136MinTTL: 3600,
},
}
for _, cfg := range validRfc2136GssTsigConfigs {
err := ValidateConfig(cfg)
assert.Nil(t, err)
}
}

View File

@ -43,6 +43,8 @@ type Plan struct {
DomainFilter endpoint.DomainFilter
// Property comparator compares custom properties of providers
PropertyComparator PropertyComparator
// DNS record types that will be considered for management
ManagedRecords []string
}
// Changes holds lists of actions to be executed by dns providers
@ -119,10 +121,10 @@ func (t planTable) addCandidate(e *endpoint.Endpoint) {
func (p *Plan) Calculate() *Plan {
t := newPlanTable()
for _, current := range filterRecordsForPlan(p.Current, p.DomainFilter) {
for _, current := range filterRecordsForPlan(p.Current, p.DomainFilter, p.ManagedRecords) {
t.addCurrent(current)
}
for _, desired := range filterRecordsForPlan(p.Desired, p.DomainFilter) {
for _, desired := range filterRecordsForPlan(p.Desired, p.DomainFilter, p.ManagedRecords) {
t.addCandidate(desired)
}
@ -158,6 +160,7 @@ func (p *Plan) Calculate() *Plan {
Current: p.Current,
Desired: p.Desired,
Changes: changes,
ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},
}
return plan
@ -224,7 +227,7 @@ func (p *Plan) shouldUpdateProviderSpecific(desired, current *endpoint.Endpoint)
// Per RFC 1034, CNAME records conflict with all other records - it is the
// only record with this property. The behavior of the planner may need to be
// made more sophisticated to codify this.
func filterRecordsForPlan(records []*endpoint.Endpoint, domainFilter endpoint.DomainFilter) []*endpoint.Endpoint {
func filterRecordsForPlan(records []*endpoint.Endpoint, domainFilter endpoint.DomainFilter, managedRecords []string) []*endpoint.Endpoint {
filtered := []*endpoint.Endpoint{}
for _, record := range records {
@ -232,14 +235,8 @@ func filterRecordsForPlan(records []*endpoint.Endpoint, domainFilter endpoint.Do
if !domainFilter.Match(record.DNSName) {
continue
}
// Explicitly specify which records we want to use for planning.
// TODO: Add AAAA records as well when they are supported.
switch record.RecordType {
case endpoint.RecordTypeA, endpoint.RecordTypeCNAME, endpoint.RecordTypeNS:
if isManagedRecord(record.RecordType, managedRecords) {
filtered = append(filtered, record)
default:
continue
}
}
@ -280,3 +277,12 @@ func CompareBoolean(defaultValue bool, name, current, previous string) bool {
return v1 == v2
}
func isManagedRecord(record string, managedRecords []string) bool {
for _, r := range managedRecords {
if record == r {
return true
}
}
return false
}

View File

@ -217,6 +217,7 @@ func (suite *PlanTestSuite) TestSyncFirstRound() {
Policies: []Policy{&SyncPolicy{}},
Current: current,
Desired: desired,
ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},
}
changes := p.Calculate().Changes
@ -238,6 +239,7 @@ func (suite *PlanTestSuite) TestSyncSecondRound() {
Policies: []Policy{&SyncPolicy{}},
Current: current,
Desired: desired,
ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},
}
changes := p.Calculate().Changes
@ -259,6 +261,7 @@ func (suite *PlanTestSuite) TestSyncSecondRoundMigration() {
Policies: []Policy{&SyncPolicy{}},
Current: current,
Desired: desired,
ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},
}
changes := p.Calculate().Changes
@ -280,6 +283,7 @@ func (suite *PlanTestSuite) TestSyncSecondRoundWithTTLChange() {
Policies: []Policy{&SyncPolicy{}},
Current: current,
Desired: desired,
ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},
}
changes := p.Calculate().Changes
@ -301,6 +305,7 @@ func (suite *PlanTestSuite) TestSyncSecondRoundWithProviderSpecificChange() {
Policies: []Policy{&SyncPolicy{}},
Current: current,
Desired: desired,
ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},
}
changes := p.Calculate().Changes
@ -325,6 +330,7 @@ func (suite *PlanTestSuite) TestSyncSecondRoundWithProviderSpecificDefaultFalse(
PropertyComparator: func(name, previous, current string) bool {
return CompareBoolean(false, name, previous, current)
},
ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},
}
changes := p.Calculate().Changes
@ -380,6 +386,7 @@ func (suite *PlanTestSuite) TestSyncSecondRoundWithOwnerInherited() {
Policies: []Policy{&SyncPolicy{}},
Current: current,
Desired: desired,
ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},
}
changes := p.Calculate().Changes
@ -422,6 +429,7 @@ func (suite *PlanTestSuite) TestDifferentTypes() {
Policies: []Policy{&SyncPolicy{}},
Current: current,
Desired: desired,
ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},
}
changes := p.Calculate().Changes
@ -443,6 +451,7 @@ func (suite *PlanTestSuite) TestIgnoreTXT() {
Policies: []Policy{&SyncPolicy{}},
Current: current,
Desired: desired,
ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},
}
changes := p.Calculate().Changes
@ -485,6 +494,7 @@ func (suite *PlanTestSuite) TestRemoveEndpoint() {
Policies: []Policy{&SyncPolicy{}},
Current: current,
Desired: desired,
ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},
}
changes := p.Calculate().Changes
@ -506,6 +516,7 @@ func (suite *PlanTestSuite) TestRemoveEndpointWithUpsert() {
Policies: []Policy{&UpsertOnlyPolicy{}},
Current: current,
Desired: desired,
ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},
}
changes := p.Calculate().Changes
@ -528,6 +539,7 @@ func (suite *PlanTestSuite) TestDuplicatedEndpointsForSameResourceReplace() {
Policies: []Policy{&SyncPolicy{}},
Current: current,
Desired: desired,
ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},
}
changes := p.Calculate().Changes
@ -551,6 +563,7 @@ func (suite *PlanTestSuite) TestDuplicatedEndpointsForSameResourceRetain() {
Policies: []Policy{&SyncPolicy{}},
Current: current,
Desired: desired,
ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},
}
changes := p.Calculate().Changes
@ -573,6 +586,7 @@ func (suite *PlanTestSuite) TestMultipleRecordsSameNameDifferentSetIdentifier()
Policies: []Policy{&SyncPolicy{}},
Current: current,
Desired: desired,
ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},
}
changes := p.Calculate().Changes
@ -595,6 +609,7 @@ func (suite *PlanTestSuite) TestSetIdentifierUpdateCreatesAndDeletes() {
Policies: []Policy{&SyncPolicy{}},
Current: current,
Desired: desired,
ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},
}
changes := p.Calculate().Changes
@ -618,6 +633,7 @@ func (suite *PlanTestSuite) TestDomainFiltersInitial() {
Current: current,
Desired: desired,
DomainFilter: endpoint.NewDomainFilterWithExclusions([]string{"domain.tld"}, []string{"ex.domain.tld"}),
ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},
}
changes := p.Calculate().Changes
@ -641,6 +657,7 @@ func (suite *PlanTestSuite) TestDomainFiltersUpdate() {
Current: current,
Desired: desired,
DomainFilter: endpoint.NewDomainFilterWithExclusions([]string{"domain.tld"}, []string{"ex.domain.tld"}),
ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},
}
changes := p.Calculate().Changes
@ -838,6 +855,7 @@ func TestShouldUpdateProviderSpecific(tt *testing.T) {
Current: []*endpoint.Endpoint{test.current},
Desired: []*endpoint.Endpoint{test.desired},
PropertyComparator: test.propertyComparator,
ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},
}
b := plan.shouldUpdateProviderSpecific(test.desired, test.current)
assert.Equal(t, test.shouldUpdate, b)

View File

@ -17,15 +17,13 @@ limitations under the License.
package akamai
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
c "github.com/akamai/AkamaiOPEN-edgegrid-golang/client-v1"
dns "github.com/akamai/AkamaiOPEN-edgegrid-golang/configdns-v2"
"github.com/akamai/AkamaiOPEN-edgegrid-golang/edgegrid"
log "github.com/sirupsen/logrus"
@ -34,22 +32,23 @@ import (
"sigs.k8s.io/external-dns/provider"
)
type akamaiClient interface {
NewRequest(config edgegrid.Config, method, path string, body io.Reader) (*http.Request, error)
Do(config edgegrid.Config, req *http.Request) (*http.Response, error)
const (
// Default Record TTL
edgeDNSRecordTTL = 600
maxUint = ^uint(0)
maxInt = int(maxUint >> 1)
)
// edgeDNSClient is a proxy interface of the Akamai edgegrid configdns-v2 package that can be stubbed for testing.
type AkamaiDNSService interface {
ListZones(queryArgs dns.ZoneListQueryArgs) (*dns.ZoneListResponse, error)
GetRecordsets(zone string, queryArgs dns.RecordsetQueryArgs) (*dns.RecordSetResponse, error)
GetRecord(zone string, name string, recordtype string) (*dns.RecordBody, error)
DeleteRecord(record *dns.RecordBody, zone string, recLock bool) error
UpdateRecord(record *dns.RecordBody, zone string, recLock bool) error
CreateRecordsets(recordsets *dns.Recordsets, zone string, recLock bool) error
}
type akamaiOpenClient struct{}
func (*akamaiOpenClient) NewRequest(config edgegrid.Config, method, path string, body io.Reader) (*http.Request, error) {
return c.NewRequest(config, method, path, body)
}
func (*akamaiOpenClient) Do(config edgegrid.Config, req *http.Request) (*http.Response, error) {
return c.Do(config, req)
}
// AkamaiConfig clarifies the method signature
type AkamaiConfig struct {
DomainFilter endpoint.DomainFilter
ZoneIDFilter provider.ZoneIDFilter
@ -57,17 +56,25 @@ type AkamaiConfig struct {
ClientToken string
ClientSecret string
AccessToken string
EdgercPath string
EdgercSection string
MaxBody int
AccountKey string
DryRun bool
}
// AkamaiProvider implements the DNS provider for Akamai.
type AkamaiProvider struct {
provider.BaseProvider
// Edgedns zones to filter on
domainFilter endpoint.DomainFilter
// Contract Ids to filter on
zoneIDFilter provider.ZoneIDFilter
config edgegrid.Config
// Edgegrid library configuration
config *edgegrid.Config
dryRun bool
client akamaiClient
// Defines client. Allows for mocking.
client AkamaiDNSService
}
type akamaiZones struct {
@ -79,84 +86,124 @@ type akamaiZone struct {
Zone string `json:"zone"`
}
type akamaiRecordsets struct {
Recordsets []akamaiRecord `json:"recordsets"`
}
type akamaiRecord struct {
Name string `json:"name"`
Type string `json:"type"`
TTL int64 `json:"ttl"`
Rdata []interface{} `json:"rdata"`
}
// NewAkamaiProvider initializes a new Akamai DNS based Provider.
func NewAkamaiProvider(akamaiConfig AkamaiConfig) *AkamaiProvider {
edgeGridConfig := edgegrid.Config{
func NewAkamaiProvider(akamaiConfig AkamaiConfig, akaService AkamaiDNSService) (provider.Provider, error) {
var edgeGridConfig edgegrid.Config
/*
log.Debugf("Host: %s", akamaiConfig.ServiceConsumerDomain)
log.Debugf("ClientToken: %s", akamaiConfig.ClientToken)
log.Debugf("ClientSecret: %s", akamaiConfig.ClientSecret)
log.Debugf("AccessToken: %s", akamaiConfig.AccessToken)
log.Debugf("EdgePath: %s", akamaiConfig.EdgercPath)
log.Debugf("EdgeSection: %s", akamaiConfig.EdgercSection)
*/
// environment overrides edgerc file but config needs to be complete
if akamaiConfig.ServiceConsumerDomain == "" || akamaiConfig.ClientToken == "" || akamaiConfig.ClientSecret == "" || akamaiConfig.AccessToken == "" {
// Kubernetes config incomplete or non existent. Can't mix and match.
// Look for Akamai environment or .edgerd creds
var err error
edgeGridConfig, err = edgegrid.Init(akamaiConfig.EdgercPath, akamaiConfig.EdgercSection) // use default .edgerc location and section
if err != nil {
log.Errorf("Edgegrid Init Failed")
return &AkamaiProvider{}, err // return empty provider for backward compatibility
}
edgeGridConfig.HeaderToSign = append(edgeGridConfig.HeaderToSign, "X-External-DNS")
} else {
// Use external-dns config
edgeGridConfig = edgegrid.Config{
Host: akamaiConfig.ServiceConsumerDomain,
ClientToken: akamaiConfig.ClientToken,
ClientSecret: akamaiConfig.ClientSecret,
AccessToken: akamaiConfig.AccessToken,
MaxBody: 1024,
MaxBody: 131072, // same default val as used by Edgegrid
HeaderToSign: []string{
"X-External-DNS",
},
Debug: false,
}
// Check for edgegrid overrides
if envval, ok := os.LookupEnv("AKAMAI_MAX_BODY"); ok {
if i, err := strconv.Atoi(envval); err == nil {
edgeGridConfig.MaxBody = i
log.Debugf("Edgegrid maxbody set to %s", envval)
}
}
if envval, ok := os.LookupEnv("AKAMAI_ACCOUNT_KEY"); ok {
edgeGridConfig.AccountKey = envval
log.Debugf("Edgegrid applying account key %s", envval)
}
if envval, ok := os.LookupEnv("AKAMAI_DEBUG"); ok {
if dbgval, err := strconv.ParseBool(envval); err == nil {
edgeGridConfig.Debug = dbgval
log.Debugf("Edgegrid debug set to %s", envval)
}
}
}
provider := &AkamaiProvider{
domainFilter: akamaiConfig.DomainFilter,
zoneIDFilter: akamaiConfig.ZoneIDFilter,
config: edgeGridConfig,
config: &edgeGridConfig,
dryRun: akamaiConfig.DryRun,
client: &akamaiOpenClient{},
}
return provider
if akaService != nil {
log.Debugf("Using STUB")
provider.client = akaService
} else {
provider.client = provider
}
// Init library for direct endpoint calls
dns.Init(edgeGridConfig)
return provider, nil
}
func (p *AkamaiProvider) request(method, path string, body io.Reader) (*http.Response, error) {
req, err := p.client.NewRequest(p.config, method, fmt.Sprintf("https://%s/%s", p.config.Host, path), body)
if err != nil {
log.Errorf("Akamai client failed to prepare the request")
return nil, err
}
resp, err := p.client.Do(p.config, req)
if err != nil {
log.Errorf("Akamai client failed to do the request")
return nil, err
}
if !c.IsSuccess(resp) {
return nil, c.NewAPIError(resp)
}
return resp, err
func (p AkamaiProvider) ListZones(queryArgs dns.ZoneListQueryArgs) (*dns.ZoneListResponse, error) {
return dns.ListZones(queryArgs)
}
//Look here for endpoint documentation -> https://developer.akamai.com/api/web_performance/fast_dns_zone_management/v2.html#getzones
func (p *AkamaiProvider) fetchZones() (zones akamaiZones, err error) {
log.Debugf("Trying to fetch zones from Akamai")
resp, err := p.request("GET", "config-dns/v2/zones?showAll=true&types=primary%2Csecondary", nil)
func (p AkamaiProvider) GetRecordsets(zone string, queryArgs dns.RecordsetQueryArgs) (*dns.RecordSetResponse, error) {
return dns.GetRecordsets(zone, queryArgs)
}
func (p AkamaiProvider) CreateRecordsets(recordsets *dns.Recordsets, zone string, reclock bool) error {
return recordsets.Save(zone, reclock)
}
func (p AkamaiProvider) GetRecord(zone string, name string, recordtype string) (*dns.RecordBody, error) {
return dns.GetRecord(zone, name, recordtype)
}
func (p AkamaiProvider) DeleteRecord(record *dns.RecordBody, zone string, recLock bool) error {
return record.Delete(zone, recLock)
}
func (p AkamaiProvider) UpdateRecord(record *dns.RecordBody, zone string, recLock bool) error {
return record.Update(zone, recLock)
}
// Fetch zones using Edgegrid DNS v2 API
func (p AkamaiProvider) fetchZones() (akamaiZones, error) {
filteredZones := akamaiZones{Zones: make([]akamaiZone, 0)}
queryArgs := dns.ZoneListQueryArgs{Types: "primary", ShowAll: true}
// filter based on contractIds
if len(p.zoneIDFilter.ZoneIDs) > 0 {
queryArgs.ContractIds = strings.Join(p.zoneIDFilter.ZoneIDs, ",")
}
resp, err := p.client.ListZones(queryArgs) // retrieve all primary zones filtered by contract ids
if err != nil {
log.Errorf("Failed to fetch zones from Akamai")
return zones, err
return filteredZones, err
}
err = json.NewDecoder(resp.Body).Decode(&zones)
if err != nil {
log.Errorf("Could not decode json response from Akamai on zone request")
return zones, err
for _, zone := range resp.Zones {
if p.domainFilter.Match(zone.Zone) || !p.domainFilter.IsConfigured() {
filteredZones.Zones = append(filteredZones.Zones, akamaiZone{ContractID: zone.ContractId, Zone: zone.Zone})
log.Debugf("Fetched zone: '%s' (ZoneID: %s)", zone.Zone, zone.ContractId)
}
defer resp.Body.Close()
filteredZones := akamaiZones{}
for _, zone := range zones.Zones {
if !p.zoneIDFilter.Match(zone.ContractID) {
log.Debugf("Skipping zone: '%s' with ZoneID: '%s', it does not match against ZoneID filters", zone.Zone, zone.ContractID)
continue
}
filteredZones.Zones = append(filteredZones.Zones, akamaiZone{ContractID: zone.ContractID, Zone: zone.Zone})
log.Debugf("Fetched zone: '%s' (ZoneID: %s)", zone.Zone, zone.ContractID)
}
lenFilteredZones := len(filteredZones.Zones)
if lenFilteredZones == 0 {
@ -168,53 +215,39 @@ func (p *AkamaiProvider) fetchZones() (zones akamaiZones, err error) {
return filteredZones, nil
}
//Look here for endpoint documentation -> https://developer.akamai.com/api/web_performance/fast_dns_zone_management/v2.html#getzonerecordsets
func (p *AkamaiProvider) fetchRecordSet(zone string) (recordSet akamaiRecordsets, err error) {
log.Debugf("Trying to fetch endpoints for zone: '%s' from Akamai", zone)
resp, err := p.request("GET", "config-dns/v2/zones/"+zone+"/recordsets?showAll=true&types=A%2CTXT%2CCNAME", nil)
if err != nil {
log.Errorf("Failed to fetch records from Akamai for zone: '%s'", zone)
return recordSet, err
}
defer resp.Body.Close()
err = json.NewDecoder(resp.Body).Decode(&recordSet)
if err != nil {
log.Errorf("Could not decode json response from Akamai for zone: '%s' on request", zone)
return recordSet, err
}
return recordSet, nil
}
//Records returns the list of records in a given zone.
func (p *AkamaiProvider) Records(context.Context) (endpoints []*endpoint.Endpoint, err error) {
zones, err := p.fetchZones()
func (p AkamaiProvider) Records(context.Context) (endpoints []*endpoint.Endpoint, err error) {
zones, err := p.fetchZones() // returns a filtered set of zones
if err != nil {
log.Warnf("No zones to fetch endpoints from!")
log.Warnf("Failed to identify target zones! Error: %s", err.Error())
return endpoints, err
}
for _, zone := range zones.Zones {
records, err := p.fetchRecordSet(zone.Zone)
recordsets, err := p.client.GetRecordsets(zone.Zone, dns.RecordsetQueryArgs{})
if err != nil {
log.Warnf("No recordsets could be fetched for zone: '%s'!", zone.Zone)
log.Errorf("Recordsets retrieval for zone: '%s' failed! %s", zone.Zone, err.Error())
continue
}
for _, record := range records.Recordsets {
rdata := make([]string, len(record.Rdata))
for i, v := range record.Rdata {
rdata[i] = v.(string)
if len(recordsets.Recordsets) == 0 {
log.Warnf("Zone %s contains no recordsets", zone.Zone)
}
if !p.domainFilter.Match(record.Name) {
log.Debugf("Skipping endpoint DNSName: '%s' RecordType: '%s', it does not match against Domain filters", record.Name, record.Type)
for _, recordset := range recordsets.Recordsets {
if !provider.SupportedRecordType(recordset.Type) {
log.Debugf("Skipping endpoint DNSName: '%s' RecordType: '%s'. Record type not supported.", recordset.Name, recordset.Type)
continue
}
endpoints = append(endpoints, endpoint.NewEndpoint(record.Name, record.Type, rdata...))
log.Debugf("Fetched endpoint DNSName: '%s' RecordType: '%s' Rdata: '%s')", record.Name, record.Type, rdata)
if !p.domainFilter.Match(recordset.Name) {
log.Debugf("Skipping endpoint. Record name %s doesn't match containing zone %s.", recordset.Name, zone)
continue
}
var temp interface{} = int64(recordset.TTL)
var ttl endpoint.TTL = endpoint.TTL(temp.(int64))
endpoints = append(endpoints, endpoint.NewEndpointWithTTL(recordset.Name,
recordset.Type,
ttl,
trimTxtRdata(recordset.Rdata, recordset.Type)...))
log.Debugf("Fetched endpoint DNSName: '%s' RecordType: '%s' Rdata: '%s')", recordset.Name, recordset.Type, recordset.Rdata)
}
}
lenEndpoints := len(endpoints)
@ -222,161 +255,237 @@ func (p *AkamaiProvider) Records(context.Context) (endpoints []*endpoint.Endpoin
log.Warnf("No endpoints could be fetched")
} else {
log.Debugf("Fetched '%d' endpoints from Akamai", lenEndpoints)
log.Debugf("Endpoints [%v]", endpoints)
}
return endpoints, nil
}
// ApplyChanges applies a given set of changes in a given zone.
func (p *AkamaiProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error {
func (p AkamaiProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error {
zoneNameIDMapper := provider.ZoneIDName{}
zones, err := p.fetchZones()
if err != nil {
log.Warnf("No zones to fetch endpoints from!")
return nil
log.Errorf("Failed to fetch zones from Akamai")
return err
}
for _, z := range zones.Zones {
zoneNameIDMapper[z.Zone] = z.Zone
}
log.Debugf("Processing zones: [%v]", zoneNameIDMapper)
_, cf := p.createRecords(zoneNameIDMapper, changes.Create)
if !p.dryRun {
if len(cf) > 0 {
log.Warnf("Not all desired endpoints could be created, retrying next iteration")
for _, f := range cf {
log.Warnf("Not created was DNSName: '%s' RecordType: '%s'", f.DNSName, f.RecordType)
// Create recordsets
log.Debugf("Create Changes requested [%v]", changes.Create)
if err := p.createRecordsets(zoneNameIDMapper, changes.Create); err != nil {
return err
}
// Delete recordsets
log.Debugf("Delete Changes requested [%v]", changes.Delete)
if err := p.deleteRecordsets(zoneNameIDMapper, changes.Delete); err != nil {
return err
}
// Update recordsets
log.Debugf("Update Changes requested [%v]", changes.UpdateNew)
if err := p.updateNewRecordsets(zoneNameIDMapper, changes.UpdateNew); err != nil {
return err
}
// Check that all old endpoints were accounted for
revRecs := changes.Delete
revRecs = append(revRecs, changes.UpdateNew...)
for _, rec := range changes.UpdateOld {
found := false
for _, r := range revRecs {
if rec.DNSName == r.DNSName {
found = true
break
}
}
}
_, df := p.deleteRecords(zoneNameIDMapper, changes.Delete)
if !p.dryRun {
if len(df) > 0 {
log.Warnf("Not all endpoints that require deletion could be deleted, retrying next iteration")
for _, f := range df {
log.Warnf("Not deleted was DNSName: '%s' RecordType: '%s'", f.DNSName, f.RecordType)
}
}
}
_, uf := p.updateNewRecords(zoneNameIDMapper, changes.UpdateNew)
if !p.dryRun {
if len(uf) > 0 {
log.Warnf("Not all endpoints that require updating could be updated, retrying next iteration")
for _, f := range uf {
log.Warnf("Not updated was DNSName: '%s' RecordType: '%s'", f.DNSName, f.RecordType)
}
}
}
for _, uold := range changes.UpdateOld {
if !p.dryRun {
log.Debugf("UpdateOld (ignored) for DNSName: '%s' RecordType: '%s'", uold.DNSName, uold.RecordType)
if !found {
log.Warnf("UpdateOld endpoint '%s' is not accounted for in UpdateNew|Delete endpoint list", rec.DNSName)
}
}
return nil
}
func (p *AkamaiProvider) newAkamaiRecord(dnsName, recordType string, targets ...string) *akamaiRecord {
cleanTargets := make([]interface{}, len(targets))
for idx, target := range targets {
cleanTargets[idx] = strings.TrimSuffix(target, ".")
}
return &akamaiRecord{
// Create DNS Recordset
func newAkamaiRecordset(dnsName, recordType string, ttl int, targets []string) dns.Recordset {
return dns.Recordset{
Name: strings.TrimSuffix(dnsName, "."),
Rdata: cleanTargets,
Rdata: targets,
Type: recordType,
TTL: 300,
TTL: ttl,
}
}
func (p *AkamaiProvider) createRecords(zoneNameIDMapper provider.ZoneIDName, endpoints []*endpoint.Endpoint) (created []*endpoint.Endpoint, failed []*endpoint.Endpoint) {
for _, endpoint := range endpoints {
if !p.domainFilter.Match(endpoint.DNSName) {
log.Debugf("Skipping creation at Akamai of endpoint DNSName: '%s' RecordType: '%s', it does not match against Domain filters", endpoint.DNSName, endpoint.RecordType)
continue
// cleanTargets preps recordset rdata if necessary for EdgeDNS
func cleanTargets(rtype string, targets ...string) []string {
log.Debugf("Targets to clean: [%v]", targets)
if rtype == "CNAME" || rtype == "SRV" {
for idx, target := range targets {
targets[idx] = strings.TrimSuffix(target, ".")
}
if zoneName, _ := zoneNameIDMapper.FindZone(endpoint.DNSName); zoneName != "" {
akamaiRecord := p.newAkamaiRecord(endpoint.DNSName, endpoint.RecordType, endpoint.Targets...)
body, _ := json.MarshalIndent(akamaiRecord, "", " ")
} else if rtype == "TXT" {
for idx, target := range targets {
log.Debugf("TXT data to clean: [%s]", target)
// need to embed text data in quotes. Make sure not piling on
target = strings.Trim(target, "\"")
// bug in DNS API with embedded quotes.
if strings.Contains(target, "owner") && strings.Contains(target, "\"") {
target = strings.ReplaceAll(target, "\"", "`")
}
targets[idx] = "\"" + target + "\""
}
}
log.Debugf("Clean targets: [%v]", targets)
log.Infof("Create new Endpoint at Akamai FastDNS - Zone: '%s', DNSName: '%s', RecordType: '%s', Targets: '%+v'", zoneName, endpoint.DNSName, endpoint.RecordType, endpoint.Targets)
return targets
}
// trimTxtRdata removes surrounding quotes for received TXT rdata
func trimTxtRdata(rdata []string, rtype string) []string {
if rtype == "TXT" {
for idx, d := range rdata {
if strings.Contains(d, "`") {
rdata[idx] = strings.ReplaceAll(d, "`", "\"")
}
}
}
log.Debugf("Trimmed data: [%v]", rdata)
return rdata
}
func ttlAsInt(src endpoint.TTL) int {
var temp interface{} = int64(src)
var temp64 = temp.(int64)
var ttl int = edgeDNSRecordTTL
if temp64 > 0 && temp64 <= int64(maxInt) {
ttl = int(temp64)
}
return ttl
}
// Create Endpoint Recordsets
func (p AkamaiProvider) createRecordsets(zoneNameIDMapper provider.ZoneIDName, endpoints []*endpoint.Endpoint) error {
if len(endpoints) == 0 {
log.Info("No endpoints to create")
return nil
}
endpointsByZone := edgeChangesByZone(zoneNameIDMapper, endpoints)
// create all recordsets by zone
for zone, endpoints := range endpointsByZone {
recordsets := &dns.Recordsets{Recordsets: make([]dns.Recordset, 0)}
for _, endpoint := range endpoints {
newrec := newAkamaiRecordset(endpoint.DNSName,
endpoint.RecordType,
ttlAsInt(endpoint.RecordTTL),
cleanTargets(endpoint.RecordType, endpoint.Targets...))
logfields := log.Fields{
"record": newrec.Name,
"type": newrec.Type,
"ttl": newrec.TTL,
"target": fmt.Sprintf("%v", newrec.Rdata),
"zone": zone,
}
log.WithFields(logfields).Info("Creating recordsets")
recordsets.Recordsets = append(recordsets.Recordsets, newrec)
}
if p.dryRun {
continue
}
_, err := p.request("POST", "config-dns/v2/zones/"+zoneName+"/names/"+endpoint.DNSName+"/types/"+endpoint.RecordType, bytes.NewReader(body))
// Create recordsets all at once
err := p.client.CreateRecordsets(recordsets, zone, true)
if err != nil {
log.Errorf("Failed to create Akamai endpoint DNSName: '%s' RecordType: '%s' for zone: '%s'", endpoint.DNSName, endpoint.RecordType, zoneName)
failed = append(failed, endpoint)
continue
}
created = append(created, endpoint)
} else {
log.Warnf("No matching zone for endpoint addition DNSName: '%s' RecordType: '%s'", endpoint.DNSName, endpoint.RecordType)
failed = append(failed, endpoint)
log.Errorf("Failed to create endpoints for DNS zone %s. Error: %s", zone, err.Error())
return err
}
}
return created, failed
return nil
}
func (p *AkamaiProvider) deleteRecords(zoneNameIDMapper provider.ZoneIDName, endpoints []*endpoint.Endpoint) (deleted []*endpoint.Endpoint, failed []*endpoint.Endpoint) {
func (p AkamaiProvider) deleteRecordsets(zoneNameIDMapper provider.ZoneIDName, endpoints []*endpoint.Endpoint) error {
for _, endpoint := range endpoints {
if !p.domainFilter.Match(endpoint.DNSName) {
log.Debugf("Skipping deletion at Akamai of endpoint: '%s' type: '%s', it does not match against Domain filters", endpoint.DNSName, endpoint.RecordType)
zoneName, _ := zoneNameIDMapper.FindZone(endpoint.DNSName)
if zoneName == "" {
log.Debugf("Skipping Akamai Edge DNS endpoint deletion: '%s' type: '%s', it does not match against Domain filters", endpoint.DNSName, endpoint.RecordType)
continue
}
if zoneName, _ := zoneNameIDMapper.FindZone(endpoint.DNSName); zoneName != "" {
log.Infof("Deletion at Akamai FastDNS - Zone: '%s', DNSName: '%s', RecordType: '%s', Targets: '%+v'", zoneName, endpoint.DNSName, endpoint.RecordType, endpoint.Targets)
log.Infof("Akamai Edge DNS recordset deletion- Zone: '%s', DNSName: '%s', RecordType: '%s', Targets: '%+v'", zoneName, endpoint.DNSName, endpoint.RecordType, endpoint.Targets)
if p.dryRun {
continue
}
_, err := p.request("DELETE", "config-dns/v2/zones/"+zoneName+"/names/"+endpoint.DNSName+"/types/"+endpoint.RecordType, nil)
recName := strings.TrimSuffix(endpoint.DNSName, ".")
rec, err := p.client.GetRecord(zoneName, recName, endpoint.RecordType)
if err != nil {
log.Errorf("Failed to delete Akamai endpoint DNSName: '%s' for zone: '%s'", endpoint.DNSName, zoneName)
failed = append(failed, endpoint)
if _, ok := err.(*dns.RecordError); !ok {
return fmt.Errorf("endpoint deletion. record validation failed. error: %s", err.Error())
}
log.Infof("Endpoint deletion. Record doesn't exist. Name: %s, Type: %s", recName, endpoint.RecordType)
continue
}
deleted = append(deleted, endpoint)
} else {
log.Warnf("No matching zone for endpoint deletion DNSName: '%s' RecordType: '%s'", endpoint.DNSName, endpoint.RecordType)
failed = append(failed, endpoint)
if err := p.client.DeleteRecord(rec, zoneName, true); err != nil {
log.Errorf("edge dns recordset deletion failed. error: %s", err.Error())
return err
}
}
return deleted, failed
return nil
}
func (p *AkamaiProvider) updateNewRecords(zoneNameIDMapper provider.ZoneIDName, endpoints []*endpoint.Endpoint) (updated []*endpoint.Endpoint, failed []*endpoint.Endpoint) {
// Update endpoint recordsets
func (p AkamaiProvider) updateNewRecordsets(zoneNameIDMapper provider.ZoneIDName, endpoints []*endpoint.Endpoint) error {
for _, endpoint := range endpoints {
if !p.domainFilter.Match(endpoint.DNSName) {
log.Debugf("Skipping update at Akamai of endpoint DNSName: '%s' RecordType: '%s', it does not match against Domain filters", endpoint.DNSName, endpoint.RecordType)
zoneName, _ := zoneNameIDMapper.FindZone(endpoint.DNSName)
if zoneName == "" {
log.Debugf("Skipping Akamai Edge DNS endpoint update: '%s' type: '%s', it does not match against Domain filters", endpoint.DNSName, endpoint.RecordType)
continue
}
if zoneName, _ := zoneNameIDMapper.FindZone(endpoint.DNSName); zoneName != "" {
akamaiRecord := p.newAkamaiRecord(endpoint.DNSName, endpoint.RecordType, endpoint.Targets...)
body, _ := json.MarshalIndent(akamaiRecord, "", " ")
log.Infof("Updating endpoint at Akamai FastDNS - Zone: '%s', DNSName: '%s', RecordType: '%s', Targets: '%+v'", zoneName, endpoint.DNSName, endpoint.RecordType, endpoint.Targets)
log.Infof("Akamai Edge DNS recordset update - Zone: '%s', DNSName: '%s', RecordType: '%s', Targets: '%+v'", zoneName, endpoint.DNSName, endpoint.RecordType, endpoint.Targets)
if p.dryRun {
continue
}
_, err := p.request("PUT", "config-dns/v2/zones/"+zoneName+"/names/"+endpoint.DNSName+"/types/"+endpoint.RecordType, bytes.NewReader(body))
recName := strings.TrimSuffix(endpoint.DNSName, ".")
rec, err := p.client.GetRecord(zoneName, recName, endpoint.RecordType)
if err != nil {
log.Errorf("Failed to update Akamai endpoint DNSName: '%s' for zone: '%s'", endpoint.DNSName, zoneName)
failed = append(failed, endpoint)
log.Errorf("Endpoint update. Record validation failed. Error: %s", err.Error())
return err
}
rec.TTL = ttlAsInt(endpoint.RecordTTL)
rec.Target = cleanTargets(endpoint.RecordType, endpoint.Targets...)
if err := p.client.UpdateRecord(rec, zoneName, true); err != nil {
log.Errorf("Akamai Edge DNS recordset update failed. Error: %s", err.Error())
return err
}
}
return nil
}
// edgeChangesByZone separates a multi-zone change into a single change per zone.
func edgeChangesByZone(zoneMap provider.ZoneIDName, endpoints []*endpoint.Endpoint) map[string][]*endpoint.Endpoint {
createsByZone := make(map[string][]*endpoint.Endpoint, len(zoneMap))
for _, z := range zoneMap {
createsByZone[z] = make([]*endpoint.Endpoint, 0)
}
for _, ep := range endpoints {
zone, _ := zoneMap.FindZone(ep.DNSName)
if zone != "" {
createsByZone[zone] = append(createsByZone[zone], ep)
continue
}
updated = append(updated, endpoint)
} else {
log.Warnf("No matching zone for endpoint update DNSName: '%s' RecordType: '%s'", endpoint.DNSName, endpoint.RecordType)
failed = append(failed, endpoint)
log.Debugf("Skipping Akamai Edge DNS creation of endpoint: '%s' type: '%s', it does not match against Domain filters", ep.DNSName, ep.RecordType)
}
}
return updated, failed
return createsByZone
}

View File

@ -17,148 +17,206 @@ limitations under the License.
package akamai
import (
"bytes"
"context"
"encoding/json"
"io"
"io/ioutil"
"net/http"
"net/http/httptest"
"strings"
log "github.com/sirupsen/logrus"
"testing"
"github.com/akamai/AkamaiOPEN-edgegrid-golang/edgegrid"
dns "github.com/akamai/AkamaiOPEN-edgegrid-golang/configdns-v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"sigs.k8s.io/external-dns/endpoint"
"sigs.k8s.io/external-dns/plan"
"sigs.k8s.io/external-dns/provider"
)
type mockAkamaiClient struct {
mock.Mock
type edgednsStubData struct {
objType string // zone, record, recordsets
output []interface{}
updateRecords []interface{}
createRecords []interface{}
}
func (m *mockAkamaiClient) NewRequest(config edgegrid.Config, met, p string, b io.Reader) (*http.Request, error) {
switch {
case met == "GET":
switch {
case strings.HasPrefix(p, "https:///config-dns/v2/zones?"):
b = bytes.NewReader([]byte("{\"zones\":[{\"contractId\":\"Test\",\"zone\":\"example.com\"},{\"contractId\":\"Exclude-Me\",\"zone\":\"exclude.me\"}]}"))
case strings.HasPrefix(p, "https:///config-dns/v2/zones/example.com/"):
b = bytes.NewReader([]byte("{\"recordsets\":[{\"name\":\"www.example.com\",\"type\":\"A\",\"ttl\":300,\"rdata\":[\"10.0.0.2\",\"10.0.0.3\"]},{\"name\":\"www.example.com\",\"type\":\"TXT\",\"ttl\":300,\"rdata\":[\"heritage=external-dns,external-dns/owner=default\"]}]}"))
case strings.HasPrefix(p, "https:///config-dns/v2/zones/exclude.me/"):
b = bytes.NewReader([]byte("{\"recordsets\":[{\"name\":\"www.exclude.me\",\"type\":\"A\",\"ttl\":300,\"rdata\":[\"192.168.0.1\",\"192.168.0.2\"]}]}"))
}
case met == "DELETE":
b = bytes.NewReader([]byte("{\"title\": \"Success\", \"status\": 200, \"detail\": \"Record deleted\", \"requestId\": \"4321\"}"))
case met == "ERROR":
b = bytes.NewReader([]byte("{\"status\": 404 }"))
}
req := httptest.NewRequest(met, p, b)
return req, nil
type edgednsStub struct {
stubData map[string]edgednsStubData
}
func (m *mockAkamaiClient) Do(config edgegrid.Config, req *http.Request) (*http.Response, error) {
handler := func(w http.ResponseWriter, r *http.Request) (isError bool) {
b, _ := ioutil.ReadAll(r.Body)
io.WriteString(w, string(b))
return string(b) == "{\"status\": 404 }"
func newStub() *edgednsStub {
return &edgednsStub{
stubData: make(map[string]edgednsStubData),
}
w := httptest.NewRecorder()
err := handler(w, req)
resp := w.Result()
}
if err == true {
resp.StatusCode = 400
func createAkamaiStubProvider(stub *edgednsStub, domfilter endpoint.DomainFilter, idfilter provider.ZoneIDFilter) (*AkamaiProvider, error) {
akamaiConfig := AkamaiConfig{
DomainFilter: domfilter,
ZoneIDFilter: idfilter,
ServiceConsumerDomain: "testzone.com",
ClientToken: "test_token",
ClientSecret: "test_client_secret",
AccessToken: "test_access_token",
}
prov, err := NewAkamaiProvider(akamaiConfig, stub)
aprov := prov.(*AkamaiProvider)
return aprov, err
}
func (r *edgednsStub) createStubDataEntry(objtype string) {
log.Debugf("Creating stub data entry")
if _, exists := r.stubData[objtype]; !exists {
r.stubData[objtype] = edgednsStubData{objType: objtype}
}
return
}
func (r *edgednsStub) setOutput(objtype string, output []interface{}) {
log.Debugf("Setting output to %v", output)
r.createStubDataEntry(objtype)
stubdata := r.stubData[objtype]
stubdata.output = output
r.stubData[objtype] = stubdata
return
}
func (r *edgednsStub) setUpdateRecords(objtype string, records []interface{}) {
log.Debugf("Setting updaterecords to %v", records)
r.createStubDataEntry(objtype)
stubdata := r.stubData[objtype]
stubdata.updateRecords = records
r.stubData[objtype] = stubdata
return
}
func (r *edgednsStub) setCreateRecords(objtype string, records []interface{}) {
log.Debugf("Setting createrecords to %v", records)
r.createStubDataEntry(objtype)
stubdata := r.stubData[objtype]
stubdata.createRecords = records
r.stubData[objtype] = stubdata
return
}
func (r *edgednsStub) ListZones(queryArgs dns.ZoneListQueryArgs) (*dns.ZoneListResponse, error) {
log.Debugf("Entering ListZones")
// Ignore Metadata`
resp := &dns.ZoneListResponse{}
zones := make([]*dns.ZoneResponse, 0)
for _, zname := range r.stubData["zone"].output {
log.Debugf("Processing output: %v", zname)
zn := &dns.ZoneResponse{Zone: zname.(string), ContractId: "contract"}
log.Debugf("Created Zone Object: %v", zn)
zones = append(zones, zn)
}
resp.Zones = zones
return resp, nil
}
func (r *edgednsStub) GetRecordsets(zone string, queryArgs dns.RecordsetQueryArgs) (*dns.RecordSetResponse, error) {
log.Debugf("Entering GetRecordsets")
// Ignore Metadata`
resp := &dns.RecordSetResponse{}
sets := make([]dns.Recordset, 0)
for _, rec := range r.stubData["recordset"].output {
rset := rec.(dns.Recordset)
sets = append(sets, rset)
}
resp.Recordsets = sets
return resp, nil
}
func TestRequestError(t *testing.T) {
config := AkamaiConfig{}
func (r *edgednsStub) CreateRecordsets(recordsets *dns.Recordsets, zone string, reclock bool) error {
client := &mockAkamaiClient{}
c := NewAkamaiProvider(config)
c.client = client
m := "ERROR"
p := ""
b := ""
x, err := c.request(m, p, bytes.NewReader([]byte(b)))
assert.Nil(t, x)
assert.NotNil(t, err)
return nil
}
func TestFetchZonesZoneIDFilter(t *testing.T) {
config := AkamaiConfig{
ZoneIDFilter: provider.NewZoneIDFilter([]string{"Test"}),
}
func (r *edgednsStub) GetRecord(zone string, name string, record_type string) (*dns.RecordBody, error) {
client := &mockAkamaiClient{}
c := NewAkamaiProvider(config)
c.client = client
resp := &dns.RecordBody{}
return resp, nil
}
func (r *edgednsStub) DeleteRecord(record *dns.RecordBody, zone string, recLock bool) error {
return nil
}
func (r *edgednsStub) UpdateRecord(record *dns.RecordBody, zone string, recLock bool) error {
return nil
}
// Test FetchZones
func TestFetchZonesZoneIDFilter(t *testing.T) {
stub := newStub()
domfilter := endpoint.DomainFilter{}
idfilter := provider.NewZoneIDFilter([]string{"Test"})
c, err := createAkamaiStubProvider(stub, domfilter, idfilter)
assert.Nil(t, err)
stub.setOutput("zone", []interface{}{"test1.testzone.com", "test2.testzone.com"})
x, _ := c.fetchZones()
y, _ := json.Marshal(x)
if assert.NotNil(t, y) {
assert.Equal(t, "{\"zones\":[{\"contractId\":\"Test\",\"zone\":\"example.com\"}]}", string(y))
assert.Equal(t, "{\"zones\":[{\"contractId\":\"contract\",\"zone\":\"test1.testzone.com\"},{\"contractId\":\"contract\",\"zone\":\"test2.testzone.com\"}]}", string(y))
}
}
func TestFetchZonesEmpty(t *testing.T) {
config := AkamaiConfig{
DomainFilter: endpoint.NewDomainFilter([]string{"Nonexistent"}),
ZoneIDFilter: provider.NewZoneIDFilter([]string{"Nonexistent"}),
}
client := &mockAkamaiClient{}
c := NewAkamaiProvider(config)
c.client = client
stub := newStub()
domfilter := endpoint.NewDomainFilter([]string{"Nonexistent"})
idfilter := provider.NewZoneIDFilter([]string{"Nonexistent"})
c, err := createAkamaiStubProvider(stub, domfilter, idfilter)
assert.Nil(t, err)
stub.setOutput("zone", []interface{}{})
x, _ := c.fetchZones()
y, _ := json.Marshal(x)
if assert.NotNil(t, y) {
assert.Equal(t, "{\"zones\":null}", string(y))
}
}
func TestFetchRecordset1(t *testing.T) {
config := AkamaiConfig{}
client := &mockAkamaiClient{}
c := NewAkamaiProvider(config)
c.client = client
x, _ := c.fetchRecordSet("example.com")
y, _ := json.Marshal(x)
if assert.NotNil(t, y) {
assert.Equal(t, "{\"recordsets\":[{\"name\":\"www.example.com\",\"type\":\"A\",\"ttl\":300,\"rdata\":[\"10.0.0.2\",\"10.0.0.3\"]},{\"name\":\"www.example.com\",\"type\":\"TXT\",\"ttl\":300,\"rdata\":[\"heritage=external-dns,external-dns/owner=default\"]}]}", string(y))
}
}
func TestFetchRecordset2(t *testing.T) {
config := AkamaiConfig{}
client := &mockAkamaiClient{}
c := NewAkamaiProvider(config)
c.client = client
x, _ := c.fetchRecordSet("exclude.me")
y, _ := json.Marshal(x)
if assert.NotNil(t, y) {
assert.Equal(t, "{\"recordsets\":[{\"name\":\"www.exclude.me\",\"type\":\"A\",\"ttl\":300,\"rdata\":[\"192.168.0.1\",\"192.168.0.2\"]}]}", string(y))
assert.Equal(t, "{\"zones\":[]}", string(y))
}
}
// TestAkamaiRecords tests record endpoint
func TestAkamaiRecords(t *testing.T) {
config := AkamaiConfig{}
client := &mockAkamaiClient{}
c := NewAkamaiProvider(config)
c.client = client
stub := newStub()
domfilter := endpoint.DomainFilter{}
idfilter := provider.ZoneIDFilter{}
c, err := createAkamaiStubProvider(stub, domfilter, idfilter)
assert.Nil(t, err)
stub.setOutput("zone", []interface{}{"test1.testzone.com"})
recordsets := make([]interface{}, 0)
recordsets = append(recordsets, dns.Recordset{
Name: "www.example.com",
Type: endpoint.RecordTypeA,
Rdata: []string{"10.0.0.2", "10.0.0.3"},
})
recordsets = append(recordsets, dns.Recordset{
Name: "www.example.com",
Type: endpoint.RecordTypeTXT,
Rdata: []string{"heritage=external-dns,external-dns/owner=default"},
})
recordsets = append(recordsets, dns.Recordset{
Name: "www.exclude.me",
Type: endpoint.RecordTypeA,
Rdata: []string{"192.168.0.1", "192.168.0.2"},
})
stub.setOutput("recordset", recordsets)
endpoints := make([]*endpoint.Endpoint, 0)
endpoints = append(endpoints, endpoint.NewEndpoint("www.example.com", endpoint.RecordTypeA, "10.0.0.2", "10.0.0.3"))
endpoints = append(endpoints, endpoint.NewEndpoint("www.example.com", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default"))
@ -171,28 +229,40 @@ func TestAkamaiRecords(t *testing.T) {
}
func TestAkamaiRecordsEmpty(t *testing.T) {
config := AkamaiConfig{
ZoneIDFilter: provider.NewZoneIDFilter([]string{"Nonexistent"}),
}
client := &mockAkamaiClient{}
c := NewAkamaiProvider(config)
c.client = client
stub := newStub()
domfilter := endpoint.DomainFilter{}
idfilter := provider.NewZoneIDFilter([]string{"Nonexistent"})
c, err := createAkamaiStubProvider(stub, domfilter, idfilter)
assert.Nil(t, err)
stub.setOutput("zone", []interface{}{"test1.testzone.com"})
recordsets := make([]interface{}, 0)
stub.setOutput("recordset", recordsets)
x, _ := c.Records(context.Background())
assert.Nil(t, x)
}
func TestAkamaiRecordsFilters(t *testing.T) {
config := AkamaiConfig{
DomainFilter: endpoint.NewDomainFilter([]string{"www.exclude.me"}),
ZoneIDFilter: provider.NewZoneIDFilter([]string{"Exclude-Me"}),
}
client := &mockAkamaiClient{}
c := NewAkamaiProvider(config)
c.client = client
stub := newStub()
domfilter := endpoint.NewDomainFilter([]string{"www.exclude.me"})
idfilter := provider.ZoneIDFilter{}
c, err := createAkamaiStubProvider(stub, domfilter, idfilter)
assert.Nil(t, err)
stub.setOutput("zone", []interface{}{"www.exclude.me"})
recordsets := make([]interface{}, 0)
recordsets = append(recordsets, dns.Recordset{
Name: "www.example.com",
Type: endpoint.RecordTypeA,
Rdata: []string{"10.0.0.2", "10.0.0.3"},
})
recordsets = append(recordsets, dns.Recordset{
Name: "www.exclude.me",
Type: endpoint.RecordTypeA,
Rdata: []string{"192.168.0.1", "192.168.0.2"},
})
stub.setOutput("recordset", recordsets)
endpoints := make([]*endpoint.Endpoint, 0)
endpoints = append(endpoints, endpoint.NewEndpoint("www.exclude.me", endpoint.RecordTypeA, "192.168.0.1", "192.168.0.2"))
@ -202,32 +272,32 @@ func TestAkamaiRecordsFilters(t *testing.T) {
}
}
// TestCreateRecords tests create function
// (p AkamaiProvider) createRecordsets(zoneNameIDMapper provider.ZoneIDName, endpoints []*endpoint.Endpoint) error
func TestCreateRecords(t *testing.T) {
config := AkamaiConfig{}
client := &mockAkamaiClient{}
c := NewAkamaiProvider(config)
c.client = client
stub := newStub()
domfilter := endpoint.DomainFilter{}
idfilter := provider.ZoneIDFilter{}
c, err := createAkamaiStubProvider(stub, domfilter, idfilter)
assert.Nil(t, err)
zoneNameIDMapper := provider.ZoneIDName{"example.com": "example.com"}
endpoints := make([]*endpoint.Endpoint, 0)
endpoints = append(endpoints, endpoint.NewEndpoint("www.example.com", endpoint.RecordTypeA, "10.0.0.2", "10.0.0.3"))
endpoints = append(endpoints, endpoint.NewEndpoint("www.example.com", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default"))
x, _ := c.createRecords(zoneNameIDMapper, endpoints)
if assert.NotNil(t, x) {
assert.Equal(t, endpoints, x)
}
err = c.createRecordsets(zoneNameIDMapper, endpoints)
assert.Nil(t, err)
}
func TestCreateRecordsDomainFilter(t *testing.T) {
config := AkamaiConfig{
DomainFilter: endpoint.NewDomainFilter([]string{"example.com"}),
}
client := &mockAkamaiClient{}
c := NewAkamaiProvider(config)
c.client = client
stub := newStub()
domfilter := endpoint.DomainFilter{}
idfilter := provider.ZoneIDFilter{}
c, err := createAkamaiStubProvider(stub, domfilter, idfilter)
assert.Nil(t, err)
zoneNameIDMapper := provider.ZoneIDName{"example.com": "example.com"}
endpoints := make([]*endpoint.Endpoint, 0)
@ -235,38 +305,36 @@ func TestCreateRecordsDomainFilter(t *testing.T) {
endpoints = append(endpoints, endpoint.NewEndpoint("www.example.com", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default"))
exclude := append(endpoints, endpoint.NewEndpoint("www.exclude.me", endpoint.RecordTypeA, "10.0.0.2", "10.0.0.3"))
x, _ := c.createRecords(zoneNameIDMapper, exclude)
if assert.NotNil(t, x) {
assert.Equal(t, endpoints, x)
}
err = c.createRecordsets(zoneNameIDMapper, exclude)
assert.Nil(t, err)
}
// TestDeleteRecords validate delete
func TestDeleteRecords(t *testing.T) {
config := AkamaiConfig{}
client := &mockAkamaiClient{}
c := NewAkamaiProvider(config)
c.client = client
stub := newStub()
domfilter := endpoint.DomainFilter{}
idfilter := provider.ZoneIDFilter{}
c, err := createAkamaiStubProvider(stub, domfilter, idfilter)
assert.Nil(t, err)
zoneNameIDMapper := provider.ZoneIDName{"example.com": "example.com"}
endpoints := make([]*endpoint.Endpoint, 0)
endpoints = append(endpoints, endpoint.NewEndpoint("www.example.com", endpoint.RecordTypeA, "10.0.0.2", "10.0.0.3"))
endpoints = append(endpoints, endpoint.NewEndpoint("www.example.com", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default"))
x, _ := c.deleteRecords(zoneNameIDMapper, endpoints)
if assert.NotNil(t, x) {
assert.Equal(t, endpoints, x)
}
err = c.deleteRecordsets(zoneNameIDMapper, endpoints)
assert.Nil(t, err)
}
//
func TestDeleteRecordsDomainFilter(t *testing.T) {
config := AkamaiConfig{
DomainFilter: endpoint.NewDomainFilter([]string{"example.com"}),
}
client := &mockAkamaiClient{}
c := NewAkamaiProvider(config)
c.client = client
stub := newStub()
domfilter := endpoint.NewDomainFilter([]string{"example.com"})
idfilter := provider.ZoneIDFilter{}
c, err := createAkamaiStubProvider(stub, domfilter, idfilter)
assert.Nil(t, err)
zoneNameIDMapper := provider.ZoneIDName{"example.com": "example.com"}
endpoints := make([]*endpoint.Endpoint, 0)
@ -274,38 +342,36 @@ func TestDeleteRecordsDomainFilter(t *testing.T) {
endpoints = append(endpoints, endpoint.NewEndpoint("www.example.com", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default"))
exclude := append(endpoints, endpoint.NewEndpoint("www.exclude.me", endpoint.RecordTypeA, "10.0.0.2", "10.0.0.3"))
x, _ := c.deleteRecords(zoneNameIDMapper, exclude)
if assert.NotNil(t, x) {
assert.Equal(t, endpoints, x)
}
err = c.deleteRecordsets(zoneNameIDMapper, exclude)
assert.Nil(t, err)
}
// Test record update func
func TestUpdateRecords(t *testing.T) {
config := AkamaiConfig{}
client := &mockAkamaiClient{}
c := NewAkamaiProvider(config)
c.client = client
stub := newStub()
domfilter := endpoint.DomainFilter{}
idfilter := provider.ZoneIDFilter{}
c, err := createAkamaiStubProvider(stub, domfilter, idfilter)
assert.Nil(t, err)
zoneNameIDMapper := provider.ZoneIDName{"example.com": "example.com"}
endpoints := make([]*endpoint.Endpoint, 0)
endpoints = append(endpoints, endpoint.NewEndpoint("www.example.com", endpoint.RecordTypeA, "10.0.0.2", "10.0.0.3"))
endpoints = append(endpoints, endpoint.NewEndpoint("www.example.com", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default"))
x, _ := c.updateNewRecords(zoneNameIDMapper, endpoints)
if assert.NotNil(t, x) {
assert.Equal(t, endpoints, x)
}
err = c.updateNewRecordsets(zoneNameIDMapper, endpoints)
assert.Nil(t, err)
}
//
func TestUpdateRecordsDomainFilter(t *testing.T) {
config := AkamaiConfig{
DomainFilter: endpoint.NewDomainFilter([]string{"example.com"}),
}
client := &mockAkamaiClient{}
c := NewAkamaiProvider(config)
c.client = client
stub := newStub()
domfilter := endpoint.NewDomainFilter([]string{"example.com"})
idfilter := provider.ZoneIDFilter{}
c, err := createAkamaiStubProvider(stub, domfilter, idfilter)
assert.Nil(t, err)
zoneNameIDMapper := provider.ZoneIDName{"example.com": "example.com"}
endpoints := make([]*endpoint.Endpoint, 0)
@ -313,19 +379,19 @@ func TestUpdateRecordsDomainFilter(t *testing.T) {
endpoints = append(endpoints, endpoint.NewEndpoint("www.example.com", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default"))
exclude := append(endpoints, endpoint.NewEndpoint("www.exclude.me", endpoint.RecordTypeA, "10.0.0.2", "10.0.0.3"))
x, _ := c.updateNewRecords(zoneNameIDMapper, exclude)
if assert.NotNil(t, x) {
assert.Equal(t, endpoints, x)
}
err = c.updateNewRecordsets(zoneNameIDMapper, exclude)
assert.Nil(t, err)
}
func TestAkamaiApplyChanges(t *testing.T) {
config := AkamaiConfig{}
client := &mockAkamaiClient{}
c := NewAkamaiProvider(config)
c.client = client
stub := newStub()
domfilter := endpoint.NewDomainFilter([]string{"example.com"})
idfilter := provider.ZoneIDFilter{}
c, err := createAkamaiStubProvider(stub, domfilter, idfilter)
assert.Nil(t, err)
stub.setOutput("zone", []interface{}{"example.com"})
changes := &plan.Changes{}
changes.Create = []*endpoint.Endpoint{
{DNSName: "www.example.com", RecordType: "A", Targets: endpoint.Targets{"target"}, RecordTTL: 300},

View File

@ -1152,6 +1152,7 @@ func TestAWSHealthTargetAnnotation(tt *testing.T) {
Current: []*endpoint.Endpoint{test.current},
Desired: []*endpoint.Endpoint{test.desired},
PropertyComparator: provider.PropertyValuesEqual,
ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},
}
plan = plan.Calculate()
assert.Equal(t, test.shouldUpdate, len(plan.Changes.UpdateNew) == 1)

View File

@ -19,17 +19,12 @@ package azure
import (
"context"
"fmt"
"io/ioutil"
"strings"
log "github.com/sirupsen/logrus"
yaml "gopkg.in/yaml.v2"
"github.com/Azure/azure-sdk-for-go/services/dns/mgmt/2018-05-01/dns"
"github.com/Azure/go-autorest/autorest"
"github.com/Azure/go-autorest/autorest/adal"
"github.com/Azure/go-autorest/autorest/azure"
"github.com/Azure/go-autorest/autorest/to"
"sigs.k8s.io/external-dns/endpoint"
@ -41,18 +36,6 @@ const (
azureRecordTTL = 300
)
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"`
UseManagedIdentityExtension bool `json:"useManagedIdentityExtension" yaml:"useManagedIdentityExtension"`
UserAssignedIdentityID string `json:"userAssignedIdentityID" yaml:"userAssignedIdentityID"`
}
// ZonesClient is an interface of dns.ZoneClient that can be stubbed for testing.
type ZonesClient interface {
ListByResourceGroupComplete(ctx context.Context, resourceGroupName string, top *int32) (result dns.ZoneListResultIterator, err error)
@ -82,46 +65,22 @@ type AzureProvider struct {
//
// Returns the provider or an error if a provider could not be created.
func NewAzureProvider(configFile string, domainFilter endpoint.DomainFilter, zoneNameFilter endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, resourceGroup string, userAssignedIdentityClientID string, dryRun bool) (*AzureProvider, error) {
contents, err := ioutil.ReadFile(configFile)
if err != nil {
return nil, fmt.Errorf("failed to read Azure config file '%s': %v", configFile, err)
}
cfg := config{}
err = yaml.Unmarshal(contents, &cfg)
cfg, err := getConfig(configFile, resourceGroup, userAssignedIdentityClientID)
if err != nil {
return nil, fmt.Errorf("failed to read Azure config file '%s': %v", configFile, err)
}
// If a resource group was given, override what was present in the config file
if resourceGroup != "" {
cfg.ResourceGroup = resourceGroup
}
// If userAssignedIdentityClientID is provided explicitly, override existing one in config file
if userAssignedIdentityClientID != "" {
cfg.UserAssignedIdentityID = userAssignedIdentityClientID
}
var environment azure.Environment
if cfg.Cloud == "" {
environment = azure.PublicCloud
} else {
environment, err = azure.EnvironmentFromName(cfg.Cloud)
if err != nil {
return nil, fmt.Errorf("invalid cloud value '%s': %v", cfg.Cloud, err)
}
}
token, err := getAccessToken(cfg, environment)
token, err := getAccessToken(*cfg, cfg.Environment)
if err != nil {
return nil, fmt.Errorf("failed to get token: %v", err)
}
zonesClient := dns.NewZonesClientWithBaseURI(environment.ResourceManagerEndpoint, cfg.SubscriptionID)
zonesClient := dns.NewZonesClientWithBaseURI(cfg.Environment.ResourceManagerEndpoint, cfg.SubscriptionID)
zonesClient.Authorizer = autorest.NewBearerAuthorizer(token)
recordSetsClient := dns.NewRecordSetsClientWithBaseURI(environment.ResourceManagerEndpoint, cfg.SubscriptionID)
recordSetsClient := dns.NewRecordSetsClientWithBaseURI(cfg.Environment.ResourceManagerEndpoint, cfg.SubscriptionID)
recordSetsClient.Authorizer = autorest.NewBearerAuthorizer(token)
provider := &AzureProvider{
return &AzureProvider{
domainFilter: domainFilter,
zoneNameFilter: zoneNameFilter,
zoneIDFilter: zoneIDFilter,
@ -130,61 +89,7 @@ func NewAzureProvider(configFile string, domainFilter endpoint.DomainFilter, zon
userAssignedIdentityClientID: cfg.UserAssignedIdentityID,
zonesClient: zonesClient,
recordSetsClient: recordSetsClient,
}
return provider, nil
}
// getAccessToken retrieves Azure API access token.
func getAccessToken(cfg config, environment azure.Environment) (*adal.ServicePrincipalToken, error) {
// Try to retrieve token with service principal credentials.
// Try to use service principal first, some AKS clusters are in an intermediate state that `UseManagedIdentityExtension` is `true`
// and service principal exists. In this case, we still want to use service principal to authenticate.
if len(cfg.ClientID) > 0 &&
len(cfg.ClientSecret) > 0 &&
// due to some historical reason, for pure MSI cluster,
// they will use "msi" as placeholder in azure.json.
// In this case, we shouldn't try to use SPN to authenticate.
!strings.EqualFold(cfg.ClientID, "msi") &&
!strings.EqualFold(cfg.ClientSecret, "msi") {
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
}
// Try to retrieve 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)
}
if cfg.UserAssignedIdentityID != "" {
log.Infof("Resolving to user assigned identity, client id is %s.", cfg.UserAssignedIdentityID)
token, err := adal.NewServicePrincipalTokenFromMSIWithUserAssignedID(msiEndpoint, environment.ServiceManagementEndpoint, cfg.UserAssignedIdentityID)
if err != nil {
return nil, fmt.Errorf("failed to create the managed service identity token: %v", err)
}
return token, nil
}
log.Info("Resolving to system assigned identity.")
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
}
return nil, fmt.Errorf("no credentials provided for Azure API")
}, nil
}
// Records gets the current records.
@ -352,20 +257,20 @@ func (p *AzureProvider) mapChanges(zones []dns.Zone, changes *plan.Changes) (azu
func (p *AzureProvider) deleteRecords(ctx context.Context, deleted azureChangeMap) {
// Delete records first
for zone, endpoints := range deleted {
for _, endpoint := range endpoints {
name := p.recordSetNameForZone(zone, endpoint)
if !p.domainFilter.Match(endpoint.DNSName) {
log.Debugf("Skipping deletion of record %s because it was filtered out by the specified --domain-filter", endpoint.DNSName)
for _, ep := range endpoints {
name := p.recordSetNameForZone(zone, ep)
if !p.domainFilter.Match(ep.DNSName) {
log.Debugf("Skipping deletion of record %s because it was filtered out by the specified --domain-filter", ep.DNSName)
continue
}
if p.dryRun {
log.Infof("Would delete %s record named '%s' for Azure DNS zone '%s'.", endpoint.RecordType, name, zone)
log.Infof("Would delete %s record named '%s' for Azure DNS zone '%s'.", ep.RecordType, name, zone)
} else {
log.Infof("Deleting %s record named '%s' for Azure DNS zone '%s'.", endpoint.RecordType, name, zone)
if _, err := p.recordSetsClient.Delete(ctx, p.resourceGroup, zone, name, dns.RecordType(endpoint.RecordType), ""); err != nil {
log.Infof("Deleting %s record named '%s' for Azure DNS zone '%s'.", ep.RecordType, name, zone)
if _, err := p.recordSetsClient.Delete(ctx, p.resourceGroup, zone, name, dns.RecordType(ep.RecordType), ""); err != nil {
log.Errorf(
"Failed to delete %s record named '%s' for Azure DNS zone '%s': %v",
endpoint.RecordType,
ep.RecordType,
name,
zone,
err,
@ -378,18 +283,18 @@ func (p *AzureProvider) deleteRecords(ctx context.Context, deleted azureChangeMa
func (p *AzureProvider) updateRecords(ctx context.Context, updated azureChangeMap) {
for zone, endpoints := range updated {
for _, endpoint := range endpoints {
name := p.recordSetNameForZone(zone, endpoint)
if !p.domainFilter.Match(endpoint.DNSName) {
log.Debugf("Skipping update of record %s because it was filtered out by the specified --domain-filter", endpoint.DNSName)
for _, ep := range endpoints {
name := p.recordSetNameForZone(zone, ep)
if !p.domainFilter.Match(ep.DNSName) {
log.Debugf("Skipping update of record %s because it was filtered out by the specified --domain-filter", ep.DNSName)
continue
}
if p.dryRun {
log.Infof(
"Would update %s record named '%s' to '%s' for Azure DNS zone '%s'.",
endpoint.RecordType,
ep.RecordType,
name,
endpoint.Targets,
ep.Targets,
zone,
)
continue
@ -397,20 +302,20 @@ func (p *AzureProvider) updateRecords(ctx context.Context, updated azureChangeMa
log.Infof(
"Updating %s record named '%s' to '%s' for Azure DNS zone '%s'.",
endpoint.RecordType,
ep.RecordType,
name,
endpoint.Targets,
ep.Targets,
zone,
)
recordSet, err := p.newRecordSet(endpoint)
recordSet, err := p.newRecordSet(ep)
if err == nil {
_, err = p.recordSetsClient.CreateOrUpdate(
ctx,
p.resourceGroup,
zone,
name,
dns.RecordType(endpoint.RecordType),
dns.RecordType(ep.RecordType),
recordSet,
"",
"",
@ -419,9 +324,9 @@ func (p *AzureProvider) updateRecords(ctx context.Context, updated azureChangeMa
if err != nil {
log.Errorf(
"Failed to update %s record named '%s' to '%s' for DNS zone '%s': %v",
endpoint.RecordType,
ep.RecordType,
name,
endpoint.Targets,
ep.Targets,
zone,
err,
)

View File

@ -21,9 +21,8 @@ import (
"fmt"
"strings"
"github.com/Azure/azure-sdk-for-go/profiles/latest/privatedns/mgmt/privatedns"
"github.com/Azure/azure-sdk-for-go/services/privatedns/mgmt/2018-09-01/privatedns"
"github.com/Azure/go-autorest/autorest"
"github.com/Azure/go-autorest/autorest/azure/auth"
"github.com/Azure/go-autorest/autorest/to"
log "github.com/sirupsen/logrus"
@ -50,8 +49,8 @@ type AzurePrivateDNSProvider struct {
domainFilter endpoint.DomainFilter
zoneIDFilter provider.ZoneIDFilter
dryRun bool
subscriptionID string
resourceGroup string
userAssignedIdentityClientID string
zonesClient PrivateZonesClient
recordSetsClient PrivateRecordSetsClient
}
@ -59,32 +58,31 @@ type AzurePrivateDNSProvider struct {
// NewAzurePrivateDNSProvider creates a new Azure Private DNS provider.
//
// Returns the provider or an error if a provider could not be created.
func NewAzurePrivateDNSProvider(domainFilter endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, resourceGroup string, subscriptionID string, dryRun bool) (*AzurePrivateDNSProvider, error) {
authorizer, err := auth.NewAuthorizerFromEnvironment()
func NewAzurePrivateDNSProvider(configFile string, domainFilter endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, resourceGroup, userAssignedIdentityClientID string, dryRun bool) (*AzurePrivateDNSProvider, error) {
cfg, err := getConfig(configFile, resourceGroup, userAssignedIdentityClientID)
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to read Azure config file '%s': %v", configFile, err)
}
settings, err := auth.GetSettingsFromEnvironment()
token, err := getAccessToken(*cfg, cfg.Environment)
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to get token: %v", err)
}
zonesClient := privatedns.NewPrivateZonesClientWithBaseURI(settings.Environment.ResourceManagerEndpoint, subscriptionID)
zonesClient.Authorizer = authorizer
recordSetsClient := privatedns.NewRecordSetsClientWithBaseURI(settings.Environment.ResourceManagerEndpoint, subscriptionID)
recordSetsClient.Authorizer = authorizer
zonesClient := privatedns.NewPrivateZonesClientWithBaseURI(cfg.Environment.ResourceManagerEndpoint, cfg.SubscriptionID)
zonesClient.Authorizer = autorest.NewBearerAuthorizer(token)
recordSetsClient := privatedns.NewRecordSetsClientWithBaseURI(cfg.Environment.ResourceManagerEndpoint, cfg.SubscriptionID)
recordSetsClient.Authorizer = autorest.NewBearerAuthorizer(token)
provider := &AzurePrivateDNSProvider{
return &AzurePrivateDNSProvider{
domainFilter: domainFilter,
zoneIDFilter: zoneIDFilter,
dryRun: dryRun,
subscriptionID: subscriptionID,
resourceGroup: resourceGroup,
resourceGroup: cfg.ResourceGroup,
userAssignedIdentityClientID: cfg.UserAssignedIdentityID,
zonesClient: zonesClient,
recordSetsClient: recordSetsClient,
}
return provider, nil
}, nil
}
// Records gets the current records.
@ -256,16 +254,16 @@ func (p *AzurePrivateDNSProvider) deleteRecords(ctx context.Context, deleted azu
log.Debugf("Records to be deleted: %d", len(deleted))
// Delete records first
for zone, endpoints := range deleted {
for _, endpoint := range endpoints {
name := p.recordSetNameForZone(zone, endpoint)
for _, ep := range endpoints {
name := p.recordSetNameForZone(zone, ep)
if p.dryRun {
log.Infof("Would delete %s record named '%s' for Azure Private DNS zone '%s'.", endpoint.RecordType, name, zone)
log.Infof("Would delete %s record named '%s' for Azure Private DNS zone '%s'.", ep.RecordType, name, zone)
} else {
log.Infof("Deleting %s record named '%s' for Azure Private DNS zone '%s'.", endpoint.RecordType, name, zone)
if _, err := p.recordSetsClient.Delete(ctx, p.resourceGroup, zone, privatedns.RecordType(endpoint.RecordType), name, ""); err != nil {
log.Infof("Deleting %s record named '%s' for Azure Private DNS zone '%s'.", ep.RecordType, name, zone)
if _, err := p.recordSetsClient.Delete(ctx, p.resourceGroup, zone, privatedns.RecordType(ep.RecordType), name, ""); err != nil {
log.Errorf(
"Failed to delete %s record named '%s' for Azure Private DNS zone '%s': %v",
endpoint.RecordType,
ep.RecordType,
name,
zone,
err,
@ -279,14 +277,14 @@ func (p *AzurePrivateDNSProvider) deleteRecords(ctx context.Context, deleted azu
func (p *AzurePrivateDNSProvider) updateRecords(ctx context.Context, updated azurePrivateDNSChangeMap) {
log.Debugf("Records to be updated: %d", len(updated))
for zone, endpoints := range updated {
for _, endpoint := range endpoints {
name := p.recordSetNameForZone(zone, endpoint)
for _, ep := range endpoints {
name := p.recordSetNameForZone(zone, ep)
if p.dryRun {
log.Infof(
"Would update %s record named '%s' to '%s' for Azure Private DNS zone '%s'.",
endpoint.RecordType,
ep.RecordType,
name,
endpoint.Targets,
ep.Targets,
zone,
)
continue
@ -294,19 +292,19 @@ func (p *AzurePrivateDNSProvider) updateRecords(ctx context.Context, updated azu
log.Infof(
"Updating %s record named '%s' to '%s' for Azure Private DNS zone '%s'.",
endpoint.RecordType,
ep.RecordType,
name,
endpoint.Targets,
ep.Targets,
zone,
)
recordSet, err := p.newRecordSet(endpoint)
recordSet, err := p.newRecordSet(ep)
if err == nil {
_, err = p.recordSetsClient.CreateOrUpdate(
ctx,
p.resourceGroup,
zone,
privatedns.RecordType(endpoint.RecordType),
privatedns.RecordType(ep.RecordType),
name,
recordSet,
"",
@ -316,9 +314,9 @@ func (p *AzurePrivateDNSProvider) updateRecords(ctx context.Context, updated azu
if err != nil {
log.Errorf(
"Failed to update %s record named '%s' to '%s' for Azure Private DNS zone '%s': %v",
endpoint.RecordType,
ep.RecordType,
name,
endpoint.Targets,
ep.Targets,
zone,
err,
)

View File

@ -18,16 +18,11 @@ package azure
import (
"context"
"os"
"testing"
"github.com/Azure/azure-sdk-for-go/services/privatedns/mgmt/2018-09-01/privatedns"
"github.com/Azure/go-autorest/autorest"
"github.com/Azure/go-autorest/autorest/azure"
"github.com/Azure/go-autorest/autorest/azure/auth"
"github.com/Azure/go-autorest/autorest/to"
"github.com/stretchr/testify/assert"
"sigs.k8s.io/external-dns/endpoint"
"sigs.k8s.io/external-dns/plan"
"sigs.k8s.io/external-dns/provider"
@ -255,36 +250,6 @@ func newAzurePrivateDNSProvider(domainFilter endpoint.DomainFilter, zoneIDFilter
}
}
func validateAzurePrivateDNSClientsResourceManager(t *testing.T, environmentName string, expectedResourceManagerEndpoint string) {
err := os.Setenv(auth.EnvironmentName, environmentName)
if err != nil {
t.Fatal(err)
}
azurePrivateDNSProvider, err := NewAzurePrivateDNSProvider(endpoint.NewDomainFilter([]string{"example.com"}), provider.NewZoneIDFilter([]string{""}), "k8s", "sub", true)
if err != nil {
t.Fatal(err)
}
zonesClientBaseURI := azurePrivateDNSProvider.zonesClient.(privatedns.PrivateZonesClient).BaseURI
recordSetsClientBaseURI := azurePrivateDNSProvider.recordSetsClient.(privatedns.RecordSetsClient).BaseURI
assert.Equal(t, zonesClientBaseURI, expectedResourceManagerEndpoint, "expected and actual resource manager endpoints don't match. expected: %s, got: %s", expectedResourceManagerEndpoint, zonesClientBaseURI)
assert.Equal(t, recordSetsClientBaseURI, expectedResourceManagerEndpoint, "expected and actual resource manager endpoints don't match. expected: %s, got: %s", expectedResourceManagerEndpoint, recordSetsClientBaseURI)
}
func TestNewAzurePrivateDNSProvider(t *testing.T) {
// make sure to reset the environment variables at the end again
originalEnv := os.Getenv(auth.EnvironmentName)
defer os.Setenv(auth.EnvironmentName, originalEnv)
validateAzurePrivateDNSClientsResourceManager(t, "", azure.PublicCloud.ResourceManagerEndpoint)
validateAzurePrivateDNSClientsResourceManager(t, "AZURECHINACLOUD", azure.ChinaCloud.ResourceManagerEndpoint)
validateAzurePrivateDNSClientsResourceManager(t, "AZUREGERMANCLOUD", azure.GermanCloud.ResourceManagerEndpoint)
validateAzurePrivateDNSClientsResourceManager(t, "AZUREUSGOVERNMENTCLOUD", azure.USGovernmentCloud.ResourceManagerEndpoint)
}
func TestAzurePrivateDNSRecord(t *testing.T) {
provider, err := newMockedAzurePrivateDNSProvider(endpoint.NewDomainFilter([]string{"example.com"}), provider.NewZoneIDFilter([]string{""}), true, "k8s",
&[]privatedns.PrivateZone{

129
provider/azure/config.go Normal file
View File

@ -0,0 +1,129 @@
/*
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 azure
import (
"fmt"
"io/ioutil"
"strings"
"github.com/Azure/go-autorest/autorest/adal"
"github.com/Azure/go-autorest/autorest/azure"
log "github.com/sirupsen/logrus"
"gopkg.in/yaml.v2"
)
// config represents common config items for Azure DNS and Azure Private DNS
type config struct {
Cloud string `json:"cloud" yaml:"cloud"`
Environment azure.Environment `json:"-" yaml:"-"`
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"`
UserAssignedIdentityID string `json:"userAssignedIdentityID" yaml:"userAssignedIdentityID"`
}
func getConfig(configFile, resourceGroup, userAssignedIdentityClientID string) (*config, error) {
contents, err := ioutil.ReadFile(configFile)
if err != nil {
return nil, fmt.Errorf("failed to read Azure config file '%s': %v", configFile, err)
}
cfg := &config{}
err = yaml.Unmarshal(contents, &cfg)
if err != nil {
return nil, fmt.Errorf("failed to read Azure config file '%s': %v", configFile, err)
}
// If a resource group was given, override what was present in the config file
if resourceGroup != "" {
cfg.ResourceGroup = resourceGroup
}
// If userAssignedIdentityClientID is provided explicitly, override existing one in config file
if userAssignedIdentityClientID != "" {
cfg.UserAssignedIdentityID = userAssignedIdentityClientID
}
var environment azure.Environment
if cfg.Cloud == "" {
environment = azure.PublicCloud
} else {
environment, err = azure.EnvironmentFromName(cfg.Cloud)
if err != nil {
return nil, fmt.Errorf("invalid cloud value '%s': %v", cfg.Cloud, err)
}
}
cfg.Environment = environment
return cfg, nil
}
// getAccessToken retrieves Azure API access token.
func getAccessToken(cfg config, environment azure.Environment) (*adal.ServicePrincipalToken, error) {
// Try to retrieve token with service principal credentials.
// Try to use service principal first, some AKS clusters are in an intermediate state that `UseManagedIdentityExtension` is `true`
// and service principal exists. In this case, we still want to use service principal to authenticate.
if len(cfg.ClientID) > 0 &&
len(cfg.ClientSecret) > 0 &&
// due to some historical reason, for pure MSI cluster,
// they will use "msi" as placeholder in azure.json.
// In this case, we shouldn't try to use SPN to authenticate.
!strings.EqualFold(cfg.ClientID, "msi") &&
!strings.EqualFold(cfg.ClientSecret, "msi") {
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
}
// Try to retrieve 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)
}
if cfg.UserAssignedIdentityID != "" {
log.Infof("Resolving to user assigned identity, client id is %s.", cfg.UserAssignedIdentityID)
token, err := adal.NewServicePrincipalTokenFromMSIWithUserAssignedID(msiEndpoint, environment.ServiceManagementEndpoint, cfg.UserAssignedIdentityID)
if err != nil {
return nil, fmt.Errorf("failed to create the managed service identity token: %v", err)
}
return token, nil
}
log.Info("Resolving to system assigned identity.")
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
}
return nil, fmt.Errorf("no credentials provided for Azure API")
}

View File

@ -0,0 +1,67 @@
/*
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 azure
import (
"fmt"
"github.com/Azure/go-autorest/autorest/azure"
"io/ioutil"
"os"
"reflect"
"testing"
)
func TestGetAzureEnvironmentConfig(t *testing.T) {
tmp, err := ioutil.TempFile("", "azureconf")
if err != nil {
t.Errorf("couldn't write temp file %v", err)
}
defer os.Remove(tmp.Name())
tests := map[string]struct {
cloud string
err error
}{
"AzureChinaCloud": {"AzureChinaCloud", nil},
"AzureGermanCloud": {"AzureGermanCloud", nil},
"AzurePublicCloud": {"", nil},
"AzureUSGovernment": {"AzureUSGovernmentCloud", nil},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
_, _ = tmp.Seek(0, 0)
_, _ = tmp.Write([]byte(fmt.Sprintf(`{"cloud": "%s"}`, test.cloud)))
got, err := getConfig(tmp.Name(), "", "")
if err != nil {
t.Errorf("got unexpected err %v", err)
}
if test.cloud == "" {
test.cloud = "AzurePublicCloud"
}
want, err := azure.EnvironmentFromName(test.cloud)
if err != nil {
t.Errorf("couldn't get azure environment from provided name %v", err)
}
if !reflect.DeepEqual(want, got.Environment) {
t.Errorf("got %v, want %v", got.Environment, want)
}
})
}
}

View File

@ -229,7 +229,7 @@ func (m *mockCloudFlareClient) ZoneDetails(zoneID string) (cloudflare.Zone, erro
return cloudflare.Zone{}, errors.New("Unknown zoneID: " + zoneID)
}
func AssertActions(t *testing.T, provider *CloudFlareProvider, endpoints []*endpoint.Endpoint, actions []MockAction, args ...interface{}) {
func AssertActions(t *testing.T, provider *CloudFlareProvider, endpoints []*endpoint.Endpoint, actions []MockAction, managedRecords []string, args ...interface{}) {
t.Helper()
var client *mockCloudFlareClient
@ -253,6 +253,7 @@ func AssertActions(t *testing.T, provider *CloudFlareProvider, endpoints []*endp
Current: records,
Desired: endpoints,
DomainFilter: endpoint.NewDomainFilter([]string{"bar.com"}),
ManagedRecords: managedRecords,
}
changes := plan.Calculate().Changes
@ -305,7 +306,10 @@ func TestCloudflareA(t *testing.T) {
Proxied: false,
},
},
})
},
[]string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},
)
}
func TestCloudflareCname(t *testing.T) {
@ -340,7 +344,9 @@ func TestCloudflareCname(t *testing.T) {
Proxied: false,
},
},
})
},
[]string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},
)
}
func TestCloudflareCustomTTL(t *testing.T) {
@ -365,7 +371,9 @@ func TestCloudflareCustomTTL(t *testing.T) {
Proxied: false,
},
},
})
},
[]string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},
)
}
func TestCloudflareProxiedDefault(t *testing.T) {
@ -389,7 +397,9 @@ func TestCloudflareProxiedDefault(t *testing.T) {
Proxied: true,
},
},
})
},
[]string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},
)
}
func TestCloudflareProxiedOverrideTrue(t *testing.T) {
@ -419,7 +429,9 @@ func TestCloudflareProxiedOverrideTrue(t *testing.T) {
Proxied: true,
},
},
})
},
[]string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},
)
}
func TestCloudflareProxiedOverrideFalse(t *testing.T) {
@ -449,7 +461,9 @@ func TestCloudflareProxiedOverrideFalse(t *testing.T) {
Proxied: false,
},
},
})
},
[]string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},
)
}
func TestCloudflareProxiedOverrideIllegal(t *testing.T) {
@ -479,7 +493,9 @@ func TestCloudflareProxiedOverrideIllegal(t *testing.T) {
Proxied: true,
},
},
})
},
[]string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},
)
}
func TestCloudflareSetProxied(t *testing.T) {
@ -525,7 +541,7 @@ func TestCloudflareSetProxied(t *testing.T) {
Proxied: testCase.proxiable,
},
},
}, testCase.recordType+" record on "+testCase.domain)
}, []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME, endpoint.RecordTypeNS}, testCase.recordType+" record on "+testCase.domain)
}
}
@ -1084,6 +1100,7 @@ func TestProviderPropertiesIdempotency(t *testing.T) {
Current: current,
Desired: desired,
PropertyComparator: provider.PropertyValuesEqual,
ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},
}
plan = *plan.Calculate()
@ -1138,6 +1155,7 @@ func TestCloudflareComplexUpdate(t *testing.T) {
},
},
DomainFilter: endpoint.NewDomainFilter([]string{"bar.com"}),
ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},
}
planned := plan.Calculate()
@ -1227,6 +1245,7 @@ func TestCustomTTLWithEnabledProxyNotChanged(t *testing.T) {
Current: records,
Desired: endpoints,
DomainFilter: endpoint.NewDomainFilter([]string{"bar.com"}),
ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeCNAME},
}
planned := plan.Calculate()

300
provider/godaddy/client.go Normal file
View File

@ -0,0 +1,300 @@
/*
Copyright 2020 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package godaddy
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net/http"
"time"
"sigs.k8s.io/external-dns/pkg/apis/externaldns"
)
// DefaultTimeout api requests after 180s
const DefaultTimeout = 180 * time.Second
// Errors
var (
ErrAPIDown = errors.New("godaddy: the GoDaddy API is down")
)
// APIError error
type APIError struct {
Code string
Message string
}
func (err *APIError) Error() string {
return fmt.Sprintf("Error %s: %q", err.Code, err.Message)
}
// Logger is the interface that should be implemented for loggers that wish to
// log HTTP requests and HTTP responses.
type Logger interface {
// LogRequest logs an HTTP request.
LogRequest(*http.Request)
// LogResponse logs an HTTP response.
LogResponse(*http.Response)
}
// Client represents a client to call the GoDaddy API
type Client struct {
// APIKey holds the Application key
APIKey string
// APISecret holds the Application secret key
APISecret string
// API endpoint
APIEndPoint string
// Client is the underlying HTTP client used to run the requests. It may be overloaded but a default one is instanciated in ``NewClient`` by default.
Client *http.Client
// Logger is used to log HTTP requests and responses.
Logger Logger
Timeout time.Duration
}
// NewClient represents a new client to call the API
func NewClient(useOTE bool, apiKey, apiSecret string) (*Client, error) {
var endpoint string
if useOTE {
endpoint = " https://api.ote-godaddy.com"
} else {
endpoint = "https://api.godaddy.com"
}
client := Client{
APIKey: apiKey,
APISecret: apiSecret,
APIEndPoint: endpoint,
Client: &http.Client{},
Timeout: DefaultTimeout,
}
// Get and check the configuration
if err := client.validate(); err != nil {
return nil, err
}
return &client, nil
}
//
// Common request wrappers
//
// Get is a wrapper for the GET method
func (c *Client) Get(url string, resType interface{}) error {
return c.CallAPI("GET", url, nil, resType, true)
}
// Patch is a wrapper for the POST method
func (c *Client) Patch(url string, reqBody, resType interface{}) error {
return c.CallAPI("PATCH", url, reqBody, resType, true)
}
// Post is a wrapper for the POST method
func (c *Client) Post(url string, reqBody, resType interface{}) error {
return c.CallAPI("POST", url, reqBody, resType, true)
}
// Put is a wrapper for the PUT method
func (c *Client) Put(url string, reqBody, resType interface{}) error {
return c.CallAPI("PUT", url, reqBody, resType, true)
}
// Delete is a wrapper for the DELETE method
func (c *Client) Delete(url string, resType interface{}) error {
return c.CallAPI("DELETE", url, nil, resType, true)
}
// GetWithContext is a wrapper for the GET method
func (c *Client) GetWithContext(ctx context.Context, url string, resType interface{}) error {
return c.CallAPIWithContext(ctx, "GET", url, nil, resType, true)
}
// PatchWithContext is a wrapper for the POST method
func (c *Client) PatchWithContext(ctx context.Context, url string, reqBody, resType interface{}) error {
return c.CallAPIWithContext(ctx, "PATCH", url, reqBody, resType, true)
}
// PostWithContext is a wrapper for the POST method
func (c *Client) PostWithContext(ctx context.Context, url string, reqBody, resType interface{}) error {
return c.CallAPIWithContext(ctx, "POST", url, reqBody, resType, true)
}
// PutWithContext is a wrapper for the PUT method
func (c *Client) PutWithContext(ctx context.Context, url string, reqBody, resType interface{}) error {
return c.CallAPIWithContext(ctx, "PUT", url, reqBody, resType, true)
}
// DeleteWithContext is a wrapper for the DELETE method
func (c *Client) DeleteWithContext(ctx context.Context, url string, resType interface{}) error {
return c.CallAPIWithContext(ctx, "DELETE", url, nil, resType, true)
}
// NewRequest returns a new HTTP request
func (c *Client) NewRequest(method, path string, reqBody interface{}, needAuth bool) (*http.Request, error) {
var body []byte
var err error
if reqBody != nil {
body, err = json.Marshal(reqBody)
if err != nil {
return nil, err
}
}
target := fmt.Sprintf("%s%s", c.APIEndPoint, path)
req, err := http.NewRequest(method, target, bytes.NewReader(body))
if err != nil {
return nil, err
}
// Inject headers
if body != nil {
req.Header.Set("Content-Type", "application/json;charset=utf-8")
}
req.Header.Set("Authorization", fmt.Sprintf("sso-key %s:%s", c.APIKey, c.APISecret))
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "ExternalDNS/"+externaldns.Version)
// Send the request with requested timeout
c.Client.Timeout = c.Timeout
return req, nil
}
// Do sends an HTTP request and returns an HTTP response
func (c *Client) Do(req *http.Request) (*http.Response, error) {
if c.Logger != nil {
c.Logger.LogRequest(req)
}
resp, err := c.Client.Do(req)
if err != nil {
return nil, err
}
if c.Logger != nil {
c.Logger.LogResponse(resp)
}
return resp, nil
}
// CallAPI is the lowest level call helper. If needAuth is true,
// inject authentication headers and sign the request.
//
// Request signature is a sha1 hash on following fields, joined by '+':
// - applicationSecret (from Client instance)
// - consumerKey (from Client instance)
// - capitalized method (from arguments)
// - full request url, including any query string argument
// - full serialized request body
// - server current time (takes time delta into account)
//
// Call will automatically assemble the target url from the endpoint
// configured in the client instance and the path argument. If the reqBody
// argument is not nil, it will also serialize it as json and inject
// the required Content-Type header.
//
// If everything went fine, unmarshall response into resType and return nil
// otherwise, return the error
func (c *Client) CallAPI(method, path string, reqBody, resType interface{}, needAuth bool) error {
return c.CallAPIWithContext(context.Background(), method, path, reqBody, resType, needAuth)
}
// CallAPIWithContext is the lowest level call helper. If needAuth is true,
// inject authentication headers and sign the request.
//
// Request signature is a sha1 hash on following fields, joined by '+':
// - applicationSecret (from Client instance)
// - consumerKey (from Client instance)
// - capitalized method (from arguments)
// - full request url, including any query string argument
// - full serialized request body
// - server current time (takes time delta into account)
//
// Context is used by http.Client to handle context cancelation
//
// Call will automatically assemble the target url from the endpoint
// configured in the client instance and the path argument. If the reqBody
// argument is not nil, it will also serialize it as json and inject
// the required Content-Type header.
//
// If everything went fine, unmarshall response into resType and return nil
// otherwise, return the error
func (c *Client) CallAPIWithContext(ctx context.Context, method, path string, reqBody, resType interface{}, needAuth bool) error {
req, err := c.NewRequest(method, path, reqBody, needAuth)
if err != nil {
return err
}
req = req.WithContext(ctx)
response, err := c.Do(req)
if err != nil {
return err
}
return c.UnmarshalResponse(response, resType)
}
// UnmarshalResponse checks the response and unmarshals it into the response
// type if needed Helper function, called from CallAPI
func (c *Client) UnmarshalResponse(response *http.Response, resType interface{}) error {
// Read all the response body
defer response.Body.Close()
body, err := ioutil.ReadAll(response.Body)
if err != nil {
return err
}
// < 200 && >= 300 : API error
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
apiError := &APIError{
Code: fmt.Sprintf("HTTPStatus: %d", response.StatusCode),
}
if err = json.Unmarshal(body, apiError); err != nil {
return err
}
return apiError
}
// Nothing to unmarshal
if len(body) == 0 || resType == nil {
return nil
}
return json.Unmarshal(body, &resType)
}
func (c *Client) validate() error {
var response interface{}
if err := c.Get("/v1/domains?statuses=ACTIVE", response); err != nil {
return err
}
return nil
}

508
provider/godaddy/godaddy.go Normal file
View File

@ -0,0 +1,508 @@
/*
Copyright 2020 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package godaddy
import (
"context"
"errors"
"fmt"
"strings"
log "github.com/sirupsen/logrus"
"golang.org/x/sync/errgroup"
"sigs.k8s.io/external-dns/endpoint"
"sigs.k8s.io/external-dns/plan"
"sigs.k8s.io/external-dns/provider"
)
const (
gdMinimalTTL = 600
gdCreate = iota
gdUpdate
gdDelete
)
var (
// ErrRecordToMutateNotFound when ApplyChange has to update/delete and didn't found the record in the existing zone (Change with no record ID)
ErrRecordToMutateNotFound = errors.New("record to mutate not found in current zone")
// ErrNoDryRun No dry run support for the moment
ErrNoDryRun = errors.New("dry run not supported")
)
type gdClient interface {
Patch(string, interface{}, interface{}) error
Post(string, interface{}, interface{}) error
Put(string, interface{}, interface{}) error
Get(string, interface{}) error
Delete(string, interface{}) error
}
// GDProvider declare GoDaddy provider
type GDProvider struct {
provider.BaseProvider
domainFilter endpoint.DomainFilter
client gdClient
ttl int64
DryRun bool
}
type gdEndpoint struct {
endpoint *endpoint.Endpoint
action int
}
type gdRecordField struct {
Data string `json:"data"`
Name string `json:"name"`
TTL int64 `json:"ttl"`
Type string `json:"type"`
Port *int `json:"port,omitempty"`
Priority *int `json:"priority,omitempty"`
Weight *int64 `json:"weight,omitempty"`
Protocol *string `json:"protocol,omitempty"`
Service *string `json:"service,omitempty"`
}
type gdRecords struct {
records []gdRecordField
changed bool
zone string
}
type gdZone struct {
CreatedAt string
Domain string
DomainID int64
ExpirationProtected bool
Expires string
ExposeWhois bool
HoldRegistrar bool
Locked bool
NameServers *[]string
Privacy bool
RenewAuto bool
RenewDeadline string
Renewable bool
Status string
TransferProtected bool
}
type gdZoneIDName map[string]*gdRecords
func (z gdZoneIDName) add(zoneID string, zoneRecord *gdRecords) {
z[zoneID] = zoneRecord
}
func (z gdZoneIDName) findZoneRecord(hostname string) (suitableZoneID string, suitableZoneRecord *gdRecords) {
for zoneID, zoneRecord := range z {
if hostname == zoneRecord.zone || strings.HasSuffix(hostname, "."+zoneRecord.zone) {
if suitableZoneRecord == nil || len(zoneRecord.zone) > len(suitableZoneRecord.zone) {
suitableZoneID = zoneID
suitableZoneRecord = zoneRecord
}
}
}
return
}
// NewGoDaddyProvider initializes a new GoDaddy DNS based Provider.
func NewGoDaddyProvider(ctx context.Context, domainFilter endpoint.DomainFilter, ttl int64, apiKey, apiSecret string, useOTE, dryRun bool) (*GDProvider, error) {
client, err := NewClient(useOTE, apiKey, apiSecret)
if err != nil {
return nil, err
}
// TODO: Add Dry Run support
if dryRun {
return nil, ErrNoDryRun
}
return &GDProvider{
client: client,
domainFilter: domainFilter,
ttl: maxOf(gdMinimalTTL, ttl),
DryRun: dryRun,
}, nil
}
func (p *GDProvider) zones() ([]string, error) {
zones := []gdZone{}
filteredZones := []string{}
if err := p.client.Get("/v1/domains?statuses=ACTIVE", &zones); err != nil {
return nil, err
}
for _, zone := range zones {
if p.domainFilter.Match(zone.Domain) {
filteredZones = append(filteredZones, zone.Domain)
log.Debugf("GoDaddy: %s zone found", zone.Domain)
}
}
log.Infof("GoDaddy: %d zones found", len(filteredZones))
return filteredZones, nil
}
func (p *GDProvider) zonesRecords(ctx context.Context, all bool) ([]string, []gdRecords, error) {
var allRecords []gdRecords
zones, err := p.zones()
if err != nil {
return nil, nil, err
}
if len(zones) == 0 {
allRecords = []gdRecords{}
} else if len(zones) == 1 {
record, err := p.records(&ctx, zones[0], all)
if err != nil {
return nil, nil, err
}
allRecords = append(allRecords, *record)
} else {
chRecords := make(chan gdRecords, len(zones))
eg, ctx := errgroup.WithContext(ctx)
for _, zoneName := range zones {
zone := zoneName
eg.Go(func() error {
record, err := p.records(&ctx, zone, all)
if err != nil {
return err
}
chRecords <- *record
return nil
})
}
if err := eg.Wait(); err != nil {
return nil, nil, err
}
close(chRecords)
for records := range chRecords {
allRecords = append(allRecords, records)
}
}
return zones, allRecords, nil
}
func (p *GDProvider) records(ctx *context.Context, zone string, all bool) (*gdRecords, error) {
var recordsIds []gdRecordField
log.Debugf("GoDaddy: Getting records for %s", zone)
if err := p.client.Get(fmt.Sprintf("/v1/domains/%s/records", zone), &recordsIds); err != nil {
return nil, err
}
if all {
return &gdRecords{
zone: zone,
records: recordsIds,
}, nil
}
results := &gdRecords{
zone: zone,
records: make([]gdRecordField, 0, len(recordsIds)),
}
for _, rec := range recordsIds {
if provider.SupportedRecordType(rec.Type) {
log.Debugf("GoDaddy: Record %s for %s is %+v", rec.Name, zone, rec)
results.records = append(results.records, rec)
} else {
log.Infof("GoDaddy: Discard record %s for %s is %+v", rec.Name, zone, rec)
}
}
return results, nil
}
func (p *GDProvider) groupByNameAndType(zoneRecords []gdRecords) []*endpoint.Endpoint {
endpoints := []*endpoint.Endpoint{}
// group supported records by name and type
groupsByZone := map[string]map[string][]gdRecordField{}
for _, zone := range zoneRecords {
groups := map[string][]gdRecordField{}
groupsByZone[zone.zone] = groups
for _, r := range zone.records {
groupBy := fmt.Sprintf("%s - %s", r.Type, r.Name)
if _, ok := groups[groupBy]; !ok {
groups[groupBy] = []gdRecordField{}
}
groups[groupBy] = append(groups[groupBy], r)
}
}
// create single endpoint with all the targets for each name/type
for zoneName, groups := range groupsByZone {
for _, records := range groups {
targets := []string{}
for _, record := range records {
targets = append(targets, record.Data)
}
var recordName string
if records[0].Name == "@" {
recordName = strings.TrimPrefix(zoneName, ".")
} else {
recordName = strings.TrimPrefix(fmt.Sprintf("%s.%s", records[0].Name, zoneName), ".")
}
endpoint := endpoint.NewEndpointWithTTL(
recordName,
records[0].Type,
endpoint.TTL(records[0].TTL),
targets...,
)
endpoints = append(endpoints, endpoint)
}
}
return endpoints
}
// Records returns the list of records in all relevant zones.
func (p *GDProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) {
_, records, err := p.zonesRecords(ctx, false)
if err != nil {
return nil, err
}
endpoints := p.groupByNameAndType(records)
log.Infof("GoDaddy: %d endpoints have been found", len(endpoints))
return endpoints, nil
}
func (p *GDProvider) flushRecords(patch bool, zoneRecord *gdRecords) error {
if patch {
return p.client.Patch(fmt.Sprintf("/v1/domains/%s/records", zoneRecord.zone), zoneRecord.records, nil)
}
return p.client.Put(fmt.Sprintf("/v1/domains/%s/records", zoneRecord.zone), zoneRecord.records, nil)
}
func (p *GDProvider) appendChange(action int, endpoints []*endpoint.Endpoint, allChanges []gdEndpoint) []gdEndpoint {
for _, e := range endpoints {
allChanges = append(allChanges, gdEndpoint{
action: action,
endpoint: e,
})
}
return allChanges
}
func (p *GDProvider) changeAllRecords(patch bool, endpoints []gdEndpoint, zoneRecords []*gdRecords) error {
zoneNameIDMapper := gdZoneIDName{}
for _, zoneRecord := range zoneRecords {
if patch {
zoneRecord.changed = false
zoneRecord.records = nil
}
zoneNameIDMapper.add(zoneRecord.zone, zoneRecord)
}
for _, e := range endpoints {
dnsName := e.endpoint.DNSName
zone, zoneRecord := zoneNameIDMapper.findZoneRecord(dnsName)
if zone == "" {
log.Debugf("Skipping record %s because no hosted zone matching record DNS Name was detected", dnsName)
} else {
dnsName = strings.TrimSuffix(dnsName, "."+zone)
if e.endpoint.RecordType == endpoint.RecordTypeA && (len(dnsName) == 0) {
dnsName = "@"
}
for _, target := range e.endpoint.Targets {
change := gdRecordField{
Type: e.endpoint.RecordType,
Name: dnsName,
TTL: p.ttl,
Data: target,
}
if e.endpoint.RecordTTL.IsConfigured() {
change.TTL = maxOf(gdMinimalTTL, int64(e.endpoint.RecordTTL))
}
zoneRecord.applyChange(e.action, change)
}
}
}
return nil
}
// ApplyChanges applies a given set of changes in a given zone.
func (p *GDProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error {
if countTargets(changes) == 0 {
return nil
}
_, records, err := p.zonesRecords(ctx, true)
if err != nil {
return err
}
changedZoneRecords := make([]*gdRecords, len(records))
for i := range records {
changedZoneRecords[i] = &records[i]
}
allChanges := make([]gdEndpoint, 0, countTargets(changes))
allChanges = p.appendChange(gdCreate, changes.Create, allChanges)
allChanges = p.appendChange(gdCreate, changes.UpdateNew, allChanges)
allChanges = p.appendChange(gdDelete, changes.UpdateOld, allChanges)
allChanges = p.appendChange(gdDelete, changes.Delete, allChanges)
log.Infof("GoDaddy: %d changes will be done", len(allChanges))
patch := len(changes.UpdateOld)+len(changes.Delete) == 0
if err = p.changeAllRecords(patch, allChanges, changedZoneRecords); err != nil {
return err
}
for _, record := range changedZoneRecords {
if record.changed {
if err = p.flushRecords(patch, record); err != nil {
return err
}
}
}
return nil
}
func (p *gdRecords) addRecord(change gdRecordField) {
log.Debugf("GoDaddy: Add an entry %s to zone %s", change.String(), p.zone)
p.records = append(p.records, change)
p.changed = true
}
func (p *gdRecords) updateRecord(change gdRecordField) {
log.Debugf("GoDaddy: Update an entry %s to zone %s", change.String(), p.zone)
for index, record := range p.records {
if record.Type == change.Type && record.Name == change.Name {
p.records[index] = change
p.changed = true
break
}
}
}
// Remove one record from the record list
func (p *gdRecords) deleteRecord(change gdRecordField) {
log.Debugf("GoDaddy: Delete an entry %s to zone %s", change.String(), p.zone)
deleteIndex := -1
for index, record := range p.records {
if record.Type == change.Type && record.Name == change.Name && record.Data == change.Data {
deleteIndex = index
break
}
}
if deleteIndex >= 0 {
p.records[deleteIndex] = p.records[len(p.records)-1]
p.records = p.records[:len(p.records)-1]
p.changed = true
} else {
log.Warnf("GoDaddy: record in zone %s not found %s to delete", p.zone, change.String())
}
}
func (p *gdRecords) applyChange(action int, change gdRecordField) {
switch action {
case gdCreate:
p.addRecord(change)
case gdUpdate:
p.updateRecord(change)
case gdDelete:
p.deleteRecord(change)
}
}
func (c gdRecordField) String() string {
return fmt.Sprintf("%s %d IN %s %s", c.Name, c.TTL, c.Type, c.Data)
}
func countTargets(p *plan.Changes) int {
changes := [][]*endpoint.Endpoint{p.Create, p.UpdateNew, p.UpdateOld, p.Delete}
count := 0
for _, endpoints := range changes {
for _, endpoint := range endpoints {
count += len(endpoint.Targets)
}
}
return count
}
func maxOf(vars ...int64) int64 {
max := vars[0]
for _, i := range vars {
if max < i {
max = i
}
}
return max
}

View File

@ -0,0 +1,376 @@
/*
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 godaddy
import (
"context"
"encoding/json"
"sort"
"testing"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"sigs.k8s.io/external-dns/endpoint"
"sigs.k8s.io/external-dns/plan"
)
type mockGoDaddyClient struct {
mock.Mock
currentTest *testing.T
}
func newMockGoDaddyClient(t *testing.T) *mockGoDaddyClient {
return &mockGoDaddyClient{
currentTest: t,
}
}
var (
zoneNameExampleOrg string = "example.org"
zoneNameExampleNet string = "example.net"
)
func (c *mockGoDaddyClient) Post(endpoint string, input interface{}, output interface{}) error {
log.Infof("POST: %s - %v", endpoint, input)
stub := c.Called(endpoint, input)
data, _ := json.Marshal(stub.Get(0))
json.Unmarshal(data, output)
return stub.Error(1)
}
func (c *mockGoDaddyClient) Patch(endpoint string, input interface{}, output interface{}) error {
log.Infof("PATCH: %s - %v", endpoint, input)
stub := c.Called(endpoint, input)
data, _ := json.Marshal(stub.Get(0))
json.Unmarshal(data, output)
return stub.Error(1)
}
func (c *mockGoDaddyClient) Put(endpoint string, input interface{}, output interface{}) error {
log.Infof("PUT: %s - %v", endpoint, input)
stub := c.Called(endpoint, input)
data, _ := json.Marshal(stub.Get(0))
json.Unmarshal(data, output)
return stub.Error(1)
}
func (c *mockGoDaddyClient) Get(endpoint string, output interface{}) error {
log.Infof("GET: %s", endpoint)
stub := c.Called(endpoint)
data, _ := json.Marshal(stub.Get(0))
json.Unmarshal(data, output)
return stub.Error(1)
}
func (c *mockGoDaddyClient) Delete(endpoint string, output interface{}) error {
log.Infof("DELETE: %s", endpoint)
stub := c.Called(endpoint)
data, _ := json.Marshal(stub.Get(0))
json.Unmarshal(data, output)
return stub.Error(1)
}
func TestGoDaddyZones(t *testing.T) {
assert := assert.New(t)
client := newMockGoDaddyClient(t)
provider := &GDProvider{
client: client,
domainFilter: endpoint.NewDomainFilter([]string{"com"}),
}
// Basic zones
client.On("Get", "/v1/domains?statuses=ACTIVE").Return([]gdZone{
{
Domain: "example.com",
},
{
Domain: "example.net",
},
}, nil).Once()
domains, err := provider.zones()
assert.NoError(err)
assert.Contains(domains, "example.com")
assert.NotContains(domains, "example.net")
client.AssertExpectations(t)
// Error on getting zones
client.On("Get", "/v1/domains?statuses=ACTIVE").Return(nil, ErrAPIDown).Once()
domains, err = provider.zones()
assert.Error(err)
assert.Nil(domains)
client.AssertExpectations(t)
}
func TestGoDaddyZoneRecords(t *testing.T) {
assert := assert.New(t)
client := newMockGoDaddyClient(t)
provider := &GDProvider{
client: client,
}
// Basic zones records
client.On("Get", "/v1/domains?statuses=ACTIVE").Return([]gdZone{
{
Domain: zoneNameExampleNet,
},
}, nil).Once()
client.On("Get", "/v1/domains/example.net/records").Return([]gdRecordField{
{
Name: "godaddy",
Type: "NS",
TTL: gdMinimalTTL,
Data: "203.0.113.42",
},
{
Name: "godaddy",
Type: "A",
TTL: gdMinimalTTL,
Data: "203.0.113.42",
},
}, nil).Once()
zones, records, err := provider.zonesRecords(context.TODO(), true)
assert.NoError(err)
assert.ElementsMatch(zones, []string{
zoneNameExampleNet,
})
assert.ElementsMatch(records, []gdRecords{
{
zone: zoneNameExampleNet,
records: []gdRecordField{
{
Name: "godaddy",
Type: "NS",
TTL: gdMinimalTTL,
Data: "203.0.113.42",
},
{
Name: "godaddy",
Type: "A",
TTL: gdMinimalTTL,
Data: "203.0.113.42",
},
},
},
})
client.AssertExpectations(t)
// Error on getting zones list
client.On("Get", "/v1/domains?statuses=ACTIVE").Return(nil, ErrAPIDown).Once()
zones, records, err = provider.zonesRecords(context.TODO(), false)
assert.Error(err)
assert.Nil(zones)
assert.Nil(records)
client.AssertExpectations(t)
// Error on getting zone records
client.On("Get", "/v1/domains?statuses=ACTIVE").Return([]gdZone{
{
Domain: zoneNameExampleNet,
},
}, nil).Once()
client.On("Get", "/v1/domains/example.net/records").Return(nil, ErrAPIDown).Once()
zones, records, err = provider.zonesRecords(context.TODO(), false)
assert.Error(err)
assert.Nil(zones)
assert.Nil(records)
client.AssertExpectations(t)
// Error on getting zone record detail
client.On("Get", "/v1/domains?statuses=ACTIVE").Return([]gdZone{
{
Domain: zoneNameExampleNet,
},
}, nil).Once()
client.On("Get", "/v1/domains/example.net/records").Return(nil, ErrAPIDown).Once()
zones, records, err = provider.zonesRecords(context.TODO(), false)
assert.Error(err)
assert.Nil(zones)
assert.Nil(records)
client.AssertExpectations(t)
}
func TestGoDaddyRecords(t *testing.T) {
assert := assert.New(t)
client := newMockGoDaddyClient(t)
provider := &GDProvider{
client: client,
}
// Basic zones records
client.On("Get", "/v1/domains?statuses=ACTIVE").Return([]gdZone{
{
Domain: zoneNameExampleOrg,
},
{
Domain: zoneNameExampleNet,
},
}, nil).Once()
client.On("Get", "/v1/domains/example.org/records").Return([]gdRecordField{
{
Name: "@",
Type: "A",
TTL: gdMinimalTTL,
Data: "203.0.113.42",
},
{
Name: "www",
Type: "CNAME",
TTL: gdMinimalTTL,
Data: "example.org",
},
}, nil).Once()
client.On("Get", "/v1/domains/example.net/records").Return([]gdRecordField{
{
Name: "godaddy",
Type: "A",
TTL: gdMinimalTTL,
Data: "203.0.113.42",
},
{
Name: "godaddy",
Type: "A",
TTL: gdMinimalTTL,
Data: "203.0.113.43",
},
}, nil).Once()
endpoints, err := provider.Records(context.TODO())
assert.NoError(err)
// Little fix for multi targets endpoint
for _, endpoint := range endpoints {
sort.Strings(endpoint.Targets)
}
assert.ElementsMatch(endpoints, []*endpoint.Endpoint{
{
DNSName: "godaddy.example.net",
RecordType: "A",
RecordTTL: gdMinimalTTL,
Labels: endpoint.NewLabels(),
Targets: []string{
"203.0.113.42",
"203.0.113.43",
},
},
{
DNSName: "example.org",
RecordType: "A",
RecordTTL: gdMinimalTTL,
Labels: endpoint.NewLabels(),
Targets: []string{
"203.0.113.42",
},
},
{
DNSName: "www.example.org",
RecordType: "CNAME",
RecordTTL: gdMinimalTTL,
Labels: endpoint.NewLabels(),
Targets: []string{
"example.org",
},
},
})
client.AssertExpectations(t)
// Error getting zone
client.On("Get", "/v1/domains?statuses=ACTIVE").Return(nil, ErrAPIDown).Once()
endpoints, err = provider.Records(context.TODO())
assert.Error(err)
assert.Nil(endpoints)
client.AssertExpectations(t)
}
func TestGoDaddyChange(t *testing.T) {
assert := assert.New(t)
client := newMockGoDaddyClient(t)
provider := &GDProvider{
client: client,
}
changes := plan.Changes{
Create: []*endpoint.Endpoint{
{
DNSName: ".example.net",
RecordType: "A",
RecordTTL: gdMinimalTTL,
Targets: []string{
"203.0.113.42",
},
},
},
Delete: []*endpoint.Endpoint{
{
DNSName: "godaddy.example.net",
RecordType: "A",
Targets: []string{
"203.0.113.43",
},
},
},
}
// Fetch domains
client.On("Get", "/v1/domains?statuses=ACTIVE").Return([]gdZone{
{
Domain: zoneNameExampleNet,
},
}, nil).Once()
// Fetch record
client.On("Get", "/v1/domains/example.net/records").Return([]gdRecordField{
{
Name: "godaddy",
Type: "A",
TTL: gdMinimalTTL,
Data: "203.0.113.43",
},
}, nil).Once()
// Update domain
client.On("Put", "/v1/domains/example.net/records", []gdRecordField{
{
Name: "@",
Type: "A",
TTL: gdMinimalTTL,
Data: "203.0.113.42",
},
}).Return(nil, nil).Once()
assert.NoError(provider.ApplyChanges(context.TODO(), &changes))
client.AssertExpectations(t)
}

View File

@ -24,7 +24,11 @@ import (
"strings"
"time"
"github.com/bodgit/tsig"
extendedClient "github.com/bodgit/tsig/client"
"github.com/bodgit/tsig/gss"
"github.com/miekg/dns"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
@ -36,6 +40,9 @@ import (
const (
// maximum size of a UDP transport message in DNS protocol
udpMaxMsgSize = 512
// maximum time DNS client can be off from server for an update to succeed
clockSkew = 300
)
// rfc2136 provider type
@ -50,6 +57,12 @@ type rfc2136Provider struct {
axfr bool
minTTL time.Duration
// options specific to rfc3645 gss-tsig support
gssTsig bool
krb5Username string
krb5Password string
krb5Realm string
// only consider hosted zones managing domains ending in this suffix
domainFilter endpoint.DomainFilter
dryRun bool
@ -72,9 +85,9 @@ type rfc2136Actions interface {
}
// NewRfc2136Provider is a factory function for OpenStack rfc2136 providers
func NewRfc2136Provider(host string, port int, zoneName string, insecure bool, keyName string, secret string, secretAlg string, axfr bool, domainFilter endpoint.DomainFilter, dryRun bool, minTTL time.Duration, actions rfc2136Actions) (provider.Provider, error) {
func NewRfc2136Provider(host string, port int, zoneName string, insecure bool, keyName string, secret string, secretAlg string, axfr bool, domainFilter endpoint.DomainFilter, dryRun bool, minTTL time.Duration, gssTsig bool, krb5Username string, krb5Password string, actions rfc2136Actions) (provider.Provider, error) {
secretAlgChecked, ok := tsigAlgs[secretAlg]
if !ok && !insecure {
if !ok && !insecure && !gssTsig {
return nil, errors.Errorf("%s is not supported TSIG algorithm", secretAlg)
}
@ -82,6 +95,10 @@ func NewRfc2136Provider(host string, port int, zoneName string, insecure bool, k
nameserver: net.JoinHostPort(host, strconv.Itoa(port)),
zoneName: dns.Fqdn(zoneName),
insecure: insecure,
gssTsig: gssTsig,
krb5Username: krb5Username,
krb5Password: krb5Password,
krb5Realm: strings.ToUpper(zoneName),
domainFilter: domainFilter,
dryRun: dryRun,
axfr: axfr,
@ -103,6 +120,22 @@ func NewRfc2136Provider(host string, port int, zoneName string, insecure bool, k
return r, nil
}
// KeyName will return TKEY name and TSIG handle to use for followon actions with a secure connection
func (r rfc2136Provider) KeyData() (keyName *string, handle *gss.GSS, err error) {
handle, err = gss.New()
if err != nil {
return keyName, handle, err
}
rawHost, _, err := net.SplitHostPort(r.nameserver)
if err != nil {
return keyName, handle, err
}
keyName, _, err = handle.NegotiateContextWithCredentials(rawHost, r.krb5Realm, r.krb5Username, r.krb5Password)
return keyName, handle, err
}
// Records returns the list of records.
func (r rfc2136Provider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) {
rrs, err := r.List()
@ -163,7 +196,7 @@ OuterLoop:
func (r rfc2136Provider) IncomeTransfer(m *dns.Msg, a string) (env chan *dns.Envelope, err error) {
t := new(dns.Transfer)
if !r.insecure {
if !r.insecure && !r.gssTsig {
t.TsigSecret = map[string]string{r.tsigKeyName: r.tsigSecret}
}
@ -180,8 +213,8 @@ func (r rfc2136Provider) List() ([]dns.RR, error) {
m := new(dns.Msg)
m.SetAxfr(r.zoneName)
if !r.insecure {
m.SetTsig(r.tsigKeyName, r.tsigSecretAlg, 300, time.Now().Unix())
if !r.insecure && !r.gssTsig {
m.SetTsig(r.tsigKeyName, r.tsigSecretAlg, clockSkew, time.Now().Unix())
}
env, err := r.actions.IncomeTransfer(m, r.nameserver)
@ -304,12 +337,31 @@ func (r rfc2136Provider) SendMessage(msg *dns.Msg) error {
}
log.Debugf("SendMessage")
c := new(dns.Client)
c := new(extendedClient.Client)
c.SingleInflight = true
if !r.insecure {
if r.gssTsig {
keyName, handle, err := r.KeyData()
if err != nil {
return err
}
defer handle.Close()
defer handle.DeleteContext(keyName)
c.TsigAlgorithm = map[string]*extendedClient.TsigAlgorithm{
tsig.GSS: {
Generate: handle.GenerateGSS,
Verify: handle.VerifyGSS,
},
}
c.TsigSecret = map[string]string{*keyName: ""}
msg.SetTsig(*keyName, tsig.GSS, clockSkew, time.Now().Unix())
} else {
c.TsigSecret = map[string]string{r.tsigKeyName: r.tsigSecret}
msg.SetTsig(r.tsigKeyName, r.tsigSecretAlg, 300, time.Now().Unix())
msg.SetTsig(r.tsigKeyName, r.tsigSecretAlg, clockSkew, time.Now().Unix())
}
}
if msg.Len() > udpMaxMsgSize {
@ -318,9 +370,12 @@ func (r rfc2136Provider) SendMessage(msg *dns.Msg) error {
resp, _, err := c.Exchange(msg, r.nameserver)
if err != nil {
if resp != nil && resp.Rcode != dns.RcodeSuccess {
log.Infof("error in dns.Client.Exchange: %s", err)
return err
}
log.Warnf("warn in dns.Client.Exchange: %s", err)
}
if resp != nil && resp.Rcode != dns.RcodeSuccess {
log.Infof("Bad dns.Client.Exchange response: %s", resp)
return fmt.Errorf("bad return code: %s", dns.RcodeToString[resp.Rcode])

View File

@ -95,7 +95,7 @@ func (r *rfc2136Stub) IncomeTransfer(m *dns.Msg, a string) (env chan *dns.Envelo
}
func createRfc2136StubProvider(stub *rfc2136Stub) (provider.Provider, error) {
return NewRfc2136Provider("", 0, "", false, "key", "secret", "hmac-sha512", true, endpoint.DomainFilter{}, false, 300*time.Second, stub)
return NewRfc2136Provider("", 0, "", false, "key", "secret", "hmac-sha512", true, endpoint.DomainFilter{}, false, 300*time.Second, false, "", "", stub)
}
func extractAuthoritySectionFromMessage(msg fmt.Stringer) []string {

View File

@ -269,8 +269,13 @@ func endpointToScalewayRecords(zoneName string, ep *endpoint.Endpoint) []*domain
records := []*domain.Record{}
for _, target := range ep.Targets {
finalTargetName := target
if domain.RecordType(ep.RecordType) == domain.RecordTypeCNAME {
finalTargetName = provider.EnsureTrailingDot(target)
}
records = append(records, &domain.Record{
Data: target,
Data: finalTargetName,
Name: strings.Trim(strings.TrimSuffix(ep.DNSName, zoneName), ". "),
Priority: priority,
TTL: ttl,
@ -285,9 +290,14 @@ func endpointToScalewayRecordsChangeDelete(zoneName string, ep *endpoint.Endpoin
records := []*domain.RecordChange{}
for _, target := range ep.Targets {
finalTargetName := target
if domain.RecordType(ep.RecordType) == domain.RecordTypeCNAME {
finalTargetName = provider.EnsureTrailingDot(target)
}
records = append(records, &domain.RecordChange{
Delete: &domain.RecordChangeDelete{
Data: target,
Data: finalTargetName,
Name: strings.Trim(strings.TrimSuffix(ep.DNSName, zoneName), ". "),
Type: domain.RecordType(ep.RecordType),
},

View File

@ -93,7 +93,7 @@ func (m *mockScalewayDomain) ListDNSZoneRecords(req *domain.ListDNSZoneRecordsRe
Type: domain.RecordTypeA,
},
{
Data: "test.example.com",
Data: "test.example.com.",
Name: "two",
TTL: 600,
Priority: 30,
@ -330,7 +330,7 @@ func TestScalewayProvider_generateApplyRequests(t *testing.T) {
Add: &domain.RecordChangeAdd{
Records: []*domain.Record{
{
Data: "example.com",
Data: "example.com.",
Name: "",
TTL: 600,
Type: domain.RecordTypeCNAME,

View File

@ -20,6 +20,14 @@ function generate_changelog {
pr_author="$(gh pr view "$pr_num" | grep author | awk '{ print $2 }' | tr $'\n' ' ')"
printf "* %s (%s) @%s\n\n" "$pr_desc" "$pr_num" "$pr_author"
done
git log "$previous_tag".. --reverse --oneline --grep='(#' | \
while read -r sha title; do
pr_num="$(grep -o '#[[:digit:]]\+' <<<"$title")"
pr_desc="$(git show -s --format=%s "$sha")"
pr_author="$(gh pr view "$pr_num" | grep author | awk '{ print $2 }' | tr $'\n' ' ')"
printf "* %s (%s) @%s\n\n" "$pr_desc" "$pr_num" "$pr_author"
done
}
function create_release {

283
source/ambassador_host.go Normal file
View File

@ -0,0 +1,283 @@
/*
Copyright 2020 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package source
import (
"context"
"fmt"
"sort"
"strings"
"time"
ambassador "github.com/datawire/ambassador/pkg/api/getambassador.io/v2"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/wait"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/dynamic/dynamicinformer"
"k8s.io/client-go/informers"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/tools/cache"
api "k8s.io/kubernetes/pkg/apis/core"
"sigs.k8s.io/external-dns/endpoint"
)
// ambHostAnnotation is the annotation in the Host that maps to a Service
const ambHostAnnotation = "external-dns.ambassador-service"
// groupName is the group name for the Ambassador API
const groupName = "getambassador.io"
var schemeGroupVersion = schema.GroupVersion{Group: groupName, Version: "v2"}
var ambHostGVR = schemeGroupVersion.WithResource("hosts")
// ambassadorHostSource is an implementation of Source for Ambassador Host objects.
// The IngressRoute implementation uses the spec.virtualHost.fqdn value for the hostname.
// Use targetAnnotationKey to explicitly set Endpoint.
type ambassadorHostSource struct {
dynamicKubeClient dynamic.Interface
kubeClient kubernetes.Interface
namespace string
ambassadorHostInformer informers.GenericInformer
unstructuredConverter *unstructuredConverter
}
// NewAmbassadorHostSource creates a new ambassadorHostSource with the given config.
func NewAmbassadorHostSource(
dynamicKubeClient dynamic.Interface,
kubeClient kubernetes.Interface,
namespace string) (Source, error) {
var err error
// Use shared informer to listen for add/update/delete of Host in the specified namespace.
// Set resync period to 0, to prevent processing when nothing has changed.
informerFactory := dynamicinformer.NewFilteredDynamicSharedInformerFactory(dynamicKubeClient, 0, namespace, nil)
ambassadorHostInformer := informerFactory.ForResource(ambHostGVR)
// Add default resource event handlers to properly initialize informer.
ambassadorHostInformer.Informer().AddEventHandler(
cache.ResourceEventHandlerFuncs{
AddFunc: func(obj interface{}) {
},
},
)
// TODO informer is not explicitly stopped since controller is not passing in its channel.
informerFactory.Start(wait.NeverStop)
// wait for the local cache to be populated.
err = poll(time.Second, 60*time.Second, func() (bool, error) {
return ambassadorHostInformer.Informer().HasSynced(), nil
})
if err != nil {
return nil, errors.Wrapf(err, "failed to sync cache")
}
uc, err := newUnstructuredConverter()
if err != nil {
return nil, errors.Wrapf(err, "failed to setup Unstructured Converter")
}
return &ambassadorHostSource{
dynamicKubeClient: dynamicKubeClient,
kubeClient: kubeClient,
namespace: namespace,
ambassadorHostInformer: ambassadorHostInformer,
unstructuredConverter: uc,
}, nil
}
// Endpoints returns endpoint objects for each host-target combination that should be processed.
// Retrieves all Hosts in the source's namespace(s).
func (sc *ambassadorHostSource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, error) {
hosts, err := sc.ambassadorHostInformer.Lister().ByNamespace(sc.namespace).List(labels.Everything())
if err != nil {
return nil, err
}
endpoints := []*endpoint.Endpoint{}
for _, hostObj := range hosts {
unstructuredHost, ok := hostObj.(*unstructured.Unstructured)
if !ok {
return nil, errors.New("could not convert")
}
host := &ambassador.Host{}
err := sc.unstructuredConverter.scheme.Convert(unstructuredHost, host, nil)
if err != nil {
return nil, err
}
fullname := fmt.Sprintf("%s/%s", host.Namespace, host.Name)
// look for the "exernal-dns.ambassador-service" annotation. If it is not there then just ignore this `Host`
service, found := host.Annotations[ambHostAnnotation]
if !found {
log.Debugf("Host %s ignored: no annotation %q found", fullname, ambHostAnnotation)
continue
}
targets, err := sc.targetsFromAmbassadorLoadBalancer(ctx, service)
if err != nil {
return nil, err
}
hostEndpoints, err := sc.endpointsFromHost(ctx, host, targets)
if err != nil {
return nil, err
}
if len(hostEndpoints) == 0 {
log.Debugf("No endpoints could be generated from Host %s", fullname)
continue
}
log.Debugf("Endpoints generated from Host: %s: %v", fullname, hostEndpoints)
endpoints = append(endpoints, hostEndpoints...)
}
for _, ep := range endpoints {
sort.Sort(ep.Targets)
}
return endpoints, nil
}
// endpointsFromHost extracts the endpoints from a Host object
func (sc *ambassadorHostSource) endpointsFromHost(ctx context.Context, host *ambassador.Host, targets endpoint.Targets) ([]*endpoint.Endpoint, error) {
var endpoints []*endpoint.Endpoint
providerSpecific := endpoint.ProviderSpecific{}
setIdentifier := ""
annotations := host.Annotations
ttl, err := getTTLFromAnnotations(annotations)
if err != nil {
return nil, err
}
if host.Spec != nil {
hostname := host.Spec.Hostname
if hostname != "" {
endpoints = append(endpoints, endpointsForHostname(hostname, targets, ttl, providerSpecific, setIdentifier)...)
}
}
return endpoints, nil
}
func (sc *ambassadorHostSource) targetsFromAmbassadorLoadBalancer(ctx context.Context, service string) (targets endpoint.Targets, err error) {
lbNamespace, lbName, err := parseAmbLoadBalancerService(service)
if err != nil {
return nil, err
}
svc, err := sc.kubeClient.CoreV1().Services(lbNamespace).Get(ctx, lbName, metav1.GetOptions{})
if err != nil {
return nil, err
}
for _, lb := range svc.Status.LoadBalancer.Ingress {
if lb.IP != "" {
targets = append(targets, lb.IP)
}
if lb.Hostname != "" {
targets = append(targets, lb.Hostname)
}
}
return
}
// parseAmbLoadBalancerService returns a name/namespace tuple from the annotation in
// an Ambassador Host CRD
//
// This is a thing because Ambassador has historically supported cross-namespace
// references using a name.namespace syntax, but here we want to also support
// namespace/name.
//
// Returns namespace, name, error.
func parseAmbLoadBalancerService(service string) (namespace, name string, err error) {
// Start by assuming that we have namespace/name.
parts := strings.Split(service, "/")
if len(parts) == 1 {
// No "/" at all, so let's try for name.namespace. To be consistent with the
// rest of Ambassador, use SplitN to limit this to one split, so that e.g.
// svc.foo.bar uses service "svc" in namespace "foo.bar".
parts = strings.SplitN(service, ".", 2)
if len(parts) == 2 {
// We got a namespace, great.
name := parts[0]
namespace := parts[1]
return namespace, name, nil
}
// If here, we have no separator, so the whole string is the service, and
// we can assume the default namespace.
name := service
namespace := api.NamespaceDefault
return namespace, name, nil
} else if len(parts) == 2 {
// This is "namespace/name". Note that the name could be qualified,
// which is fine.
namespace := parts[0]
name := parts[1]
return namespace, name, nil
}
// If we got here, this string is simply ill-formatted. Return an error.
return "", "", errors.New(fmt.Sprintf("invalid external-dns service: %s", service))
}
func (sc *ambassadorHostSource) AddEventHandler(ctx context.Context, handler func()) {
}
// unstructuredConverter handles conversions between unstructured.Unstructured and Ambassador types
type unstructuredConverter struct {
// scheme holds an initializer for converting Unstructured to a type
scheme *runtime.Scheme
}
// newUnstructuredConverter returns a new unstructuredConverter initialized
func newUnstructuredConverter() (*unstructuredConverter, error) {
uc := &unstructuredConverter{
scheme: runtime.NewScheme(),
}
// Setup converter to understand custom CRD types
ambassador.AddToScheme(uc.scheme)
// Add the core types we need
if err := scheme.AddToScheme(uc.scheme); err != nil {
return nil, err
}
return uc, nil
}

View File

@ -0,0 +1,78 @@
/*
Copyright 2019 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package source
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
type AmbassadorSuite struct {
suite.Suite
}
func TestAmbassadorSource(t *testing.T) {
suite.Run(t, new(AmbassadorSuite))
t.Run("Interface", testAmbassadorSourceImplementsSource)
}
// testAmbassadorSourceImplementsSource tests that ambassadorHostSource is a valid Source.
func testAmbassadorSourceImplementsSource(t *testing.T) {
require.Implements(t, (*Source)(nil), new(ambassadorHostSource))
}
// TestParseAmbLoadBalancerService tests our parsing of Ambassador service info.
func TestParseAmbLoadBalancerService(t *testing.T) {
vectors := []struct {
input string
ns string
svc string
errstr string
}{
{"svc", "default", "svc", ""},
{"ns/svc", "ns", "svc", ""},
{"svc.ns", "ns", "svc", ""},
{"svc.ns.foo.bar", "ns.foo.bar", "svc", ""},
{"ns/svc/foo/bar", "", "", "invalid external-dns service: ns/svc/foo/bar"},
{"ns/svc/foo.bar", "", "", "invalid external-dns service: ns/svc/foo.bar"},
{"ns.foo/svc/bar", "", "", "invalid external-dns service: ns.foo/svc/bar"},
}
for _, v := range vectors {
ns, svc, err := parseAmbLoadBalancerService(v.input)
errstr := ""
if err != nil {
errstr = err.Error()
}
if v.ns != ns {
t.Errorf("%s: got ns \"%s\", wanted \"%s\"", v.input, ns, v.ns)
}
if v.svc != svc {
t.Errorf("%s: got svc \"%s\", wanted \"%s\"", v.input, svc, v.svc)
}
if v.errstr != errstr {
t.Errorf("%s: got err \"%s\", wanted \"%s\"", v.input, errstr, v.errstr)
}
}
}

View File

@ -628,14 +628,17 @@ func (sc *serviceSource) extractNodePortEndpoints(svc *v1.Service, nodeTargets e
for _, port := range svc.Spec.Ports {
if port.NodePort > 0 {
// following the RFC 2782, SRV record must have a following format
// _service._proto.name. TTL class SRV priority weight port
// see https://en.wikipedia.org/wiki/SRV_record
// 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)
}
// take the service name from the K8s Service object
// it is safe to use since it is DNS compatible
// see https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#dns-label-names
serviceName := svc.ObjectMeta.Name
// figure out the protocol
protocol := strings.ToLower(string(port.Protocol))
@ -643,7 +646,7 @@ func (sc *serviceSource) extractNodePortEndpoints(svc *v1.Service, nodeTargets e
protocol = "tcp"
}
recordName := fmt.Sprintf("_%s._%s.%s", portName, protocol, hostname)
recordName := fmt.Sprintf("_%s._%s.%s", serviceName, protocol, hostname)
var ep *endpoint.Endpoint
if ttl.IsConfigured() {

View File

@ -1642,7 +1642,7 @@ func TestNodePortServices(t *testing.T) {
},
nil,
[]*endpoint.Endpoint{
{DNSName: "_30192._tcp.foo.example.org", Targets: endpoint.Targets{"0 50 30192 foo.example.org"}, RecordType: endpoint.RecordTypeSRV},
{DNSName: "_foo._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,
@ -1729,7 +1729,7 @@ func TestNodePortServices(t *testing.T) {
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._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,
@ -1775,7 +1775,7 @@ func TestNodePortServices(t *testing.T) {
},
nil,
[]*endpoint.Endpoint{
{DNSName: "_30192._tcp.foo.example.org", Targets: endpoint.Targets{"0 50 30192 foo.example.org"}, RecordType: endpoint.RecordTypeSRV},
{DNSName: "_foo._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,
@ -1819,7 +1819,7 @@ func TestNodePortServices(t *testing.T) {
},
nil,
[]*endpoint.Endpoint{
{DNSName: "_30192._tcp.foo.example.org", Targets: endpoint.Targets{"0 50 30192 foo.example.org"}, RecordType: endpoint.RecordTypeSRV},
{DNSName: "_foo._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.2"}, RecordType: endpoint.RecordTypeA},
},
false,
@ -1865,7 +1865,7 @@ func TestNodePortServices(t *testing.T) {
},
nil,
[]*endpoint.Endpoint{
{DNSName: "_30192._tcp.foo.example.org", Targets: endpoint.Targets{"0 50 30192 foo.example.org"}, RecordType: endpoint.RecordTypeSRV},
{DNSName: "_foo._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.2"}, RecordType: endpoint.RecordTypeA},
},
false,
@ -1912,7 +1912,7 @@ func TestNodePortServices(t *testing.T) {
},
nil,
[]*endpoint.Endpoint{
{DNSName: "_30192._tcp.foo.example.org", Targets: endpoint.Targets{"0 50 30192 foo.example.org"}, RecordType: endpoint.RecordTypeSRV},
{DNSName: "_foo._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,
@ -1959,7 +1959,7 @@ func TestNodePortServices(t *testing.T) {
},
nil,
[]*endpoint.Endpoint{
{DNSName: "_30192._tcp.foo.example.org", Targets: endpoint.Targets{"0 50 30192 foo.example.org"}, RecordType: endpoint.RecordTypeSRV},
{DNSName: "_foo._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,

View File

@ -83,12 +83,12 @@ type SingletonClientGenerator struct {
kubeClient kubernetes.Interface
istioClient *istioclient.Clientset
cfClient *cfclient.Client
contourClient dynamic.Interface
dynKubeClient dynamic.Interface
openshiftClient openshift.Interface
kubeOnce sync.Once
istioOnce sync.Once
cfOnce sync.Once
contourOnce sync.Once
dynCliOnce sync.Once
openshiftOnce sync.Once
}
@ -134,13 +134,13 @@ func NewCFClient(cfAPIEndpoint string, cfUsername string, cfPassword string) (*c
return client, nil
}
// DynamicKubernetesClient generates a contour client if it was not created before
// DynamicKubernetesClient generates a dynamic client if it was not created before
func (p *SingletonClientGenerator) DynamicKubernetesClient() (dynamic.Interface, error) {
var err error
p.contourOnce.Do(func() {
p.contourClient, err = NewDynamicKubernetesClient(p.KubeConfig, p.APIServerURL, p.RequestTimeout)
p.dynCliOnce.Do(func() {
p.dynKubeClient, err = NewDynamicKubernetesClient(p.KubeConfig, p.APIServerURL, p.RequestTimeout)
})
return p.contourClient, err
return p.dynKubeClient, err
}
// OpenShiftClient generates an openshift client if it was not created before
@ -213,6 +213,16 @@ func BuildWithConfig(source string, p ClientGenerator, cfg *Config) (Source, err
return nil, err
}
return NewCloudFoundrySource(cfClient)
case "ambassador-host":
kubernetesClient, err := p.KubeClient()
if err != nil {
return nil, err
}
dynamicClient, err := p.DynamicKubernetesClient()
if err != nil {
return nil, err
}
return NewAmbassadorHostSource(dynamicClient, kubernetesClient, cfg.Namespace)
case "contour-ingressroute":
kubernetesClient, err := p.KubeClient()
if err != nil {