Unverified Commit 430e357d authored by Hugome's avatar Hugome
Browse files

Add OVH Provider

 - OVH Provider
 - Tests
 - Documentations
parent 1e7c6002
......@@ -44,6 +44,7 @@ ExternalDNS' current release is `v0.6`. This version allows you to keep selected
* [NS1](https://ns1.com/)
* [TransIP](https://www.transip.eu/domain-name/)
* [VinylDNS](https://www.vinyldns.io)
* [OVH](https://www.ovh.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.
......@@ -93,6 +94,7 @@ The following table clarifies the current status of the providers according to t
| VinylDNS | Alpha |
| RancherDNS | Alpha |
| Akamai FastDNS | Alpha |
| OVH | Alpha |
## Running ExternalDNS:
......@@ -139,6 +141,7 @@ The following tutorials are provided:
* [RFC2136](docs/tutorials/rfc2136.md)
* [TransIP](docs/tutorials/transip.md)
* [VinylDNS](docs/tutorials/vinyldns.md)
* [OVH](docs/tutorials/ovh.md)
### Running Locally
......@@ -269,6 +272,7 @@ Here's a rough outline on what is to come (subject to change):
### v0.6
- [ ] Ability to replace Kops' [DNS Controller](https://github.com/kubernetes/kops/tree/master/dns-controller) (This could also directly become `v1.0`)
- [x] Support for OVH
### v1.0
......
# Setting up ExternalDNS for Services on OVH
This tutorial describes how to setup ExternalDNS for use within a
Kubernetes cluster using OVH DNS.
Make sure to use **>=0.6** version of ExternalDNS for this tutorial.
## Creating a zone with OVH DNS
If you are new to OVH, we recommend you first read the following
instructions for creating a zone.
[Creating a zone using the OVH manager](https://docs.ovh.com/gb/en/domains/create_a_dns_zone_for_a_domain_which_is_not_registered_at_ovh/)
[Creating a zone using the OVH API](https://api.ovh.com/console/)
## Creating OVH Credentials
You first need to create an OVH application.
Using the [OVH documentation](https://docs.ovh.com/gb/en/customer/first-steps-with-ovh-api/#creation-of-your-application-keys) you will have your `Application key` and `Application secret`
And you will need a `Consumer key`, you can ask `External DNS` to generate that for you, using the `--ovh-generate-consumer` option.
```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: registry.opensource.zalan.do/teapot/external-dns:latest
args:
- --provider=ovh
- --ovh-generate-consumer
env:
- name: OVH_APPLICATION_KEY
value: "YOUR_OVH_APPLICATION_KEY"
- name: OVH_APPLICATION_SECRET
value: "YOUR_OVH_APPLICATION_SECRET"
```
In log, you will have two log lines :
```
INFO[0000] Generated consumer key: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
INFO[0000] Please visit https://eu.api.ovh.com/auth/?credentialToken=YYYYYYYYYYYYYY to validate it
```
Keep the `Consumer key` and go to the link to allow external dns to manage your OVH Dns zone.
You can also generate the key manually, here the access needed :
- GET on `/domain/zone`
- GET on `/domain/zone/*/record`
- POST on `/domain/zone/*/record`
- PUT on `/domain/zone/*/record`
- DELETE on `/domain/zone/*/record`
- GET on `/domain/zone/*/record/*`
- POST on `/domain/zone/*/record/*`
- PUT on `/domain/zone/*/record/*`
- DELETE on `/domain/zone/*/record/*`
- POST on `/domain/zone/*/refresh`
## 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: registry.opensource.zalan.do/teapot/external-dns:latest
args:
- --source=service # ingress is also possible
- --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above.
- --provider=ovh
env:
- name: OVH_APPLICATION_KEY
value: "YOUR_OVH_APPLICATION_KEY"
- name: OVH_APPLICATION_SECRET
value: "YOUR_OVH_APPLICATION_SECRET"
- name: OVH_CONSUMER_KEY
value: "YOUR_OVH_CONSUMER_KEY_AFTER_VALIDATED_LINK"
```
### Manifest (for clusters with RBAC enabled)
```yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: external-dns
---
apiVersion: rbac.authorization.k8s.io/v1beta1
kind: ClusterRole
metadata:
name: external-dns
rules:
- apiGroups: [""]
resources: ["services"]
verbs: ["get","watch","list"]
- apiGroups: [""]
resources: ["pods"]
verbs: ["get","watch","list"]
- apiGroups: ["extensions"]
resources: ["ingresses"]
verbs: ["get","watch","list"]
- apiGroups: [""]
resources: ["nodes"]
verbs: ["list"]
---
apiVersion: rbac.authorization.k8s.io/v1beta1
kind: ClusterRoleBinding
metadata:
name: external-dns-viewer
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: external-dns
subjects:
- kind: ServiceAccount
name: external-dns
namespace: default
---
apiVersion: 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: registry.opensource.zalan.do/teapot/external-dns:latest
args:
- --source=service # ingress is also possible
- --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above.
- --provider=ovh
env:
- name: OVH_APPLICATION_KEY
value: "YOUR_OVH_APPLICATION_KEY"
- name: OVH_APPLICATION_SECRET
value: "YOUR_OVH_APPLICATION_SECRET"
- name: OVH_CONSUMER_KEY
value: "YOUR_OVH_CONSUMER_KEY_AFTER_VALIDATED_LINK"
```
## 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 OVH 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 OVH DNS records.
## Verifying OVH DNS records
Use the OVH manager 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
```
......@@ -27,6 +27,7 @@ require (
github.com/ffledgling/pdns-go v0.0.0-20180219074714-524e7daccd99
github.com/go-resty/resty v1.8.0 // indirect
github.com/gobs/pretty v0.0.0-20180724170744-09732c25a95b // indirect
github.com/golang/sync v0.0.0-20180314180146-1d60e4601c6f
github.com/gophercloud/gophercloud v0.1.0
github.com/heptio/contour v0.15.0
github.com/infobloxopen/infoblox-go-client v0.0.0-20180606155407-61dc5f9b0a65
......@@ -37,6 +38,7 @@ require (
github.com/nesv/go-dynect v0.6.0
github.com/nic-at/rc0go v1.1.0
github.com/oracle/oci-go-sdk v1.8.0
github.com/ovh/go-ovh v0.0.0-20181109152953-ba5adb4cf014
github.com/pkg/errors v0.8.1
github.com/prometheus/client_golang v0.9.3
github.com/sanyu/dynectsoap v0.0.0-20181203081243-b83de5edc4e0
......
......@@ -445,6 +445,8 @@ github.com/operator-framework/operator-sdk v0.7.0/go.mod h1:iVyukRkam5JZa8AnjYf+
github.com/oracle/oci-go-sdk v1.8.0 h1:4SO45bKV0I3/Mn1os3ANDZmV0eSE5z5CLdSUIkxtyzs=
github.com/oracle/oci-go-sdk v1.8.0/go.mod h1:VQb79nF8Z2cwLkLS35ukwStZIg5F66tcBccjip/j888=
github.com/ory/dockertest v3.3.4+incompatible/go.mod h1:1vX4m9wsvi00u5bseYwXaSnhNrne+V0E6LAcBILJdPs=
github.com/ovh/go-ovh v0.0.0-20181109152953-ba5adb4cf014 h1:37VE5TYj2m/FLA9SNr4z0+A0JefvTmR60Zwf8XSEV7c=
github.com/ovh/go-ovh v0.0.0-20181109152953-ba5adb4cf014/go.mod h1:joRatxRJaZBsY3JAOEMcoOp05CnZzsx4scTxi95DHyQ=
github.com/oxtoacart/bpool v0.0.0-20150712133111-4e1c5567d7c2 h1:CXwSGu/LYmbjEab5aMCs5usQRVBGThelUKBNnoSOuso=
github.com/oxtoacart/bpool v0.0.0-20150712133111-4e1c5567d7c2/go.mod h1:L3UMQOThbttwfYRNFOWLLVXMhk5Lkio4GGOtw5UrxS0=
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
......@@ -740,6 +742,7 @@ gopkg.in/h2non/gock.v1 v1.0.15/go.mod h1:sX4zAkdYX1TRGJ2JY156cFspQn4yRWn6p9EMdOD
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/ini.v1 v1.42.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.44.0 h1:YRJzTUp0kSYWUVFF5XAbDFfyiqwsl0Vb9R8TVP5eRi0=
gopkg.in/ini.v1 v1.44.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/logfmt.v0 v0.3.0/go.mod h1:mRLMcMLrml5h2Ux/H+4zccFOlVCiRvOvndsolsJoU8Q=
gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA=
......
......@@ -172,6 +172,8 @@ func main() {
p, err = provider.NewGoogleProvider(ctx, cfg.GoogleProject, domainFilter, zoneIDFilter, cfg.GoogleBatchChangeSize, cfg.GoogleBatchChangeInterval, cfg.DryRun)
case "digitalocean":
p, err = provider.NewDigitalOceanProvider(ctx, domainFilter, cfg.DryRun)
case "ovh":
p, err = provider.NewOVHProvider(ctx, domainFilter, cfg.OVHEndpoint, cfg.OVHGenerateConsumerKey, cfg.DryRun)
case "linode":
p, err = provider.NewLinodeProvider(domainFilter, cfg.DryRun, externaldns.Version)
case "dnsimple":
......
......@@ -98,6 +98,8 @@ type Config struct {
DynMinTTLSeconds int
OCIConfigFile string
InMemoryZones []string
OVHEndpoint string
OVHGenerateConsumerKey bool
PDNSServer string
PDNSAPIKey string `secure:"yes"`
PDNSTLSEnabled bool
......@@ -193,6 +195,8 @@ var defaultConfig = &Config{
InfobloxMaxResults: 0,
OCIConfigFile: "/etc/kubernetes/oci.yaml",
InMemoryZones: []string{},
OVHEndpoint: "ovh-eu",
OVHGenerateConsumerKey: false,
PDNSServer: "http://localhost:8081",
PDNSAPIKey: "",
PDNSTLSEnabled: false,
......@@ -310,7 +314,7 @@ func (cfg *Config) ParseFlags(args []string) error {
app.Flag("service-type-filter", "The service types to take care about (default: all, expected: ClusterIP, NodePort, LoadBalancer or ExternalName)").StringsVar(&cfg.ServiceTypeFilter)
// Flags related to providers
app.Flag("provider", "The DNS provider where the DNS records will be created (required, options: aws, aws-sd, google, azure, azure-dns, azure-private-dns, cloudflare, rcodezero, digitalocean, dnsimple, akamai, infoblox, dyn, designate, coredns, skydns, inmemory, pdns, oci, exoscale, linode, rfc2136, ns1, transip, vinyldns, rdns)").Required().PlaceHolder("provider").EnumVar(&cfg.Provider, "aws", "aws-sd", "google", "azure", "azure-dns", "azure-private-dns", "alibabacloud", "cloudflare", "rcodezero", "digitalocean", "dnsimple", "akamai", "infoblox", "dyn", "designate", "coredns", "skydns", "inmemory", "pdns", "oci", "exoscale", "linode", "rfc2136", "ns1", "transip", "vinyldns", "rdns")
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, dnsimple, akamai, infoblox, dyn, designate, coredns, skydns, inmemory, ovh, pdns, oci, exoscale, linode, rfc2136, ns1, transip, vinyldns, rdns)").Required().PlaceHolder("provider").EnumVar(&cfg.Provider, "aws", "aws-sd", "google", "azure", "azure-dns", "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")
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-id-filter", "Filter target zones by hosted zone id; specify multiple times for multiple zones (optional)").Default("").StringsVar(&cfg.ZoneIDFilter)
......@@ -353,6 +357,8 @@ func (cfg *Config) ParseFlags(args []string) error {
app.Flag("oci-config-file", "When using the OCI provider, specify the OCI configuration file (required when --provider=oci").Default(defaultConfig.OCIConfigFile).StringVar(&cfg.OCIConfigFile)
app.Flag("rcodezero-txt-encrypt", "When using the Rcodezero provider with txt registry option, set if TXT rrs are encrypted (default: false)").Default(strconv.FormatBool(defaultConfig.RcodezeroTXTEncrypt)).BoolVar(&cfg.RcodezeroTXTEncrypt)
app.Flag("inmemory-zone", "Provide a list of pre-configured zones for the inmemory provider; specify multiple times for multiple zones (optional)").Default("").StringsVar(&cfg.InMemoryZones)
app.Flag("ovh-endpoint", "When using the OVH provider, specify the endpoint (default: ovh-eu)").Default(defaultConfig.OVHEndpoint).StringVar(&cfg.OVHEndpoint)
app.Flag("ovh-generate-consumer", "When using the OVH provider, will generate a consumer and validation URL (default: false)").Default(strconv.FormatBool(defaultConfig.OVHGenerateConsumerKey)).BoolVar(&cfg.OVHGenerateConsumerKey)
app.Flag("pdns-server", "When using the PowerDNS/PDNS provider, specify the URL to the pdns server (required when --provider=pdns)").Default(defaultConfig.PDNSServer).StringVar(&cfg.PDNSServer)
app.Flag("pdns-api-key", "When using the PowerDNS/PDNS provider, specify the API key to use to authorize requests (required when --provider=pdns)").Default(defaultConfig.PDNSAPIKey).StringVar(&cfg.PDNSAPIKey)
app.Flag("pdns-tls-enabled", "When using the PowerDNS/PDNS provider, specify whether to use TLS (default: false, requires --tls-ca, optionally specify --tls-client-cert and --tls-client-cert-key)").Default(strconv.FormatBool(defaultConfig.PDNSTLSEnabled)).BoolVar(&cfg.PDNSTLSEnabled)
......
/*
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 provider
import (
"context"
"errors"
"fmt"
"strings"
"github.com/golang/sync/errgroup"
"github.com/ovh/go-ovh/ovh"
log "github.com/sirupsen/logrus"
"sigs.k8s.io/external-dns/endpoint"
"sigs.k8s.io/external-dns/plan"
)
const (
ovhDefaultTTL = 0
)
const (
ovhCreate = iota
ovhDelete
)
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")
)
// OVHProvider is an implementation of Provider for OVH DNS.
type OVHProvider struct {
client ovhClient
domainFilter DomainFilter
DryRun bool
}
type ovhClient interface {
Post(string, interface{}, interface{}) error
Get(string, interface{}) error
Delete(string, interface{}) error
}
type ovhRecordFields struct {
FieldType string `json:"fieldType"`
SubDomain string `json:"subDomain"`
TTL int64 `json:"ttl"`
Target string `json:"target"`
}
type ovhRecord struct {
ovhRecordFields
ID uint64 `json:"id"`
Zone string `json:"zone"`
}
type ovhChange struct {
ovhRecord
Action int
}
// NewOVHProvider initializes a new OVH DNS based Provider.
func NewOVHProvider(ctx context.Context, domainFilter DomainFilter, endpoint string, generateConsumerKey bool, dryRun bool) (*OVHProvider, error) {
client, err := ovh.NewEndpointClient(endpoint)
if err != nil {
return nil, err
}
if generateConsumerKey {
ckReq := client.NewCkRequest()
ckReq.AddRules(ovh.ReadOnly, "/domain/zone")
ckReq.AddRecursiveRules(ovh.ReadWrite, "/domain/zone/*/record")
ckReq.AddRules([]string{"POST"}, "/domain/zone/*/refresh")
response, err := ckReq.Do()
if err != nil {
return nil, err
}
log.Infof("Generated consumer key: %s\n", response.ConsumerKey)
log.Infof("Please visit %s to validate it\n", response.ValidationURL)
return nil, fmt.Errorf("You have to validated the consumer key")
}
// TODO: Add Dry Run support
if dryRun {
return nil, ErrNoDryRun
}
return &OVHProvider{
client: client,
domainFilter: domainFilter,
DryRun: dryRun,
}, nil
}
// Records returns the list of records in all relevant zones.
func (p *OVHProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) {
endpoints := []*endpoint.Endpoint{}
_, records, err := p.zonesRecords(ctx)
if err != nil {
return nil, err
}
endpoints = ovhGroupByNameAndType(records)
log.Infof("OVH: %d endpoints have been found\n", len(endpoints))
return endpoints, nil
}
// ApplyChanges applies a given set of changes in a given zone.
func (p *OVHProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error {
zones, records, err := p.zonesRecords(ctx)
zonesChangeUniques := map[string]bool{}
if err != nil {
return err
}
allChanges := make([]ovhChange, 0, countTargets(changes.Create, changes.UpdateNew, changes.UpdateOld, changes.Delete))
allChanges = append(allChanges, newOvhChange(ovhCreate, changes.Create, zones, records)...)
allChanges = append(allChanges, newOvhChange(ovhCreate, changes.UpdateNew, zones, records)...)
allChanges = append(allChanges, newOvhChange(ovhDelete, changes.UpdateOld, zones, records)...)
allChanges = append(allChanges, newOvhChange(ovhDelete, changes.Delete, zones, records)...)
log.Infof("OVH: %d changes will be done\n", len(allChanges))
eg, _ := errgroup.WithContext(ctx)
for _, change := range allChanges {
change := change
zonesChangeUniques[change.Zone] = true
eg.Go(func() error { return p.change(change) })
}
if err := eg.Wait(); err != nil {
return err
}
log.Infof("OVH: %d zones will be refreshed\n", len(zonesChangeUniques))
eg, _ = errgroup.WithContext(ctx)
for zone := range zonesChangeUniques {
zone := zone
eg.Go(func() error { return p.refresh(zone) })
}
if err := eg.Wait(); err != nil {
return err
}
return nil
}
func (p *OVHProvider) refresh(zone string) error {
log.Debugf("OVH: Refresh %s zone\n", zone)
return p.client.Post(fmt.Sprintf("/domain/zone/%s/refresh", zone), nil, nil)
}
func (p *OVHProvider) change(change ovhChange) error {
var err error = nil
switch change.Action {
case ovhCreate:
log.Debugf("OVH: Add an entry to %s\n", change.String())
err = p.client.Post(fmt.Sprintf("/domain/zone/%s/record", change.Zone), change.ovhRecordFields, nil)
case ovhDelete:
if change.ID == 0 {
err = ErrRecordToMutateNotFound
break
}
log.Debugf("OVH: Delete an entry to %s\n", change.String())
err = p.client.Delete(fmt.Sprintf("/domain/zone/%s/record/%d", change.Zone, change.ID), nil)
}
return err
}
func (p *OVHProvider) zonesRecords(ctx context.Context) ([]string, []ovhRecord, error) {
var allRecords []ovhRecord
zones, err := p.zones()
if err != nil {
return nil, nil, err
}
chRecords := make(chan []ovhRecord, len(zones))
eg, ctx := errgroup.WithContext(ctx)
for _, zone := range zones {
zone := zone
eg.Go(func() error { return p.records(&ctx, &zone, chRecords) })
}
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 *OVHProvider) zones() ([]string, error) {
zones := []string{}
filteredZones := []string{}
if err := p.client.Get("/domain/zone", &zones); err != nil {
return nil, err
}
for _, zoneName := range zones {
if p.domainFilter.Match(zoneName) {
filteredZones = append(filteredZones, zoneName)
}
}
log.Infof("OVH: %d zones found\n", len(filteredZones))
return filteredZones, nil
}
func (p *OVHProvider) records(ctx *context.Context, zone *string, records chan<- []ovhRecord) error {
var recordsIds []uint64
ovhRecords := make([]ovhRecord, len(recordsIds))
eg, _ := errgroup.WithContext(*ctx)
log.Debugf("OVH: Getting records for %s\n", *zone)
if err := p.client.Get(fmt.Sprintf("/domain/zone/%s/record", *zone), &recordsIds); err != nil {
return err
}
chRecords := make(chan ovhRecord, len(recordsIds))
for _, id := range recordsIds {
id := id
eg.Go(func() error { return p.record(zone, id, chRecords) })
}
if err := eg.Wait(); err != nil {
return err
}
close(chRecords)
for record := range chRecords {
ovhRecords = append(ovhRecords, record)
}
records <- ovhRecords
return nil
}
func (p *OVHProvider) record(zone *string, id uint64, records chan<- ovhRecord) error {
record := ovhRecord{}
log.Debugf("OVH: Getting record %d for %s\n", id, *zone)
if err := p.client.Get(fmt.Sprintf("/domain/zone/%s/record/%d", *zone, id), &record); err != nil {
return err
}
if supportedRecordType(record.FieldType) {
log.Debugf("OVH: Record %d for %s is %+v\n", id, *zone, record)
records <- record
}
return nil
}
func ovhGroupByNameAndType(records []ovhRecord) []*endpoint.Endpoint {
endpoints := []*endpoint.Endpoint{}
// group supported records by name and type
groups := map[string][]ovhRecord{}
for _, r := range records {
groupBy := r.Zone + r.SubDomain + r.FieldType
if _, ok := groups[groupBy]; !ok {
groups[groupBy] = []ovhRecord{}
}
groups[groupBy] = append(groups[groupBy], r)
}
// create single endpoint with all the targets for each name/type
for _, records := range groups {
targets := []string{}
for _, record := range records {
targets = append(targets, record.Target)
}
endpoint := endpoint.NewEndpointWithTTL(
strings.TrimPrefix(records[0].SubDomain+"."+records[0].Zone, "."),
records[0].FieldType,
endpoint.TTL(records[0].TTL),
targets...,
)
endpoints = append(endpoints, endpoint)
}
return endpoints
}
func newOvhChange(action int, endpoints []*endpoint.Endpoint, zones []string, records []ovhRecord) []ovhChange {
zoneNameIDMapper := zoneIDName{}
ovhChanges := make([]ovhChange, 0, countTargets(endpoints))
for _, zone := range zones {
zoneNameIDMapper.Add(zone, zone)
}
for _, endpoint := range endpoints {
zone, _ := zoneNameIDMapper.FindZone(endpoint.DNSName)
if zone == "" {
log.Debugf("Skipping record %s because no hosted zone matching record DNS Name was detected", endpoint.DNSName)
continue
}
for _, target := range endpoint.Targets {
change := ovhChange{
Action: action,
ovhRecord: ovhRecord{
Zone: zone,
ovhRecordFields: ovhRecordFields{
FieldType: endpoint.RecordType,
SubDomain: strings.TrimSuffix(endpoint.DNSName, "."+zone),
TTL: ovhDefaultTTL,
Target: target,
},
},
}
if endpoint.RecordTTL.IsConfigured() {
change.TTL = int64(endpoint.RecordTTL)
}
for _, record := range records {
if record.Zone == change.Zone && record.SubDomain == change.SubDomain && record.FieldType == change.FieldType && record.Target == change.Target {
change.ID = record.ID
}
}
ovhChanges = append(ovhChanges, change)
}
}
return ovhChanges
}
func countTargets(allEndpoints ...[]*endpoint.Endpoint) int {
count := 0
for _, endpoints := range allEndpoints {
for _, endpoint := range endpoints {
count += len(endpoint.Targets)
}
}
return count
}
func (c *ovhChange) String() string {
if c.ID != 0 {
return fmt.Sprintf("%s zone (ID : %d) : %s %d IN %s %s", c.Zone, c.ID, c.SubDomain, c.TTL, c.FieldType, c.Target)
}
return fmt.Sprintf("%s zone : %s %d IN %s %s", c.Zone, c.SubDomain, c.TTL, c.FieldType, c.Target)
}
/*
Copyright 2017 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package provider
import (
"context"
"encoding/json"
"sort"
"testing"
"github.com/ovh/go-ovh/ovh"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"sigs.k8s.io/external-dns/endpoint"
"sigs.k8s.io/external-dns/plan"
)
type mockOvhClient struct {
mock.Mock
}
func (c *mockOvhClient) Post(endpoint string, input interface{}, output interface{}) error {
stub := c.Called(endpoint, input)
data, _ := json.Marshal(stub.Get(0))
json.Unmarshal(data, output)
return stub.Error(1)
}
func (c *mockOvhClient) Get(endpoint string, output interface{}) error {
stub := c.Called(endpoint)
data, _ := json.Marshal(stub.Get(0))
json.Unmarshal(data, output)
return stub.Error(1)
}
func (c *mockOvhClient) Delete(endpoint string, output interface{}) error {
stub := c.Called(endpoint)
data, _ := json.Marshal(stub.Get(0))
json.Unmarshal(data, output)
return stub.Error(1)
}
func TestOvhZones(t *testing.T) {
assert := assert.New(t)
client := new(mockOvhClient)
provider := &OVHProvider{
client: client,
domainFilter: NewDomainFilter([]string{"com"}),
}
// Basic zones
client.On("Get", "/domain/zone").Return([]string{"example.com", "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", "/domain/zone").Return(nil, ovh.ErrAPIDown).Once()
domains, err = provider.zones()
assert.Error(err)
assert.Nil(domains)
client.AssertExpectations(t)
}
func TestOvhZoneRecords(t *testing.T) {
assert := assert.New(t)
client := new(mockOvhClient)
provider := &OVHProvider{client: client}
// Basic zones records
client.On("Get", "/domain/zone").Return([]string{"example.org"}, nil).Once()
client.On("Get", "/domain/zone/example.org/record").Return([]uint64{24, 42}, nil).Once()
client.On("Get", "/domain/zone/example.org/record/24").Return(ovhRecord{ID: 24, Zone: "example.org", ovhRecordFields: ovhRecordFields{SubDomain: "ovh", FieldType: "NS", TTL: 10, Target: "203.0.113.42"}}, nil).Once()
client.On("Get", "/domain/zone/example.org/record/42").Return(ovhRecord{ID: 42, Zone: "example.org", ovhRecordFields: ovhRecordFields{SubDomain: "ovh", FieldType: "A", TTL: 10, Target: "203.0.113.42"}}, nil).Once()
zones, records, err := provider.zonesRecords(context.TODO())
assert.NoError(err)
assert.ElementsMatch(zones, []string{"example.org"})
assert.ElementsMatch(records, []ovhRecord{{ID: 42, Zone: "example.org", ovhRecordFields: ovhRecordFields{SubDomain: "ovh", FieldType: "A", TTL: 10, Target: "203.0.113.42"}}})
client.AssertExpectations(t)
// Error on getting zones list
client.On("Get", "/domain/zone").Return(nil, ovh.ErrAPIDown).Once()
zones, records, err = provider.zonesRecords(context.TODO())
assert.Error(err)
assert.Nil(zones)
assert.Nil(records)
client.AssertExpectations(t)
// Error on getting zone records
client.On("Get", "/domain/zone").Return([]string{"example.org"}, nil).Once()
client.On("Get", "/domain/zone/example.org/record").Return(nil, ovh.ErrAPIDown).Once()
zones, records, err = provider.zonesRecords(context.TODO())
assert.Error(err)
assert.Nil(zones)
assert.Nil(records)
client.AssertExpectations(t)
// Error on getting zone record detail
client.On("Get", "/domain/zone").Return([]string{"example.org"}, nil).Once()
client.On("Get", "/domain/zone/example.org/record").Return([]uint64{42}, nil).Once()
client.On("Get", "/domain/zone/example.org/record/42").Return(nil, ovh.ErrAPIDown).Once()
zones, records, err = provider.zonesRecords(context.TODO())
assert.Error(err)
assert.Nil(zones)
assert.Nil(records)
client.AssertExpectations(t)
}
func TestOvhRecords(t *testing.T) {
assert := assert.New(t)
client := new(mockOvhClient)
provider := &OVHProvider{client: client}
// Basic zones records
client.On("Get", "/domain/zone").Return([]string{"example.org", "example.net"}, nil).Once()
client.On("Get", "/domain/zone/example.org/record").Return([]uint64{24, 42}, nil).Once()
client.On("Get", "/domain/zone/example.org/record/24").Return(ovhRecord{ID: 24, Zone: "example.org", ovhRecordFields: ovhRecordFields{SubDomain: "", FieldType: "A", TTL: 10, Target: "203.0.113.42"}}, nil).Once()
client.On("Get", "/domain/zone/example.org/record/42").Return(ovhRecord{ID: 42, Zone: "example.org", ovhRecordFields: ovhRecordFields{SubDomain: "www", FieldType: "CNAME", TTL: 10, Target: "example.org."}}, nil).Once()
client.On("Get", "/domain/zone/example.net/record").Return([]uint64{24, 42}, nil).Once()
client.On("Get", "/domain/zone/example.net/record/24").Return(ovhRecord{ID: 24, Zone: "example.net", ovhRecordFields: ovhRecordFields{SubDomain: "ovh", FieldType: "A", TTL: 10, Target: "203.0.113.42"}}, nil).Once()
client.On("Get", "/domain/zone/example.net/record/42").Return(ovhRecord{ID: 42, Zone: "example.net", ovhRecordFields: ovhRecordFields{SubDomain: "ovh", FieldType: "A", TTL: 10, Target: "203.0.113.43"}}, nil).Once()
endpoints, err := provider.Records(context.TODO())
assert.NoError(err)
// Little fix for multi targets endpoint
for _, endoint := range endpoints {
sort.Strings(endoint.Targets)
}
assert.ElementsMatch(endpoints, []*endpoint.Endpoint{
&endpoint.Endpoint{DNSName: "example.org", RecordType: "A", RecordTTL: 10, Labels: endpoint.NewLabels(), Targets: []string{"203.0.113.42"}},
&endpoint.Endpoint{DNSName: "www.example.org", RecordType: "CNAME", RecordTTL: 10, Labels: endpoint.NewLabels(), Targets: []string{"example.org"}},
&endpoint.Endpoint{DNSName: "ovh.example.net", RecordType: "A", RecordTTL: 10, Labels: endpoint.NewLabels(), Targets: []string{"203.0.113.42", "203.0.113.43"}},
})
client.AssertExpectations(t)
// Error getting zone
client.On("Get", "/domain/zone").Return(nil, ovh.ErrAPIDown).Once()
endpoints, err = provider.Records(context.TODO())
assert.Error(err)
assert.Nil(endpoints)
client.AssertExpectations(t)
}
func TestOvhRefresh(t *testing.T) {
client := new(mockOvhClient)
provider := &OVHProvider{client: client}
// Basic zone refresh
client.On("Post", "/domain/zone/example.net/refresh", nil).Return(nil, nil).Once()
provider.refresh("example.net")
client.AssertExpectations(t)
}
func TestOvhNewChange(t *testing.T) {
assert := assert.New(t)
endpoints := []*endpoint.Endpoint{
{DNSName: ".example.net", RecordType: "A", RecordTTL: 10, Targets: []string{"203.0.113.42"}},
{DNSName: "ovh.example.net", RecordType: "A", Targets: []string{"203.0.113.43"}},
{DNSName: "test.example.org"},
}
// Create change
changes := newOvhChange(ovhCreate, endpoints, []string{"example.net"}, []ovhRecord{})
assert.ElementsMatch(changes, []ovhChange{
{Action: ovhCreate, ovhRecord: ovhRecord{Zone: "example.net", ovhRecordFields: ovhRecordFields{SubDomain: "", FieldType: "A", TTL: 10, Target: "203.0.113.42"}}},
{Action: ovhCreate, ovhRecord: ovhRecord{Zone: "example.net", ovhRecordFields: ovhRecordFields{SubDomain: "ovh", FieldType: "A", TTL: ovhDefaultTTL, Target: "203.0.113.43"}}},
})
// Delete change
endpoints = []*endpoint.Endpoint{
{DNSName: "ovh.example.net", RecordType: "A", Targets: []string{"203.0.113.42"}},
}
records := []ovhRecord{
{ID: 42, Zone: "example.net", ovhRecordFields: ovhRecordFields{FieldType: "A", SubDomain: "ovh", Target: "203.0.113.42"}},
}
changes = newOvhChange(ovhDelete, endpoints, []string{"example.net"}, records)
assert.ElementsMatch(changes, []ovhChange{
{Action: ovhDelete, ovhRecord: ovhRecord{ID: 42, Zone: "example.net", ovhRecordFields: ovhRecordFields{SubDomain: "ovh", FieldType: "A", TTL: ovhDefaultTTL, Target: "203.0.113.42"}}},
})
}
func TestOvhApplyChanges(t *testing.T) {
assert := assert.New(t)
client := new(mockOvhClient)
provider := &OVHProvider{client: client}
changes := plan.Changes{
Create: []*endpoint.Endpoint{
{DNSName: ".example.net", RecordType: "A", RecordTTL: 10, Targets: []string{"203.0.113.42"}},
},
Delete: []*endpoint.Endpoint{
{DNSName: "ovh.example.net", RecordType: "A", Targets: []string{"203.0.113.43"}},
},
}
client.On("Get", "/domain/zone").Return([]string{"example.net"}, nil).Once()
client.On("Get", "/domain/zone/example.net/record").Return([]uint64{42}, nil).Once()
client.On("Get", "/domain/zone/example.net/record/42").Return(ovhRecord{ID: 42, Zone: "example.net", ovhRecordFields: ovhRecordFields{SubDomain: "ovh", FieldType: "A", TTL: 10, Target: "203.0.113.43"}}, nil).Once()
client.On("Post", "/domain/zone/example.net/record", ovhRecordFields{SubDomain: "", FieldType: "A", TTL: 10, Target: "203.0.113.42"}).Return(nil, nil).Once()
client.On("Delete", "/domain/zone/example.net/record/42").Return(nil, nil).Once()
client.On("Post", "/domain/zone/example.net/refresh", nil).Return(nil, nil).Once()
// Basic changes
assert.NoError(provider.ApplyChanges(context.TODO(), &changes))
client.AssertExpectations(t)
// Getting zones failed
client.On("Get", "/domain/zone").Return(nil, ovh.ErrAPIDown).Once()
assert.Error(provider.ApplyChanges(context.TODO(), &changes))
client.AssertExpectations(t)
// Apply change failed
client.On("Get", "/domain/zone").Return([]string{"example.net"}, nil).Once()
client.On("Get", "/domain/zone/example.net/record").Return([]uint64{}, nil).Once()
client.On("Post", "/domain/zone/example.net/record", ovhRecordFields{SubDomain: "", FieldType: "A", TTL: 10, Target: "203.0.113.42"}).Return(nil, ovh.ErrAPIDown).Once()
assert.Error(provider.ApplyChanges(context.TODO(), &plan.Changes{
Create: []*endpoint.Endpoint{
{DNSName: ".example.net", RecordType: "A", RecordTTL: 10, Targets: []string{"203.0.113.42"}},
},
}))
client.AssertExpectations(t)
// Refresh failed
client.On("Get", "/domain/zone").Return([]string{"example.net"}, nil).Once()
client.On("Get", "/domain/zone/example.net/record").Return([]uint64{}, nil).Once()
client.On("Post", "/domain/zone/example.net/record", ovhRecordFields{SubDomain: "", FieldType: "A", TTL: 10, Target: "203.0.113.42"}).Return(nil, nil).Once()
client.On("Post", "/domain/zone/example.net/refresh", nil).Return(nil, ovh.ErrAPIDown).Once()
assert.Error(provider.ApplyChanges(context.TODO(), &plan.Changes{
Create: []*endpoint.Endpoint{
{DNSName: ".example.net", RecordType: "A", RecordTTL: 10, Targets: []string{"203.0.113.42"}},
},
}))
client.AssertExpectations(t)
}
func TestOvhChange(t *testing.T) {
assert := assert.New(t)
client := new(mockOvhClient)
provider := &OVHProvider{client: client}
// Record creation
client.On("Post", "/domain/zone/example.net/record", ovhRecordFields{SubDomain: "ovh"}).Return(nil, nil).Once()
assert.NoError(provider.change(ovhChange{
Action: ovhCreate,
ovhRecord: ovhRecord{Zone: "example.net", ovhRecordFields: ovhRecordFields{SubDomain: "ovh"}},
}))
client.AssertExpectations(t)
// Record deletion
client.On("Delete", "/domain/zone/example.net/record/42").Return(nil, nil).Once()
assert.NoError(provider.change(ovhChange{
Action: ovhDelete,
ovhRecord: ovhRecord{ID: 42, Zone: "example.net", ovhRecordFields: ovhRecordFields{SubDomain: "ovh"}},
}))
client.AssertExpectations(t)
// Record deletion error
assert.Error(provider.change(ovhChange{
Action: ovhDelete,
ovhRecord: ovhRecord{Zone: "example.net", ovhRecordFields: ovhRecordFields{SubDomain: "ovh"}},
}))
client.AssertExpectations(t)
}
func TestOvhCountTargets(t *testing.T) {
cases := []struct {
endpoints [][]*endpoint.Endpoint
count int
}{
{[][]*endpoint.Endpoint{[]*endpoint.Endpoint{{DNSName: "ovh.example.net", Targets: endpoint.Targets{"target"}}}}, 1},
{[][]*endpoint.Endpoint{[]*endpoint.Endpoint{{DNSName: "ovh.example.net", Targets: endpoint.Targets{"target"}}, {DNSName: "ovh.example.net", Targets: endpoint.Targets{"target"}}}}, 2},
{[][]*endpoint.Endpoint{[]*endpoint.Endpoint{{DNSName: "ovh.example.net", Targets: endpoint.Targets{"target", "target", "target"}}}}, 3},
{[][]*endpoint.Endpoint{[]*endpoint.Endpoint{{DNSName: "ovh.example.net", Targets: endpoint.Targets{"target", "target"}}}, []*endpoint.Endpoint{{DNSName: "ovh.example.net", Targets: endpoint.Targets{"target", "target"}}}}, 4},
}
for _, test := range cases {
count := countTargets(test.endpoints...)
if count != test.count {
t.Errorf("Wrong targets counts (Should be %d, get %d)", test.count, count)
}
}
}
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment