diff --git a/Gopkg.lock b/Gopkg.lock index d776ce0e4f..898b8fd262 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -232,6 +232,12 @@ packages = ["."] revision = "44d81051d367757e1c7c6a5a86423ece9afcf63c" +[[projects]] + branch = "master" + name = "github.com/gophercloud/gophercloud" + packages = ["."] + revision = "bfc4756e1a693a850d7d459f4b28b21f35a24b5a" + [[projects]] name = "github.com/howeyc/gopass" packages = ["."] @@ -628,6 +634,6 @@ [solve-meta] analyzer-name = "dep" analyzer-version = 1 - inputs-digest = "868d41f936420b74af7f11087b9cb6195560f33df48f0c6141c858a81a01b05d" + inputs-digest = "3cc043ee8be5b7cdba42793f12259ad23e13608d4b6a18523e540e14f029c0b0" solver-name = "gps-cdcl" solver-version = 1 diff --git a/docs/tutorials/designate.md b/docs/tutorials/designate.md new file mode 100644 index 0000000000..34fd209e10 --- /dev/null +++ b/docs/tutorials/designate.md @@ -0,0 +1,155 @@ +# Setting up ExternalDNS for Services on OpenStack Designate + +This tutorial describes how to setup ExternalDNS for usage within a Kubernetes cluster using OpenStack Designate DNS. + +## Authenticating with OpenStack + +We are going to use OpenStack CLI - `openstack` utility, which is an umbrella application for most of OpenStack clients including `designate`. + +All OpenStack CLIs require authentication parameters to be provided. These parameters include: +* URL of the OpenStack identity service (`keystone`) which is responsible for user authentication and also served as a registry for other + OpenStack services. Designate endpoints must be registered in `keystone` in order to ExternalDNS and OpenStack CLI be able to find them. +* OpenStack region name +* User login name. +* User project (tenant) name. +* User domain (only when using keystone API v3) + +Although these parameters can be passed explicitly through the CLI flags, traditionally it is done by sourcing `openrc` file (`source ~/openrc`) that is a +shell snippet that sets environment variables that all OpenStack CLI understand by convention. + +Recent versions of OpenStack Dashboard have a nice UI to download `openrc` file for both v2 and v3 auth protocols. Both protocols can be used with ExternalDNS. +v3 is generally preferred over v2, but might not be available in some OpenStack installations. + +## Installing OpenStack Designate + +Please refer to the Designate deployment [tutorial](https://docs.openstack.org/project-install-guide/dns/ocata/install.html) for instructions on how +to install and test Designate with BIND backend. You will be required to have admin rights in existing OpenStack installation to do this. One convenient +way to get yourself an OpenStack installation to play with is to use [DevStack](https://docs.openstack.org/devstack/latest/). + +## Creating DNS zones + +All domain names that are ExternalDNS is going to create must belong to one of DNS zones created in advance. Here is an example of how to create `example.com` DNS zone: +```console +$ openstack zone create --email dnsmaster@example.com example.com. +``` + +It is important to manually create all the zones that are going to be used for kubernetes entities (ExternalDNS sources) before starting ExternalDNS. + +## Deploy ExternalDNS + +Create a deployment file called `externaldns.yaml` with the following contents: + +```yaml +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: external-dns +spec: + strategy: + type: Recreate + template: + metadata: + labels: + app: external-dns + spec: + containers: + - name: external-dns + image: registry.opensource.zalan.do/teapot/external-dns + 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=designate + env: # values from openrc file + - name: OS_AUTH_URL + value: http://controller/identity/v3 + - name: OS_REGION_NAME + value: RegionOne + - name: OS_USERNAME + value: admin + - name: OS_PASSWORD + value: p@ssw0rd + - name: OS_PROJECT_NAME + value: demo + - name: OS_USER_DOMAIN_NAME + value: Default +``` + +Create the deployment for ExternalDNS: + +```console +$ kubectl create -f externaldns.yaml +``` + +## Deploying an Nginx Service + +Create a service file called 'nginx.yaml' with the following contents: + +```yaml +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: nginx +spec: + template: + metadata: + labels: + app: nginx + spec: + containers: + - image: nginx + name: nginx + ports: + - containerPort: 80 +--- +apiVersion: v1 +kind: Service +metadata: + name: nginx + annotations: + external-dns.alpha.kubernetes.io/hostname: my-app.example.com +spec: + selector: + app: nginx + type: LoadBalancer + ports: + - protocol: TCP + port: 80 + targetPort: 80 +``` + +Note the annotation on the service; use the same hostname as the DNS zone created above. + +ExternalDNS uses this annotation to determine what services should be registered with DNS. Removing the annotation will cause ExternalDNS to remove the corresponding DNS records. + +Create the deployment and service: + +```console +$ kubectl create -f nginx.yaml +``` + + +Once the service has an external IP assigned, ExternalDNS will notice the new service IP address and notify Designate, +which in turn synchronize DNS records with underlying DNS server backend. + +## Verifying DNS records + +To verify that DNS record was indeed created, you can use the following command: + +```console +$ openstack recordset list example.com. +``` + +There should be a record for my-app.example.com having `ACTIVE` status. And of course, the ultimate method to verify is to issue a DNS query: + +```console +$ dig my-app.example.com @controller +``` + +## Cleanup + +Now that we have verified that ExternalDNS created all DNS records, we can delete the tutorial's example: + +```console +$ kubectl delete service -f nginx.yaml +$ kubectl delete service -f externaldns.yaml +``` diff --git a/main.go b/main.go index 76a8c66b6f..ebf19f158e 100644 --- a/main.go +++ b/main.go @@ -136,6 +136,9 @@ func main() { p, err = provider.NewInMemoryProvider(provider.InMemoryInitZones(cfg.InMemoryZones), provider.InMemoryWithDomain(domainFilter), provider.InMemoryWithLogging()), nil case "pdns": p, err = provider.NewPDNSProvider(cfg.PDNSServer, cfg.PDNSAPIKey, domainFilter, cfg.DryRun) + case "designate": + p, err = provider.NewDesignateProvider(domainFilter, cfg.DryRun) + default: log.Fatalf("unknown dns provider: %s", cfg.Provider) } diff --git a/pkg/apis/externaldns/types.go b/pkg/apis/externaldns/types.go index 76833f85f8..3721d36ade 100644 --- a/pkg/apis/externaldns/types.go +++ b/pkg/apis/externaldns/types.go @@ -163,7 +163,7 @@ func (cfg *Config) ParseFlags(args []string) error { app.Flag("publish-internal-services", "Allow external-dns to publish DNS records for ClusterIP services (optional)").BoolVar(&cfg.PublishInternal) // Flags related to providers - app.Flag("provider", "The DNS provider where the DNS records will be created (required, options: aws, google, azure, cloudflare, digitalocean, dnsimple, infoblox, dyn, inmemory, pdns)").Required().PlaceHolder("provider").EnumVar(&cfg.Provider, "aws", "google", "azure", "cloudflare", "digitalocean", "dnsimple", "infoblox", "dyn", "inmemory", "pdns") + app.Flag("provider", "The DNS provider where the DNS records will be created (required, options: aws, google, azure, cloudflare, digitalocean, dnsimple, infoblox, dyn, designate, pdns, inmemory)").Required().PlaceHolder("provider").EnumVar(&cfg.Provider, "aws", "google", "azure", "cloudflare", "digitalocean", "dnsimple", "infoblox", "dyn", "desginate", "pdns", "inmemory") app.Flag("domain-filter", "Limit possible target zones by a domain suffix; specify multiple times for multiple domains (optional)").Default("").StringsVar(&cfg.DomainFilter) app.Flag("zone-id-filter", "Filter target zones by hosted zone id; specify multiple times for multiple zones (optional)").Default("").StringsVar(&cfg.ZoneIDFilter) app.Flag("google-project", "When using the Google provider, current project is auto-detected, when running on GCP. Specify other project with this. Must be specified when running outside GCP.").Default(defaultConfig.GoogleProject).StringVar(&cfg.GoogleProject) diff --git a/pkg/tlsutils/tlsconfig.go b/pkg/tlsutils/tlsconfig.go new file mode 100644 index 0000000000..d2e0fb7413 --- /dev/null +++ b/pkg/tlsutils/tlsconfig.go @@ -0,0 +1,85 @@ +/* +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 tlsutils + +import ( + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "io/ioutil" + "os" + "strings" +) + +// CreateTLSConfig creates tls.Config instance from TLS parameters passed in environment variables with the given prefix +func CreateTLSConfig(prefix string) (*tls.Config, error) { + caFile := os.Getenv(fmt.Sprintf("%s_CA_FILE", prefix)) + certFile := os.Getenv(fmt.Sprintf("%s_CERT_FILE", prefix)) + keyFile := os.Getenv(fmt.Sprintf("%s_KEY_FILE", prefix)) + serverName := os.Getenv(fmt.Sprintf("%s_TLS_SERVER_NAME", prefix)) + isInsecureStr := strings.ToLower(os.Getenv(fmt.Sprintf("%s_TLS_INSECURE", prefix))) + isInsecure := isInsecureStr == "true" || isInsecureStr == "yes" || isInsecureStr == "1" + tlsConfig, err := newTLSConfig(certFile, keyFile, caFile, serverName, isInsecure) + if err != nil { + return nil, err + } + return tlsConfig, nil +} + +func newTLSConfig(certPath, keyPath, caPath, serverName string, insecure bool) (*tls.Config, error) { + if certPath != "" && keyPath == "" || certPath == "" && keyPath != "" { + return nil, errors.New("either both cert and key or none must be provided") + } + var certificates []tls.Certificate + if certPath != "" { + cert, err := tls.LoadX509KeyPair(certPath, keyPath) + if err != nil { + return nil, fmt.Errorf("could not load TLS cert: %s", err) + } + certificates = append(certificates, cert) + } + roots, err := loadRoots(caPath) + if err != nil { + return nil, err + } + + return &tls.Config{ + Certificates: certificates, + RootCAs: roots, + InsecureSkipVerify: insecure, + ServerName: serverName, + }, nil +} + +// loads CA cert +func loadRoots(caPath string) (*x509.CertPool, error) { + if caPath == "" { + return nil, nil + } + + roots := x509.NewCertPool() + pem, err := ioutil.ReadFile(caPath) + if err != nil { + return nil, fmt.Errorf("error reading %s: %s", caPath, err) + } + ok := roots.AppendCertsFromPEM(pem) + if !ok { + return nil, fmt.Errorf("could not read root certs: %s", err) + } + return roots, nil +} diff --git a/provider/designate.go b/provider/designate.go new file mode 100644 index 0000000000..9f70dc759f --- /dev/null +++ b/provider/designate.go @@ -0,0 +1,440 @@ +/* +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 ( + "fmt" + "net" + "net/http" + "os" + "strings" + "time" + + "github.com/gophercloud/gophercloud" + "github.com/gophercloud/gophercloud/openstack" + "github.com/gophercloud/gophercloud/openstack/dns/v2/recordsets" + "github.com/gophercloud/gophercloud/openstack/dns/v2/zones" + "github.com/gophercloud/gophercloud/pagination" + log "github.com/sirupsen/logrus" + + "github.com/kubernetes-incubator/external-dns/endpoint" + "github.com/kubernetes-incubator/external-dns/pkg/tlsutils" + "github.com/kubernetes-incubator/external-dns/plan" +) + +const ( + // ID of the RecordSet from which endpoint was created + designateRecordSetID = "designate-recordset-id" + // Zone ID of the RecordSet + designateZoneID = "designate-record-id" + + // Initial records values of the RecordSet. This label is required in order not to loose records that haven't + // changed where there are several targets per domain and only some of them changed. + // Values are joined by zero-byte to in order to get a single string + designateOriginalRecords = "designate-original-records" +) + +// interface between provider and OpenStack DNS API +type designateClientInterface interface { + // ForEachZone calls handler for each zone managed by the Designate + ForEachZone(handler func(zone *zones.Zone) error) error + + // ForEachRecordSet calls handler for each recordset in the given DNS zone + ForEachRecordSet(zoneID string, handler func(recordSet *recordsets.RecordSet) error) error + + // CreateRecordSet creates recordset in the given DNS zone + CreateRecordSet(zoneID string, opts recordsets.CreateOpts) (string, error) + + // UpdateRecordSet updates recordset in the given DNS zone + UpdateRecordSet(zoneID, recordSetID string, opts recordsets.UpdateOpts) error + + // DeleteRecordSet deletes recordset in the given DNS zone + DeleteRecordSet(zoneID, recordSetID string) error +} + +// implementation of the designateClientInterface +type designateClient struct { + serviceClient *gophercloud.ServiceClient +} + +// factory function for the designateClientInterface +func newDesignateClient() (designateClientInterface, error) { + serviceClient, err := createDesignateServiceClient() + if err != nil { + return nil, err + } + return &designateClient{serviceClient}, nil +} + +// copies environment variables to new names without overwriting existing values +func remapEnv(mapping map[string]string) { + for k, v := range mapping { + currentVal := os.Getenv(k) + newVal := os.Getenv(v) + if currentVal == "" && newVal != "" { + os.Setenv(k, newVal) + } + } +} + +// returns OpenStack Keystone authentication settings by obtaining values from standard environment variables. +// also fixes incompatibilities between gophercloud implementation and *-stackrc files that can be downloaded +// from OpenStack dashboard in latest versions +func getAuthSettings() (gophercloud.AuthOptions, error) { + remapEnv(map[string]string{ + "OS_TENANT_NAME": "OS_PROJECT_NAME", + "OS_TENANT_ID": "OS_PROJECT_ID", + "OS_DOMAIN_NAME": "OS_USER_DOMAIN_NAME", + "OS_DOMAIN_ID": "OS_USER_DOMAIN_ID", + }) + + opts, err := openstack.AuthOptionsFromEnv() + if err != nil { + return gophercloud.AuthOptions{}, err + } + opts.AllowReauth = true + if !strings.HasSuffix(opts.IdentityEndpoint, "/") { + opts.IdentityEndpoint += "/" + } + if !strings.HasSuffix(opts.IdentityEndpoint, "/v2.0/") && !strings.HasSuffix(opts.IdentityEndpoint, "/v3/") { + opts.IdentityEndpoint += "v2.0/" + } + return opts, nil +} + +// authenticate in OpenStack and obtain Designate service endpoint +func createDesignateServiceClient() (*gophercloud.ServiceClient, error) { + opts, err := getAuthSettings() + if err != nil { + return nil, err + } + log.Infof("Using OpenStack Keystone at %s", opts.IdentityEndpoint) + authProvider, err := openstack.AuthenticatedClient(opts) + if err != nil { + return nil, err + } + + eo := gophercloud.EndpointOpts{ + Region: os.Getenv("OS_REGION_NAME"), + } + + client, err := openstack.NewDNSV2(authProvider, eo) + if err != nil { + return nil, err + } + + tlsConfig, err := tlsutils.CreateTLSConfig("OPENSTACK") + if err != nil { + return nil, err + } + + transport := &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + TLSClientConfig: tlsConfig, + } + client.ProviderClient.HTTPClient.Transport = transport + log.Infof("Found OpenStack Designate service at %s", client.Endpoint) + return client, nil +} + +// ForEachZone calls handler for each zone managed by the Designate +func (c designateClient) ForEachZone(handler func(zone *zones.Zone) error) error { + pager := zones.List(c.serviceClient, zones.ListOpts{}) + return pager.EachPage( + func(page pagination.Page) (bool, error) { + list, err := zones.ExtractZones(page) + if err != nil { + return false, err + } + for _, zone := range list { + err := handler(&zone) + if err != nil { + return false, err + } + } + return true, nil + }, + ) +} + +// ForEachRecordSet calls handler for each recordset in the given DNS zone +func (c designateClient) ForEachRecordSet(zoneID string, handler func(recordSet *recordsets.RecordSet) error) error { + pager := recordsets.ListByZone(c.serviceClient, zoneID, recordsets.ListOpts{}) + return pager.EachPage( + func(page pagination.Page) (bool, error) { + list, err := recordsets.ExtractRecordSets(page) + if err != nil { + return false, err + } + for _, recordSet := range list { + err := handler(&recordSet) + if err != nil { + return false, err + } + } + return true, nil + }, + ) +} + +// CreateRecordSet creates recordset in the given DNS zone +func (c designateClient) CreateRecordSet(zoneID string, opts recordsets.CreateOpts) (string, error) { + r, err := recordsets.Create(c.serviceClient, zoneID, opts).Extract() + if err != nil { + return "", err + } + return r.ID, nil +} + +// UpdateRecordSet updates recordset in the given DNS zone +func (c designateClient) UpdateRecordSet(zoneID, recordSetID string, opts recordsets.UpdateOpts) error { + _, err := recordsets.Update(c.serviceClient, zoneID, recordSetID, opts).Extract() + return err +} + +// DeleteRecordSet deletes recordset in the given DNS zone +func (c designateClient) DeleteRecordSet(zoneID, recordSetID string) error { + return recordsets.Delete(c.serviceClient, zoneID, recordSetID).ExtractErr() +} + +// designate provider type +type designateProvider struct { + client designateClientInterface + + // only consider hosted zones managing domains ending in this suffix + domainFilter DomainFilter + dryRun bool +} + +// NewDesignateProvider is a factory function for OpenStack designate providers +func NewDesignateProvider(domainFilter DomainFilter, dryRun bool) (Provider, error) { + client, err := newDesignateClient() + if err != nil { + return nil, err + } + return &designateProvider{ + client: client, + domainFilter: domainFilter, + dryRun: dryRun, + }, nil +} + +// converts domain name to FQDN +func canonicalizeDomainName(domain string) string { + if !strings.HasSuffix(domain, ".") { + domain += "." + } + return strings.ToLower(domain) +} + +// returns ZoneID -> ZoneName mapping for zones that are managed by the Designate and match domain filter +func (p designateProvider) getZones() (map[string]string, error) { + result := map[string]string{} + + err := p.client.ForEachZone( + func(zone *zones.Zone) error { + if zone.Type != "" && strings.ToUpper(zone.Type) != "PRIMARY" || zone.Status != "ACTIVE" { + return nil + } + + zoneName := canonicalizeDomainName(zone.Name) + if !p.domainFilter.Match(zoneName) { + return nil + } + result[zone.ID] = zoneName + return nil + }, + ) + + return result, err +} + +// finds best suitable DNS zone for the hostname +func (p designateProvider) getHostZoneID(hostname string, managedZones map[string]string) (string, error) { + longestZoneLength := 0 + resultID := "" + + for zoneID, zoneName := range managedZones { + if !strings.HasSuffix(hostname, zoneName) { + continue + } + ln := len(zoneName) + if ln > longestZoneLength { + resultID = zoneID + longestZoneLength = ln + } + } + + return resultID, nil +} + +// Records returns the list of records. +func (p designateProvider) Records() ([]*endpoint.Endpoint, error) { + var result []*endpoint.Endpoint + managedZones, err := p.getZones() + if err != nil { + return nil, err + } + for zoneID := range managedZones { + err = p.client.ForEachRecordSet(zoneID, + func(recordSet *recordsets.RecordSet) error { + if recordSet.Type != endpoint.RecordTypeA && recordSet.Type != endpoint.RecordTypeTXT && recordSet.Type != endpoint.RecordTypeCNAME { + return nil + } + for _, record := range recordSet.Records { + ep := endpoint.NewEndpoint(recordSet.Name, record, recordSet.Type) + ep.Labels[designateRecordSetID] = recordSet.ID + ep.Labels[designateZoneID] = recordSet.ZoneID + ep.Labels[designateOriginalRecords] = strings.Join(recordSet.Records, "\000") + result = append(result, ep) + } + return nil + }, + ) + if err != nil { + return nil, err + } + } + + return result, nil +} + +// temporary structure to hold recordset parameters so that we could aggregate endpoints into recordsets +type recordSet struct { + dnsName string + recordType string + zoneID string + recordSetID string + names map[string]bool +} + +// adds endpoint into recordset aggregation, loading original values from endpoint labels first +func addEndpoint(ep *endpoint.Endpoint, recordSets map[string]*recordSet, delete bool) { + key := fmt.Sprintf("%s/%s", ep.DNSName, ep.RecordType) + rs := recordSets[key] + if rs == nil { + rs = &recordSet{ + dnsName: canonicalizeDomainName(ep.DNSName), + recordType: ep.RecordType, + names: make(map[string]bool), + } + } + if rs.zoneID == "" { + rs.zoneID = ep.Labels[designateZoneID] + } + if rs.recordSetID == "" { + rs.recordSetID = ep.Labels[designateRecordSetID] + } + for _, rec := range strings.Split(ep.Labels[designateOriginalRecords], "\000") { + if _, ok := rs.names[rec]; !ok && rec != "" { + rs.names[rec] = true + } + } + target := ep.Target + if ep.RecordType == endpoint.RecordTypeCNAME { + target = canonicalizeDomainName(target) + } + rs.names[target] = !delete + recordSets[key] = rs +} + +// ApplyChanges applies a given set of changes in a given zone. +func (p designateProvider) ApplyChanges(changes *plan.Changes) error { + managedZones, err := p.getZones() + if err != nil { + return err + } + recordSets := map[string]*recordSet{} + for _, ep := range changes.Create { + addEndpoint(ep, recordSets, false) + } + for _, ep := range changes.UpdateNew { + addEndpoint(ep, recordSets, false) + } + for _, ep := range changes.UpdateOld { + addEndpoint(ep, recordSets, true) + } + for _, ep := range changes.Delete { + addEndpoint(ep, recordSets, true) + } + for _, rs := range recordSets { + if err2 := p.upsertRecordSet(rs, managedZones); err == nil { + err = err2 + } + } + return err +} + +// apply recordset changes by inserting/updating/deleting recordsets +func (p designateProvider) upsertRecordSet(rs *recordSet, managedZones map[string]string) error { + if rs.zoneID == "" { + var err error + rs.zoneID, err = p.getHostZoneID(rs.dnsName, managedZones) + if err != nil { + return err + } + if rs.zoneID == "" { + log.Debugf("Skipping record %s because no hosted zone matching record DNS Name was detected ", rs.dnsName) + return nil + } + } + var records []string + for rec, v := range rs.names { + if v { + records = append(records, rec) + } + } + if rs.recordSetID == "" && records == nil { + return nil + } + if rs.recordSetID == "" { + opts := recordsets.CreateOpts{ + Name: rs.dnsName, + Type: rs.recordType, + Records: records, + } + log.Infof("Creating records: %s/%s: %s", rs.dnsName, rs.recordType, strings.Join(records, ",")) + if p.dryRun { + return nil + } + _, err := p.client.CreateRecordSet(rs.zoneID, opts) + return err + } else if len(records) == 0 { + log.Infof("Deleting records for %s/%s", rs.dnsName, rs.recordType) + if p.dryRun { + return nil + } + return p.client.DeleteRecordSet(rs.zoneID, rs.recordSetID) + } else { + opts := recordsets.UpdateOpts{ + Records: records, + } + log.Infof("Updating records: %s/%s: %s", rs.dnsName, rs.recordType, strings.Join(records, ",")) + if p.dryRun { + return nil + } + return p.client.UpdateRecordSet(rs.zoneID, rs.recordSetID, opts) + } +} diff --git a/provider/designate_test.go b/provider/designate_test.go new file mode 100644 index 0000000000..fe1579ff6c --- /dev/null +++ b/provider/designate_test.go @@ -0,0 +1,519 @@ +/* +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 ( + "fmt" + "reflect" + "sort" + "sync/atomic" + "testing" + + "github.com/gophercloud/gophercloud/openstack/dns/v2/recordsets" + "github.com/gophercloud/gophercloud/openstack/dns/v2/zones" + "github.com/kubernetes-incubator/external-dns/endpoint" + "github.com/kubernetes-incubator/external-dns/plan" +) + +var lastGeneratedDesignateID int32 + +func generateDesignateID() string { + return fmt.Sprintf("id-%d", atomic.AddInt32(&lastGeneratedDesignateID, 1)) +} + +type fakeDesignateClient struct { + managedZones map[string]*struct { + zone *zones.Zone + recordSets map[string]*recordsets.RecordSet + } +} + +func (c fakeDesignateClient) AddZone(zone zones.Zone) string { + if zone.ID == "" { + zone.ID = zone.Name + } + c.managedZones[zone.ID] = &struct { + zone *zones.Zone + recordSets map[string]*recordsets.RecordSet + }{ + zone: &zone, + recordSets: make(map[string]*recordsets.RecordSet), + } + return zone.ID +} + +func (c fakeDesignateClient) ForEachZone(handler func(zone *zones.Zone) error) error { + for _, zone := range c.managedZones { + if err := handler(zone.zone); err != nil { + return err + } + } + return nil +} + +func (c fakeDesignateClient) ForEachRecordSet(zoneID string, handler func(recordSet *recordsets.RecordSet) error) error { + zone := c.managedZones[zoneID] + if zone == nil { + return fmt.Errorf("unknown zone %s", zoneID) + } + for _, recordSet := range zone.recordSets { + if err := handler(recordSet); err != nil { + return err + } + } + return nil +} + +func (c fakeDesignateClient) CreateRecordSet(zoneID string, opts recordsets.CreateOpts) (string, error) { + zone := c.managedZones[zoneID] + if zone == nil { + return "", fmt.Errorf("unknown zone %s", zoneID) + } + rs := &recordsets.RecordSet{ + ID: generateDesignateID(), + ZoneID: zoneID, + Name: opts.Name, + Description: opts.Description, + Records: opts.Records, + TTL: opts.TTL, + Type: opts.Type, + } + zone.recordSets[rs.ID] = rs + return rs.ID, nil +} + +func (c fakeDesignateClient) UpdateRecordSet(zoneID, recordSetID string, opts recordsets.UpdateOpts) error { + zone := c.managedZones[zoneID] + if zone == nil { + return fmt.Errorf("unknown zone %s", zoneID) + } + rs := zone.recordSets[recordSetID] + if rs == nil { + return fmt.Errorf("unknown record-set %s", recordSetID) + } + rs.Description = opts.Description + rs.TTL = opts.TTL + rs.Records = opts.Records + return nil +} + +func (c fakeDesignateClient) DeleteRecordSet(zoneID, recordSetID string) error { + zone := c.managedZones[zoneID] + if zone == nil { + return fmt.Errorf("unknown zone %s", zoneID) + } + delete(zone.recordSets, recordSetID) + return nil +} + +func (c fakeDesignateClient) ToProvider() Provider { + return &designateProvider{client: c} +} + +func newFakeDesignateClient() *fakeDesignateClient { + return &fakeDesignateClient{ + make(map[string]*struct { + zone *zones.Zone + recordSets map[string]*recordsets.RecordSet + }), + } +} + +func TestDesignateRecords(t *testing.T) { + client := newFakeDesignateClient() + + zone1ID := client.AddZone(zones.Zone{ + Name: "example.com.", + Type: "PRIMARY", + Status: "ACTIVE", + }) + rs11ID, _ := client.CreateRecordSet(zone1ID, recordsets.CreateOpts{ + Name: "www.example.com.", + Type: endpoint.RecordTypeA, + Records: []string{"10.1.1.1"}, + }) + rs12ID, _ := client.CreateRecordSet(zone1ID, recordsets.CreateOpts{ + Name: "www.example.com.", + Type: endpoint.RecordTypeTXT, + Records: []string{"text1"}, + }) + client.CreateRecordSet(zone1ID, recordsets.CreateOpts{ + Name: "xxx.example.com.", + Type: "SRV", + Records: []string{"http://test.com:1234"}, + }) + rs14ID, _ := client.CreateRecordSet(zone1ID, recordsets.CreateOpts{ + Name: "ftp.example.com.", + Type: endpoint.RecordTypeA, + Records: []string{"10.1.1.2"}, + }) + + zone2ID := client.AddZone(zones.Zone{ + Name: "test.net.", + Type: "PRIMARY", + Status: "ACTIVE", + }) + rs21ID, _ := client.CreateRecordSet(zone2ID, recordsets.CreateOpts{ + Name: "srv.test.net.", + Type: endpoint.RecordTypeA, + Records: []string{"10.2.1.1", "10.2.1.2"}, + }) + rs22ID, _ := client.CreateRecordSet(zone2ID, recordsets.CreateOpts{ + Name: "db.test.net.", + Type: endpoint.RecordTypeCNAME, + Records: []string{"sql.test.net."}, + }) + expected := []*endpoint.Endpoint{ + { + DNSName: "www.example.com", + RecordType: endpoint.RecordTypeA, + Target: "10.1.1.1", + Labels: map[string]string{ + designateRecordSetID: rs11ID, + designateZoneID: zone1ID, + designateOriginalRecords: "10.1.1.1", + }, + }, + { + DNSName: "www.example.com", + RecordType: endpoint.RecordTypeTXT, + Target: "text1", + Labels: map[string]string{ + designateRecordSetID: rs12ID, + designateZoneID: zone1ID, + designateOriginalRecords: "text1", + }, + }, + { + DNSName: "ftp.example.com", + RecordType: endpoint.RecordTypeA, + Target: "10.1.1.2", + Labels: map[string]string{ + designateRecordSetID: rs14ID, + designateZoneID: zone1ID, + designateOriginalRecords: "10.1.1.2", + }, + }, + { + DNSName: "srv.test.net", + RecordType: endpoint.RecordTypeA, + Target: "10.2.1.1", + Labels: map[string]string{ + designateRecordSetID: rs21ID, + designateZoneID: zone2ID, + designateOriginalRecords: "10.2.1.1\00010.2.1.2", + }, + }, + { + DNSName: "srv.test.net", + RecordType: endpoint.RecordTypeA, + Target: "10.2.1.2", + Labels: map[string]string{ + designateRecordSetID: rs21ID, + designateZoneID: zone2ID, + designateOriginalRecords: "10.2.1.1\00010.2.1.2", + }, + }, + { + DNSName: "db.test.net", + RecordType: endpoint.RecordTypeCNAME, + Target: "sql.test.net", + Labels: map[string]string{ + designateRecordSetID: rs22ID, + designateZoneID: zone2ID, + designateOriginalRecords: "sql.test.net.", + }, + }, + } + + endpoints, err := client.ToProvider().Records() + if err != nil { + t.Fatal(err) + } +out: + for _, ep := range endpoints { + for i, ex := range expected { + if reflect.DeepEqual(ep, ex) { + expected = append(expected[:i], expected[i+1:]...) + continue out + } + } + t.Errorf("unexpected endpoint %s/%s -> %s", ep.DNSName, ep.RecordType, ep.Target) + } + if len(expected) != 0 { + t.Errorf("not all expected endpoints were returned. Remained: %v", expected) + } +} + +func TestDesignateCreateRecords(t *testing.T) { + client := newFakeDesignateClient() + testDesignateCreateRecords(t, client) +} + +func testDesignateCreateRecords(t *testing.T, client *fakeDesignateClient) []*recordsets.RecordSet { + + for i, zoneName := range []string{"example.com.", "test.net."} { + client.AddZone(zones.Zone{ + ID: fmt.Sprintf("zone-%d", i+1), + Name: zoneName, + Type: "PRIMARY", + Status: "ACTIVE", + }) + } + endpoints := []*endpoint.Endpoint{ + { + DNSName: "www.example.com", + RecordType: endpoint.RecordTypeA, + Target: "10.1.1.1", + Labels: map[string]string{}, + }, + { + DNSName: "www.example.com", + RecordType: endpoint.RecordTypeTXT, + Target: "text1", + Labels: map[string]string{}, + }, + { + DNSName: "ftp.example.com", + RecordType: endpoint.RecordTypeA, + Target: "10.1.1.2", + Labels: map[string]string{}, + }, + { + DNSName: "srv.test.net", + RecordType: endpoint.RecordTypeA, + Target: "10.2.1.1", + Labels: map[string]string{}, + }, + { + DNSName: "srv.test.net", + RecordType: endpoint.RecordTypeA, + Target: "10.2.1.2", + Labels: map[string]string{}, + }, + { + DNSName: "db.test.net", + RecordType: endpoint.RecordTypeCNAME, + Target: "sql.test.net", + Labels: map[string]string{}, + }, + } + expected := []*recordsets.RecordSet{ + { + Name: "www.example.com.", + Type: endpoint.RecordTypeA, + Records: []string{"10.1.1.1"}, + ZoneID: "zone-1", + }, + { + Name: "www.example.com.", + Type: endpoint.RecordTypeTXT, + Records: []string{"text1"}, + ZoneID: "zone-1", + }, + { + Name: "ftp.example.com.", + Type: endpoint.RecordTypeA, + Records: []string{"10.1.1.2"}, + ZoneID: "zone-1", + }, + { + Name: "srv.test.net.", + Type: endpoint.RecordTypeA, + Records: []string{"10.2.1.1", "10.2.1.2"}, + ZoneID: "zone-2", + }, + { + Name: "db.test.net.", + Type: endpoint.RecordTypeCNAME, + Records: []string{"sql.test.net."}, + ZoneID: "zone-2", + }, + } + expectedCopy := make([]*recordsets.RecordSet, len(expected)) + copy(expectedCopy, expected) + + err := client.ToProvider().ApplyChanges(&plan.Changes{Create: endpoints}) + if err != nil { + t.Fatal(err) + } + + client.ForEachZone(func(zone *zones.Zone) error { + client.ForEachRecordSet(zone.ID, func(recordSet *recordsets.RecordSet) error { + id := recordSet.ID + recordSet.ID = "" + for i, ex := range expected { + sort.Strings(recordSet.Records) + if reflect.DeepEqual(ex, recordSet) { + ex.ID = id + recordSet.ID = id + expected = append(expected[:i], expected[i+1:]...) + return nil + } + } + t.Errorf("unexpected record-set %s/%s -> %v", recordSet.Name, recordSet.Type, recordSet.Records) + return nil + }) + return nil + }) + + if len(expected) != 0 { + t.Errorf("not all expected record-sets were created. Remained: %v", expected) + } + return expectedCopy +} + +func TestDesignateUpdateRecords(t *testing.T) { + client := newFakeDesignateClient() + testDesignateUpdateRecords(t, client) +} + +func testDesignateUpdateRecords(t *testing.T, client *fakeDesignateClient) []*recordsets.RecordSet { + expected := testDesignateCreateRecords(t, client) + + updatesOld := []*endpoint.Endpoint{ + { + DNSName: "ftp.example.com", + RecordType: endpoint.RecordTypeA, + Target: "10.1.1.2", + Labels: map[string]string{ + designateZoneID: "zone-1", + designateRecordSetID: expected[2].ID, + designateOriginalRecords: "10.1.1.2", + }, + }, + { + DNSName: "srv.test.net.", + RecordType: endpoint.RecordTypeA, + Target: "10.2.1.2", + Labels: map[string]string{ + designateZoneID: "zone-2", + designateRecordSetID: expected[3].ID, + designateOriginalRecords: "10.2.1.1\00010.2.1.2", + }, + }, + } + updatesNew := []*endpoint.Endpoint{ + { + DNSName: "ftp.example.com", + RecordType: endpoint.RecordTypeA, + Target: "10.3.3.1", + Labels: map[string]string{ + designateZoneID: "zone-1", + designateRecordSetID: expected[2].ID, + designateOriginalRecords: "10.1.1.2", + }, + }, + { + DNSName: "srv.test.net.", + RecordType: endpoint.RecordTypeA, + Target: "10.3.3.2", + Labels: map[string]string{ + designateZoneID: "zone-2", + designateRecordSetID: expected[3].ID, + designateOriginalRecords: "10.2.1.1\00010.2.1.2", + }, + }, + } + expectedCopy := make([]*recordsets.RecordSet, len(expected)) + copy(expectedCopy, expected) + + expected[2].Records = []string{"10.3.3.1"} + expected[3].Records = []string{"10.2.1.1", "10.3.3.2"} + + err := client.ToProvider().ApplyChanges(&plan.Changes{UpdateOld: updatesOld, UpdateNew: updatesNew}) + if err != nil { + t.Fatal(err) + } + + client.ForEachZone(func(zone *zones.Zone) error { + client.ForEachRecordSet(zone.ID, func(recordSet *recordsets.RecordSet) error { + for i, ex := range expected { + sort.Strings(recordSet.Records) + if reflect.DeepEqual(ex, recordSet) { + expected = append(expected[:i], expected[i+1:]...) + return nil + } + } + t.Errorf("unexpected record-set %s/%s -> %v", recordSet.Name, recordSet.Type, recordSet.Records) + return nil + }) + return nil + }) + + if len(expected) != 0 { + t.Errorf("not all expected record-sets were updated. Remained: %v", expected) + } + return expectedCopy +} + +func TestDesignateDeleteRecords(t *testing.T) { + client := newFakeDesignateClient() + testDesignateDeleteRecords(t, client) +} + +func testDesignateDeleteRecords(t *testing.T, client *fakeDesignateClient) { + expected := testDesignateUpdateRecords(t, client) + deletes := []*endpoint.Endpoint{ + { + DNSName: "www.example.com.", + RecordType: endpoint.RecordTypeA, + Target: "10.1.1.1", + Labels: map[string]string{ + designateZoneID: "zone-1", + designateRecordSetID: expected[0].ID, + designateOriginalRecords: "10.1.1.1", + }, + }, + { + DNSName: "srv.test.net.", + RecordType: endpoint.RecordTypeA, + Target: "10.2.1.1", + Labels: map[string]string{ + designateZoneID: "zone-2", + designateRecordSetID: expected[3].ID, + designateOriginalRecords: "10.2.1.1\00010.3.3.2", + }, + }, + } + expected[3].Records = []string{"10.3.3.2"} + expected = expected[1:] + + err := client.ToProvider().ApplyChanges(&plan.Changes{Delete: deletes}) + if err != nil { + t.Fatal(err) + } + + client.ForEachZone(func(zone *zones.Zone) error { + client.ForEachRecordSet(zone.ID, func(recordSet *recordsets.RecordSet) error { + for i, ex := range expected { + sort.Strings(recordSet.Records) + if reflect.DeepEqual(ex, recordSet) { + expected = append(expected[:i], expected[i+1:]...) + return nil + } + } + t.Errorf("unexpected record-set %s/%s -> %v", recordSet.Name, recordSet.Type, recordSet.Records) + return nil + }) + return nil + }) + + if len(expected) != 0 { + t.Errorf("not all expected record-sets were deleted. Remained: %v", expected) + } +}