From ec822d7bbdc66ade882613018ef9aa2bfb2b9a4f Mon Sep 17 00:00:00 2001 From: Anhad Jai Singh Date: Mon, 16 Apr 2018 19:11:35 +0530 Subject: [PATCH] Add PowerDNS as a provider Commit adds: * Implementation of PowerDNS as a provider * Tests for said implementation * github.com/ffledgling/pdns-go, which provides go client bindings for PowerDNS's HTTP API, as a dependency * "pdns" as an additional option for the `--provider` flag * `--pdns-server` and `--pdns-api-key` as additional flags for PowerDNS specific configuration --- Gopkg.lock | 6 + main.go | 2 + pkg/apis/externaldns/types.go | 8 +- pkg/apis/externaldns/types_test.go | 8 + provider/pdns.go | 402 ++++++++++++++++++++ provider/pdns_test.go | 566 +++++++++++++++++++++++++++++ 6 files changed, 991 insertions(+), 1 deletion(-) create mode 100644 provider/pdns.go create mode 100644 provider/pdns_test.go diff --git a/Gopkg.lock b/Gopkg.lock index 0ca62ab67f..898b8fd262 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -166,6 +166,12 @@ ] revision = "09691a3b6378b740595c1002f40c34dd5f218a22" +[[projects]] + branch = "master" + name = "github.com/ffledgling/pdns-go" + packages = ["."] + revision = "524e7daccd99651cdb56426eb15b7d61f9597a5c" + [[projects]] name = "github.com/ghodss/yaml" packages = ["."] diff --git a/main.go b/main.go index b551cf32a5..3396b0e9db 100644 --- a/main.go +++ b/main.go @@ -136,6 +136,8 @@ func main() { p, err = provider.NewInMemoryProvider(provider.InMemoryInitZones(cfg.InMemoryZones), provider.InMemoryWithDomain(domainFilter), provider.InMemoryWithLogging()), nil case "designate": p, err = provider.NewDesignateProvider(domainFilter, cfg.DryRun) + case "pdns": + p, err = provider.NewPDNSProvider(cfg.PDNSServer, cfg.PDNSAPIKey, domainFilter, cfg.DryRun) default: log.Fatalf("unknown dns provider: %s", cfg.Provider) } diff --git a/pkg/apis/externaldns/types.go b/pkg/apis/externaldns/types.go index 78f639e308..ee670d7538 100644 --- a/pkg/apis/externaldns/types.go +++ b/pkg/apis/externaldns/types.go @@ -64,6 +64,8 @@ type Config struct { DynPassword string DynMinTTLSeconds int InMemoryZones []string + PDNSServer string + PDNSAPIKey string Policy string Registry string TXTOwnerID string @@ -100,6 +102,8 @@ var defaultConfig = &Config{ InfobloxWapiVersion: "2.3.1", InfobloxSSLVerify: true, InMemoryZones: []string{}, + PDNSServer: "http://localhost:8081", + PDNSAPIKey: "", Policy: "sync", Registry: "txt", TXTOwnerID: "default", @@ -159,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, designate, inmemory)").Required().PlaceHolder("provider").EnumVar(&cfg.Provider, "aws", "google", "azure", "cloudflare", "digitalocean", "dnsimple", "infoblox", "dyn", "desginate", "inmemory") + app.Flag("provider", "The DNS provider where the DNS records will be created (required, options: aws, google, azure, cloudflare, digitalocean, dnsimple, infoblox, dyn, designate, inmemory, pdns)").Required().PlaceHolder("provider").EnumVar(&cfg.Provider, "aws", "google", "azure", "cloudflare", "digitalocean", "dnsimple", "infoblox", "dyn", "desginate", "inmemory", "pdns") 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) @@ -179,6 +183,8 @@ func (cfg *Config) ParseFlags(args []string) error { app.Flag("dyn-min-ttl", "Minimal TTL (in seconds) for records. This value will be used if the provided TTL for a service/ingress is lower than this.").IntVar(&cfg.DynMinTTLSeconds) app.Flag("inmemory-zone", "Provide a list of pre-configured zones for the inmemory provider; specify multiple times for multiple zones (optional)").Default("").StringsVar(&cfg.InMemoryZones) + app.Flag("pdns-server", "When using the PowerDNS/PDNS provider, specify the URL to the pdns server (required when --provider=pdns)").Default(defaultConfig.PDNSServer).StringVar(&cfg.PDNSServer) + app.Flag("pdns-api-key", "When using the PowerDNS/PDNS provider, specify the URL to the pdns server (required when --provider=pdns)").Default(defaultConfig.PDNSAPIKey).StringVar(&cfg.PDNSAPIKey) // Flags related to policies app.Flag("policy", "Modify how DNS records are sychronized between sources and providers (default: sync, options: sync, upsert-only)").Default(defaultConfig.Policy).EnumVar(&cfg.Policy, "sync", "upsert-only") diff --git a/pkg/apis/externaldns/types_test.go b/pkg/apis/externaldns/types_test.go index 1cfb0d97ca..b59dcdf393 100644 --- a/pkg/apis/externaldns/types_test.go +++ b/pkg/apis/externaldns/types_test.go @@ -50,6 +50,8 @@ var ( InfobloxWapiVersion: "2.3.1", InfobloxSSLVerify: true, InMemoryZones: []string{""}, + PDNSServer: "http://localhost:8081", + PDNSAPIKey: "", Policy: "sync", Registry: "txt", TXTOwnerID: "default", @@ -84,6 +86,8 @@ var ( InfobloxWapiVersion: "2.6.1", InfobloxSSLVerify: false, InMemoryZones: []string{"example.org", "company.com"}, + PDNSServer: "http://ns.example.com:8081", + PDNSAPIKey: "some-secret-key", Policy: "upsert-only", Registry: "noop", TXTOwnerID: "owner-1", @@ -135,6 +139,8 @@ func TestParseFlags(t *testing.T) { "--infoblox-wapi-version=2.6.1", "--inmemory-zone=example.org", "--inmemory-zone=company.com", + "--pdns-server=http://ns.example.com:8081", + "--pdns-api-key=some-secret-key", "--no-infoblox-ssl-verify", "--domain-filter=example.org", "--domain-filter=company.com", @@ -178,6 +184,8 @@ func TestParseFlags(t *testing.T) { "EXTERNAL_DNS_INFOBLOX_SSL_VERIFY": "0", "EXTERNAL_DNS_INMEMORY_ZONE": "example.org\ncompany.com", "EXTERNAL_DNS_DOMAIN_FILTER": "example.org\ncompany.com", + "EXTERNAL_DNS_PDNS_SERVER": "http://ns.example.com:8081", + "EXTERNAL_DNS_PDNS_API_KEY": "some-secret-key", "EXTERNAL_DNS_ZONE_ID_FILTER": "/hostedzone/ZTST1\n/hostedzone/ZTST2", "EXTERNAL_DNS_AWS_ZONE_TYPE": "private", "EXTERNAL_DNS_POLICY": "upsert-only", diff --git a/provider/pdns.go b/provider/pdns.go new file mode 100644 index 0000000000..6d02294be0 --- /dev/null +++ b/provider/pdns.go @@ -0,0 +1,402 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package provider + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "math" + "net/http" + "sort" + "strings" + "time" + + log "github.com/sirupsen/logrus" + + pgo "github.com/ffledgling/pdns-go" + "github.com/kubernetes-incubator/external-dns/endpoint" + "github.com/kubernetes-incubator/external-dns/plan" +) + +type pdnsChangeType string + +const ( + apiBase = "/api/v1" + + // Unless we use something like pdnsproxy (discontinued upsteam), this value will _always_ be localhost + defaultServerID = "localhost" + defaultTTL = 300 + + // PdnsDelete and PdnsReplace are effectively an enum for "pgo.RrSet.changetype" + // TODO: Can we somehow get this from the pgo swagger client library itself? + + // PdnsDelete : PowerDNS changetype used for deleting rrsets + // ref: https://doc.powerdns.com/authoritative/http-api/zone.html#rrset (see "changetype") + PdnsDelete pdnsChangeType = "DELETE" + // PdnsReplace : PowerDNS changetype for creating, updating and patching rrsets + PdnsReplace pdnsChangeType = "REPLACE" + // Number of times to retry failed PDNS requests + retryLimit = 3 + // time in milliseconds + retryAfterTime = 250 * time.Millisecond +) + +// Function for debug printing +func stringifyHTTPResponseBody(r *http.Response) (body string) { + + if r == nil { + return "" + } + + buf := new(bytes.Buffer) + buf.ReadFrom(r.Body) + body = buf.String() + return body + +} + +// PDNSAPIProvider : Interface used and extended by the PDNSAPIClient struct as +// well as mock APIClients used in testing +type PDNSAPIProvider interface { + ListZones() ([]pgo.Zone, *http.Response, error) + ListZone(zoneID string) (pgo.Zone, *http.Response, error) + PatchZone(zoneID string, zoneStruct pgo.Zone) (*http.Response, error) +} + +// PDNSAPIClient : Struct that encapsulates all the PowerDNS specific implementation details +type PDNSAPIClient struct { + dryRun bool + authCtx context.Context + client *pgo.APIClient +} + +// ListZones : Method returns all enabled zones from PowerDNS +// ref: https://doc.powerdns.com/authoritative/http-api/zone.html#get--servers-server_id-zones +func (c *PDNSAPIClient) ListZones() (zones []pgo.Zone, resp *http.Response, err error) { + for i := 0; i < retryLimit; i++ { + zones, resp, err = c.client.ZonesApi.ListZones(c.authCtx, defaultServerID) + if err != nil { + log.Debugf("Unable to fetch zones %v", err) + log.Debugf("Retrying ListZones() ... %d", i) + time.Sleep(retryAfterTime * (1 << uint(i))) + continue + + } + return zones, resp, err + } + + log.Errorf("Unable to fetch zones. %v", err) + return zones, resp, err + +} + +// ListZone : Method returns the details of a specific zone from PowerDNS +// ref: https://doc.powerdns.com/authoritative/http-api/zone.html#get--servers-server_id-zones-zone_id +func (c *PDNSAPIClient) ListZone(zoneID string) (zone pgo.Zone, resp *http.Response, err error) { + for i := 0; i < retryLimit; i++ { + zone, resp, err = c.client.ZonesApi.ListZone(c.authCtx, defaultServerID, zoneID) + if err != nil { + log.Debugf("Unable to fetch zone %v", err) + log.Debugf("Retrying ListZone() ... %d", i) + time.Sleep(retryAfterTime * (1 << uint(i))) + continue + + } + return zone, resp, err + } + + log.Errorf("Unable to list zone. %v", err) + return zone, resp, err + +} + +// PatchZone : Method used to update the contents of a particular zone from PowerDNS +// ref: https://doc.powerdns.com/authoritative/http-api/zone.html#patch--servers-server_id-zones-zone_id +func (c *PDNSAPIClient) PatchZone(zoneID string, zoneStruct pgo.Zone) (resp *http.Response, err error) { + for i := 0; i < retryLimit; i++ { + resp, err = c.client.ZonesApi.PatchZone(c.authCtx, defaultServerID, zoneID, zoneStruct) + if err != nil { + log.Debugf("Unable to patch zone %v", err) + log.Debugf("Retrying PatchZone() ... %d", i) + time.Sleep(retryAfterTime * (1 << uint(i))) + continue + + } + return resp, err + } + + log.Errorf("Unable to patch zone. %v", err) + return resp, err +} + +// PDNSProvider is an implementation of the Provider interface for PowerDNS +type PDNSProvider struct { + client PDNSAPIProvider +} + +// NewPDNSProvider initializes a new PowerDNS based Provider. +func NewPDNSProvider(server string, apikey string, domainFilter DomainFilter, dryRun bool) (*PDNSProvider, error) { + + // Do some input validation + + if apikey == "" { + return nil, errors.New("Missing API Key for PDNS. Specify using --pdns-api-key=") + } + + // The default for when no --domain-filter is passed is [""], instead of [], so we check accordingly. + if len(domainFilter.filters) != 1 && domainFilter.filters[0] != "" { + return nil, errors.New("PDNS Provider does not support domain filter") + } + // We do not support dry running, exit safely instead of surprising the user + // TODO: Add Dry Run support + if dryRun { + return nil, errors.New("PDNS Provider does not currently support dry-run") + } + + if server == "localhost" { + log.Warnf("PDNS Server is set to localhost, this may not be what you want. Specify using --pdns-server=") + } + + cfg := pgo.NewConfiguration() + cfg.Host = server + cfg.BasePath = server + apiBase + + provider := &PDNSProvider{ + client: &PDNSAPIClient{ + dryRun: dryRun, + authCtx: context.WithValue(context.TODO(), pgo.ContextAPIKey, pgo.APIKey{Key: apikey}), + client: pgo.NewAPIClient(cfg), + }, + } + + return provider, nil +} + +func (p *PDNSProvider) convertRRSetToEndpoints(rr pgo.RrSet) (endpoints []*endpoint.Endpoint, _ error) { + endpoints = []*endpoint.Endpoint{} + + for _, record := range rr.Records { + // If a record is "Disabled", it's not supposed to be "visible" + if !record.Disabled { + endpoints = append(endpoints, endpoint.NewEndpointWithTTL(rr.Name, record.Content, rr.Type_, endpoint.TTL(rr.Ttl))) + } + } + + return endpoints, nil +} + +// ConvertEndpointsToZones marshals endpoints into pdns compatible Zone structs +func (p *PDNSProvider) ConvertEndpointsToZones(eps []*endpoint.Endpoint, changetype pdnsChangeType) (zonelist []pgo.Zone, _ error) { + + zonelist = []pgo.Zone{} + endpoints := make([]*endpoint.Endpoint, len(eps)) + copy(endpoints, eps) + + // Sort the endpoints array so we have deterministic inserts + sort.SliceStable(endpoints, + func(i, j int) bool { + // We only care about sorting endpoints with the same dnsname + if endpoints[i].DNSName == endpoints[j].DNSName { + return endpoints[i].RecordType < endpoints[j].RecordType + } + return endpoints[i].DNSName < endpoints[j].DNSName + }) + + zones, _, err := p.client.ListZones() + if err != nil { + return nil, err + } + + // Sort the zone by length of the name in descending order, we use this + // property later to ensure we add a record to the longest matching zone + + sort.SliceStable(zones, func(i, j int) bool { return len(zones[i].Name) > len(zones[j].Name) }) + + // NOTE: Complexity of this loop is O(Zones*Endpoints). + // A possibly faster implementation would be a search of the reversed + // DNSName in a trie of Zone names, which should be O(Endpoints), but at this point it's not + // necessary. + for _, zone := range zones { + zone.Rrsets = []pgo.RrSet{} + for i := 0; i < len(endpoints); { + ep := endpoints[0] + dnsname := ensureTrailingDot(ep.DNSName) + if strings.HasSuffix(dnsname, zone.Name) { + // The assumption here is that there will only ever be one target + // per (ep.DNSName, ep.RecordType) tuple, which holds true for + // external-dns v5.0.0-alpha onwards + records := []pgo.Record{} + for _, t := range ep.Targets { + records = append(records, pgo.Record{Content: t}) + } + rrset := pgo.RrSet{ + Name: dnsname, + Type_: ep.RecordType, + Records: records, + Changetype: string(changetype), + } + + // DELETEs explicitly forbid a TTL, therefore only PATCHes need the TTL + if changetype == PdnsReplace { + if int64(ep.RecordTTL) > int64(math.MaxInt32) { + return nil, errors.New("Value of record TTL overflows, limited to int32") + } + if ep.RecordTTL == 0 { + // No TTL was sepecified for the record, we use the default + rrset.Ttl = int32(defaultTTL) + } else { + rrset.Ttl = int32(ep.RecordTTL) + } + } + + zone.Rrsets = append(zone.Rrsets, rrset) + + // "pop" endpoint if it's matched + endpoints = append(endpoints[0:i], endpoints[i+1:]...) + } else { + // If we didn't pop anything, we move to the next item in the list + i++ + } + + } + + if len(zone.Rrsets) > 0 { + zonelist = append(zonelist, zone) + } + + } + + // If we still have some endpoints left, it means we couldn't find a matching zone for them + // We warn instead of hard fail here because we don't want a misconfig to cause everything to go down + if len(endpoints) > 0 { + log.Warnf("No matching zones were found for the following endpoints: %+v", endpoints) + } + + log.Debugf("Zone List generated from Endpoints: %+v", zonelist) + + return zonelist, nil +} + +// mutateRecords takes a list of endpoints and creates, replaces or deletes them based on the changetype +func (p *PDNSProvider) mutateRecords(endpoints []*endpoint.Endpoint, changetype pdnsChangeType) error { + zonelist, err := p.ConvertEndpointsToZones(endpoints, changetype) + if err != nil { + return err + } + for _, zone := range zonelist { + jso, err := json.Marshal(zone) + if err != nil { + log.Errorf("JSON Marshal for zone struct failed!") + } else { + log.Debugf("Struct for PatchZone:\n%s", string(jso)) + } + + resp, err := p.client.PatchZone(zone.Id, zone) + if err != nil { + log.Debugf("PDNS API response: %s", stringifyHTTPResponseBody(resp)) + return err + } + + } + return nil +} + +// Records returns all DNS records controlled by the configured PDNS server (for all zones) +func (p *PDNSProvider) Records() (endpoints []*endpoint.Endpoint, _ error) { + + zones, _, err := p.client.ListZones() + if err != nil { + return nil, err + } + + for _, zone := range zones { + z, _, err := p.client.ListZone(zone.Id) + if err != nil { + log.Warnf("Unable to fetch Records") + return nil, err + } + + for _, rr := range z.Rrsets { + e, err := p.convertRRSetToEndpoints(rr) + if err != nil { + return nil, err + } + endpoints = append(endpoints, e...) + } + } + + log.Debugf("Records fetched:\n%+v", endpoints) + return endpoints, nil +} + +// ApplyChanges takes a list of changes (endpoints) and updates the PDNS server +// by sending the correct HTTP PATCH requests to a matching zone +func (p *PDNSProvider) ApplyChanges(changes *plan.Changes) error { + + startTime := time.Now() + + // Create + for _, change := range changes.Create { + log.Debugf("CREATE: %+v", change) + } + // We only attempt to mutate records if there are any to mutate. A + // call to mutate records with an empty list of endpoints is still a + // valid call and a no-op, but we might as well not make the call to + // prevent unnecessary logging + if len(changes.Create) > 0 { + // "Replacing" non-existant records creates them + err := p.mutateRecords(changes.Create, PdnsReplace) + if err != nil { + return err + } + } + + // Update + for _, change := range changes.UpdateOld { + // Since PDNS "Patches", we don't need to specify the "old" + // record. The Update New change type will automatically take + // care of replacing the old RRSet with the new one We simply + // leave this logging here for information + log.Debugf("UPDATE-OLD (ignored): %+v", change) + } + + for _, change := range changes.UpdateNew { + log.Debugf("UPDATE-NEW: %+v", change) + } + if len(changes.UpdateNew) > 0 { + err := p.mutateRecords(changes.UpdateNew, PdnsReplace) + if err != nil { + return err + } + } + + // Delete + for _, change := range changes.Delete { + log.Debugf("DELETE: %+v", change) + } + if len(changes.Delete) > 0 { + err := p.mutateRecords(changes.Delete, PdnsDelete) + if err != nil { + return err + } + } + + log.Debugf("Changes pushed out to PowerDNS in %s\n", time.Since(startTime)) + return nil +} diff --git a/provider/pdns_test.go b/provider/pdns_test.go new file mode 100644 index 0000000000..2baf6ca02a --- /dev/null +++ b/provider/pdns_test.go @@ -0,0 +1,566 @@ +/* +Copyright 2018 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package provider + +import ( + "errors" + //"fmt" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + + pgo "github.com/ffledgling/pdns-go" + "github.com/kubernetes-incubator/external-dns/endpoint" +) + +// FIXME: What do we do about labels? + +var ( + // Simple RRSets that contain 1 A record and 1 TXT record + RRSetSimpleARecord = pgo.RrSet{ + Name: "example.com.", + Type_: "A", + Ttl: 300, + Records: []pgo.Record{ + {Content: "8.8.8.8", Disabled: false, SetPtr: false}, + }, + } + RRSetSimpleTXTRecord = pgo.RrSet{ + Name: "example.com.", + Type_: "TXT", + Ttl: 300, + Records: []pgo.Record{ + {Content: "\"heritage=external-dns,external-dns/owner=tower-pdns\"", Disabled: false, SetPtr: false}, + }, + } + RRSetLongARecord = pgo.RrSet{ + Name: "a.very.long.domainname.example.com.", + Type_: "A", + Ttl: 300, + Records: []pgo.Record{ + {Content: "8.8.8.8", Disabled: false, SetPtr: false}, + }, + } + RRSetLongTXTRecord = pgo.RrSet{ + Name: "a.very.long.domainname.example.com.", + Type_: "TXT", + Ttl: 300, + Records: []pgo.Record{ + {Content: "\"heritage=external-dns,external-dns/owner=tower-pdns\"", Disabled: false, SetPtr: false}, + }, + } + // RRSet with one record disabled + RRSetDisabledRecord = pgo.RrSet{ + Name: "example.com.", + Type_: "A", + Ttl: 300, + Records: []pgo.Record{ + {Content: "8.8.8.8", Disabled: false, SetPtr: false}, + {Content: "8.8.4.4", Disabled: true, SetPtr: false}, + }, + } + + RRSetCNAMERecord = pgo.RrSet{ + Name: "cname.example.com.", + Type_: "CNAME", + Ttl: 300, + Records: []pgo.Record{ + {Content: "example.by.any.other.name.com", Disabled: false, SetPtr: false}, + }, + } + RRSetTXTRecord = pgo.RrSet{ + Name: "example.com.", + Type_: "TXT", + Ttl: 300, + Records: []pgo.Record{ + {Content: "'would smell as sweet'", Disabled: false, SetPtr: false}, + }, + } + + // Multiple PDNS records in an RRSet of a single type + RRSetMultipleRecords = pgo.RrSet{ + Name: "example.com.", + Type_: "A", + Ttl: 300, + Records: []pgo.Record{ + {Content: "8.8.8.8", Disabled: false, SetPtr: false}, + {Content: "8.8.4.4", Disabled: false, SetPtr: false}, + {Content: "4.4.4.4", Disabled: false, SetPtr: false}, + }, + } + + endpointsDisabledRecord = []*endpoint.Endpoint{ + endpoint.NewEndpointWithTTL("example.com", "8.8.8.8", endpoint.RecordTypeA, endpoint.TTL(300)), + } + + endpointsSimpleRecord = []*endpoint.Endpoint{ + endpoint.NewEndpointWithTTL("example.com", "8.8.8.8", endpoint.RecordTypeA, endpoint.TTL(300)), + endpoint.NewEndpointWithTTL("example.com", "\"heritage=external-dns,external-dns/owner=tower-pdns\"", endpoint.RecordTypeTXT, endpoint.TTL(300)), + } + + endpointsLongRecord = []*endpoint.Endpoint{ + endpoint.NewEndpointWithTTL("a.very.long.domainname.example.com", "8.8.8.8", endpoint.RecordTypeA, endpoint.TTL(300)), + endpoint.NewEndpointWithTTL("a.very.long.domainname.example.com", "\"heritage=external-dns,external-dns/owner=tower-pdns\"", endpoint.RecordTypeTXT, endpoint.TTL(300)), + } + + endpointsNonexistantZone = []*endpoint.Endpoint{ + endpoint.NewEndpointWithTTL("does.not.exist.com", "8.8.8.8", endpoint.RecordTypeA, endpoint.TTL(300)), + endpoint.NewEndpointWithTTL("does.not.exist.com", "\"heritage=external-dns,external-dns/owner=tower-pdns\"", endpoint.RecordTypeTXT, endpoint.TTL(300)), + } + endpointsMultipleRecords = []*endpoint.Endpoint{ + endpoint.NewEndpointWithTTL("example.com", "8.8.8.8", endpoint.RecordTypeA, endpoint.TTL(300)), + endpoint.NewEndpointWithTTL("example.com", "8.8.4.4", endpoint.RecordTypeA, endpoint.TTL(300)), + endpoint.NewEndpointWithTTL("example.com", "4.4.4.4", endpoint.RecordTypeA, endpoint.TTL(300)), + } + + endpointsMixedRecords = []*endpoint.Endpoint{ + endpoint.NewEndpointWithTTL("cname.example.com", "example.by.any.other.name.com", endpoint.RecordTypeCNAME, endpoint.TTL(300)), + endpoint.NewEndpointWithTTL("example.com", "'would smell as sweet'", endpoint.RecordTypeTXT, endpoint.TTL(300)), + endpoint.NewEndpointWithTTL("example.com", "8.8.8.8", endpoint.RecordTypeA, endpoint.TTL(300)), + endpoint.NewEndpointWithTTL("example.com", "8.8.4.4", endpoint.RecordTypeA, endpoint.TTL(300)), + endpoint.NewEndpointWithTTL("example.com", "4.4.4.4", endpoint.RecordTypeA, endpoint.TTL(300)), + } + + endpointsMultipleZones = []*endpoint.Endpoint{ + endpoint.NewEndpointWithTTL("example.com", "8.8.8.8", endpoint.RecordTypeA, endpoint.TTL(300)), + endpoint.NewEndpointWithTTL("example.com", "\"heritage=external-dns,external-dns/owner=tower-pdns\"", endpoint.RecordTypeTXT, endpoint.TTL(300)), + endpoint.NewEndpointWithTTL("mock.test", "9.9.9.9", endpoint.RecordTypeA, endpoint.TTL(300)), + endpoint.NewEndpointWithTTL("mock.test", "\"heritage=external-dns,external-dns/owner=tower-pdns\"", endpoint.RecordTypeTXT, endpoint.TTL(300)), + } + + ZoneEmpty = pgo.Zone{ + // Opaque zone id (string), assigned by the server, should not be interpreted by the application. Guaranteed to be safe for embedding in URLs. + Id: "example.com.", + // Name of the zone (e.g. “example.com.”) MUST have a trailing dot + Name: "example.com.", + // Set to “Zone” + Type_: "Zone", + // API endpoint for this zone + Url: "/api/v1/servers/localhost/zones/example.com.", + // Zone kind, one of “Native”, “Master”, “Slave” + Kind: "Native", + // RRSets in this zone + Rrsets: []pgo.RrSet{}, + } + + ZoneEmptyLong = pgo.Zone{ + Id: "long.domainname.example.com.", + Name: "long.domainname.example.com.", + Type_: "Zone", + Url: "/api/v1/servers/localhost/zones/long.domainname.example.com.", + Kind: "Native", + Rrsets: []pgo.RrSet{}, + } + + ZoneEmpty2 = pgo.Zone{ + Id: "mock.test.", + Name: "mock.test.", + Type_: "Zone", + Url: "/api/v1/servers/localhost/zones/mock.test.", + Kind: "Native", + Rrsets: []pgo.RrSet{}, + } + + ZoneMixed = pgo.Zone{ + Id: "example.com.", + Name: "example.com.", + Type_: "Zone", + Url: "/api/v1/servers/localhost/zones/example.com.", + Kind: "Native", + Rrsets: []pgo.RrSet{RRSetCNAMERecord, RRSetTXTRecord, RRSetMultipleRecords}, + } + + ZoneEmptyToSimplePatch = pgo.Zone{ + Id: "example.com.", + Name: "example.com.", + Type_: "Zone", + Url: "/api/v1/servers/localhost/zones/example.com.", + Kind: "Native", + Rrsets: []pgo.RrSet{ + { + Name: "example.com.", + Type_: "A", + Ttl: 300, + Changetype: "REPLACE", + Records: []pgo.Record{ + { + Content: "8.8.8.8", + Disabled: false, + SetPtr: false, + }, + }, + Comments: []pgo.Comment(nil), + }, + { + Name: "example.com.", + Type_: "TXT", + Ttl: 300, + Changetype: "REPLACE", + Records: []pgo.Record{ + { + Content: "\"heritage=external-dns,external-dns/owner=tower-pdns\"", + Disabled: false, + SetPtr: false, + }, + }, + Comments: []pgo.Comment(nil), + }, + }, + } + + ZoneEmptyToLongPatch = pgo.Zone{ + Id: "long.domainname.example.com.", + Name: "long.domainname.example.com.", + Type_: "Zone", + Url: "/api/v1/servers/localhost/zones/long.domainname.example.com.", + Kind: "Native", + Rrsets: []pgo.RrSet{ + { + Name: "a.very.long.domainname.example.com.", + Type_: "A", + Ttl: 300, + Changetype: "REPLACE", + Records: []pgo.Record{ + { + Content: "8.8.8.8", + Disabled: false, + SetPtr: false, + }, + }, + Comments: []pgo.Comment(nil), + }, + { + Name: "a.very.long.domainname.example.com.", + Type_: "TXT", + Ttl: 300, + Changetype: "REPLACE", + Records: []pgo.Record{ + { + Content: "\"heritage=external-dns,external-dns/owner=tower-pdns\"", + Disabled: false, + SetPtr: false, + }, + }, + Comments: []pgo.Comment(nil), + }, + }, + } + + ZoneEmptyToSimplePatch2 = pgo.Zone{ + Id: "mock.test.", + Name: "mock.test.", + Type_: "Zone", + Url: "/api/v1/servers/localhost/zones/mock.test.", + Kind: "Native", + Rrsets: []pgo.RrSet{ + { + Name: "mock.test.", + Type_: "A", + Ttl: 300, + Changetype: "REPLACE", + Records: []pgo.Record{ + { + Content: "9.9.9.9", + Disabled: false, + SetPtr: false, + }, + }, + Comments: []pgo.Comment(nil), + }, + { + Name: "mock.test.", + Type_: "TXT", + Ttl: 300, + Changetype: "REPLACE", + Records: []pgo.Record{ + { + Content: "\"heritage=external-dns,external-dns/owner=tower-pdns\"", + Disabled: false, + SetPtr: false, + }, + }, + Comments: []pgo.Comment(nil), + }, + }, + } + + ZoneEmptyToSimpleDelete = pgo.Zone{ + Id: "example.com.", + Name: "example.com.", + Type_: "Zone", + Url: "/api/v1/servers/localhost/zones/example.com.", + Kind: "Native", + Rrsets: []pgo.RrSet{ + { + Name: "example.com.", + Type_: "A", + Changetype: "DELETE", + Records: []pgo.Record{ + { + Content: "8.8.8.8", + Disabled: false, + SetPtr: false, + }, + }, + Comments: []pgo.Comment(nil), + }, + { + Name: "example.com.", + Type_: "TXT", + Changetype: "DELETE", + Records: []pgo.Record{ + { + Content: "\"heritage=external-dns,external-dns/owner=tower-pdns\"", + Disabled: false, + SetPtr: false, + }, + }, + Comments: []pgo.Comment(nil), + }, + }, + } +) + +/******************************************************************************/ +// API that returns a zone with multiple record types +type PDNSAPIClientStub struct { +} + +func (c *PDNSAPIClientStub) ListZones() ([]pgo.Zone, *http.Response, error) { + return []pgo.Zone{ZoneMixed}, nil, nil +} +func (c *PDNSAPIClientStub) ListZone(zoneID string) (pgo.Zone, *http.Response, error) { + return ZoneMixed, nil, nil +} +func (c *PDNSAPIClientStub) PatchZone(zoneID string, zoneStruct pgo.Zone) (*http.Response, error) { + return nil, nil +} + +/******************************************************************************/ +// API that returns a zones with no records +type PDNSAPIClientStubEmptyZones struct { + // Keep track of all zones we recieve via PatchZone + patchedZones []pgo.Zone +} + +func (c *PDNSAPIClientStubEmptyZones) ListZones() ([]pgo.Zone, *http.Response, error) { + return []pgo.Zone{ZoneEmpty, ZoneEmptyLong, ZoneEmpty2}, nil, nil +} +func (c *PDNSAPIClientStubEmptyZones) ListZone(zoneID string) (pgo.Zone, *http.Response, error) { + + if strings.Contains(zoneID, "example.com") { + return ZoneEmpty, nil, nil + } else if strings.Contains(zoneID, "mock.test") { + return ZoneEmpty2, nil, nil + } else if strings.Contains(zoneID, "long.domainname.example.com") { + return ZoneEmpty2, nil, nil + } + return pgo.Zone{}, nil, nil + +} +func (c *PDNSAPIClientStubEmptyZones) PatchZone(zoneID string, zoneStruct pgo.Zone) (*http.Response, error) { + c.patchedZones = append(c.patchedZones, zoneStruct) + return nil, nil +} + +/******************************************************************************/ +// API that returns error on PatchZone() +type PDNSAPIClientStubPatchZoneFailure struct { + // Anonymous struct for composition + PDNSAPIClientStubEmptyZones +} + +// Just overwrite the PatchZone method to introduce a failure +func (c *PDNSAPIClientStubPatchZoneFailure) PatchZone(zoneID string, zoneStruct pgo.Zone) (*http.Response, error) { + return nil, errors.New("Generic PDNS Error") +} + +/******************************************************************************/ +// API that returns error on ListZone() +type PDNSAPIClientStubListZoneFailure struct { + // Anonymous struct for composition + PDNSAPIClientStubEmptyZones +} + +// Just overwrite the ListZone method to introduce a failure +func (c *PDNSAPIClientStubListZoneFailure) ListZone(zoneID string) (pgo.Zone, *http.Response, error) { + return pgo.Zone{}, nil, errors.New("Generic PDNS Error") + +} + +/******************************************************************************/ +// API that returns error on ListZones() (Zones - plural) +type PDNSAPIClientStubListZonesFailure struct { + // Anonymous struct for composition + PDNSAPIClientStubEmptyZones +} + +// Just overwrite the ListZones method to introduce a failure +func (c *PDNSAPIClientStubListZonesFailure) ListZones() ([]pgo.Zone, *http.Response, error) { + return []pgo.Zone{}, nil, errors.New("Generic PDNS Error") +} + +/******************************************************************************/ + +type NewPDNSProviderTestSuite struct { + suite.Suite +} + +func (suite *NewPDNSProviderTestSuite) TestPDNSProviderCreate() { + // Function definition: NewPDNSProvider(server string, apikey string, domainFilter DomainFilter, dryRun bool) (*PDNSProvider, error) + + _, err := NewPDNSProvider("http://localhost:8081", "", NewDomainFilter([]string{""}), false) + assert.Error(suite.T(), err, "--pdns-api-key should be specified") + + _, err = NewPDNSProvider("http://localhost:8081", "foo", NewDomainFilter([]string{"example.com", "example.org"}), false) + assert.Error(suite.T(), err, "--domainfilter should raise an error") + + _, err = NewPDNSProvider("http://localhost:8081", "foo", NewDomainFilter([]string{""}), true) + assert.Error(suite.T(), err, "--dry-run should raise an error") + + // This is our "regular" code path, no error should be thrown + _, err = NewPDNSProvider("http://localhost:8081", "foo", NewDomainFilter([]string{""}), false) + assert.Nil(suite.T(), err, "Regular case should raise no error") +} + +func (suite *NewPDNSProviderTestSuite) TestPDNSRRSetToEndpoints() { + // Function definition: convertRRSetToEndpoints(rr pgo.RrSet) (endpoints []*endpoint.Endpoint, _ error) + + // Create a new provider to run tests against + p := &PDNSProvider{ + client: &PDNSAPIClientStub{}, + } + + /* given an RRSet with three records, we test: + - We correctly create corresponding endpoints + */ + eps, err := p.convertRRSetToEndpoints(RRSetMultipleRecords) + assert.Nil(suite.T(), err) + assert.Equal(suite.T(), endpointsMultipleRecords, eps) + + /* Given an RRSet with two records, one of which is disabled, we test: + - We can correctly convert the RRSet into a list of valid endpoints + - We correctly discard/ignore the disabled record. + */ + eps, err = p.convertRRSetToEndpoints(RRSetDisabledRecord) + assert.Nil(suite.T(), err) + assert.Equal(suite.T(), endpointsDisabledRecord, eps) + +} + +func (suite *NewPDNSProviderTestSuite) TestPDNSRecords() { + // Function definition: Records() (endpoints []*endpoint.Endpoint, _ error) + + // Create a new provider to run tests against + p := &PDNSProvider{ + client: &PDNSAPIClientStub{}, + } + + /* We test that endpoints are returned correctly for a Zone when Records() is called + */ + eps, err := p.Records() + assert.Nil(suite.T(), err) + assert.Equal(suite.T(), endpointsMixedRecords, eps) + + // Test failures are handled correctly + // Create a new provider to run tests against + p = &PDNSProvider{ + client: &PDNSAPIClientStubListZoneFailure{}, + } + eps, err = p.Records() + assert.NotNil(suite.T(), err) + + p = &PDNSProvider{ + client: &PDNSAPIClientStubListZonesFailure{}, + } + eps, err = p.Records() + assert.NotNil(suite.T(), err) + +} + +func (suite *NewPDNSProviderTestSuite) TestPDNSConvertEndpointsToZones() { + // Function definition: ConvertEndpointsToZones(endpoints []*endpoint.Endpoint, changetype pdnsChangeType) (zonelist []pgo.Zone, _ error) + + // Create a new provider to run tests against + p := &PDNSProvider{ + client: &PDNSAPIClientStubEmptyZones{}, + } + + // Check inserting endpoints from a single zone + zlist, err := p.ConvertEndpointsToZones(endpointsSimpleRecord, PdnsReplace) + assert.Nil(suite.T(), err) + assert.Equal(suite.T(), []pgo.Zone{ZoneEmptyToSimplePatch}, zlist) + + // Check deleting endpoints from a single zone + zlist, err = p.ConvertEndpointsToZones(endpointsSimpleRecord, PdnsDelete) + assert.Nil(suite.T(), err) + assert.Equal(suite.T(), []pgo.Zone{ZoneEmptyToSimpleDelete}, zlist) + + // Check endpoints from multiple zones + zlist, err = p.ConvertEndpointsToZones(endpointsMultipleZones, PdnsReplace) + assert.Nil(suite.T(), err) + assert.Equal(suite.T(), []pgo.Zone{ZoneEmptyToSimplePatch, ZoneEmptyToSimplePatch2}, zlist) + + // Check endpoints from a zone that does not exist + zlist, err = p.ConvertEndpointsToZones(endpointsNonexistantZone, PdnsReplace) + assert.Nil(suite.T(), err) + assert.Equal(suite.T(), []pgo.Zone{}, zlist) + + // Check endpoints that match multiple zones (one longer than other), is assigned to the right zone + zlist, err = p.ConvertEndpointsToZones(endpointsLongRecord, PdnsReplace) + assert.Nil(suite.T(), err) + assert.Equal(suite.T(), []pgo.Zone{ZoneEmptyToLongPatch}, zlist) + +} + +func (suite *NewPDNSProviderTestSuite) TestPDNSmutateRecords() { + // Function definition: mutateRecords(endpoints []*endpoint.Endpoint, changetype pdnsChangeType) error + + // Create a new provider to run tests against + c := &PDNSAPIClientStubEmptyZones{} + p := &PDNSProvider{ + client: c, + } + + // Check inserting endpoints from a single zone + err := p.mutateRecords(endpointsSimpleRecord, pdnsChangeType("REPLACE")) + assert.Nil(suite.T(), err) + assert.Equal(suite.T(), []pgo.Zone{ZoneEmptyToSimplePatch}, c.patchedZones) + + // Reset the "patchedZones" + c.patchedZones = []pgo.Zone{} + + // Check deleting endpoints from a single zone + err = p.mutateRecords(endpointsSimpleRecord, pdnsChangeType("DELETE")) + assert.Nil(suite.T(), err) + assert.Equal(suite.T(), []pgo.Zone{ZoneEmptyToSimpleDelete}, c.patchedZones) + + // Check we fail correctly when patching fails for whatever reason + p = &PDNSProvider{ + client: &PDNSAPIClientStubPatchZoneFailure{}, + } + // Check inserting endpoints from a single zone + err = p.mutateRecords(endpointsSimpleRecord, pdnsChangeType("REPLACE")) + assert.NotNil(suite.T(), err) + +} +func TestNewPDNSProviderTestSuite(t *testing.T) { + suite.Run(t, new(NewPDNSProviderTestSuite)) +}