Skip to content

Commit

Permalink
Add PDNS Provider
Browse files Browse the repository at this point in the history
- Adds PowerDNS Authoritative Server as a provider
- Adds `--pdns-server` and `--pdns-api-key` flags
- Implements the PDNS provider
- Modifies `types_test.go` to fix tests for aforementioned flags
- `gofmt`ed and `golint`ed
  • Loading branch information
ffledgling committed Nov 7, 2017
1 parent 8829fb7 commit ec0ecfb
Show file tree
Hide file tree
Showing 4 changed files with 312 additions and 1 deletion.
2 changes: 2 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ func main() {
)
case "inmemory":
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)
default:
log.Fatalf("unknown dns provider: %s", cfg.Provider)
}
Expand Down
8 changes: 7 additions & 1 deletion pkg/apis/externaldns/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ type Config struct {
InfobloxWapiVersion string
InfobloxSSLVerify bool
InMemoryZones []string
PDNSServer string
PDNSAPIKey string
Policy string
Registry string
TXTOwnerID string
Expand Down Expand Up @@ -85,6 +87,8 @@ var defaultConfig = &Config{
InfobloxWapiVersion: "2.3.1",
InfobloxSSLVerify: true,
InMemoryZones: []string{},
PDNSServer: "http://localhost:8081",
PDNSAPIKey: "",
Policy: "sync",
Registry: "txt",
TXTOwnerID: "default",
Expand Down Expand Up @@ -129,7 +133,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, inmemory)").Required().PlaceHolder("provider").EnumVar(&cfg.Provider, "aws", "google", "azure", "cloudflare", "digitalocean", "dnsimple", "infoblox", "inmemory")
app.Flag("provider", "The DNS provider where the DNS records will be created (required, options: aws, google, azure, cloudflare, digitalocean, dnsimple, infoblox, inmemory)").Required().PlaceHolder("provider").EnumVar(&cfg.Provider, "aws", "google", "azure", "cloudflare", "digitalocean", "dnsimple", "infoblox", "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("google-project", "When using the Google provider, specify the Google project (required when --provider=google)").Default(defaultConfig.GoogleProject).StringVar(&cfg.GoogleProject)
app.Flag("aws-zone-type", "When using the AWS provider, filter for zones of this type (optional, options: public, private)").Default(defaultConfig.AWSZoneType).EnumVar(&cfg.AWSZoneType, "", "public", "private")
Expand All @@ -143,6 +147,8 @@ func (cfg *Config) ParseFlags(args []string) error {
app.Flag("infoblox-wapi-version", "When using the Infoblox provider, specify the WAPI version (default: 2.3.1)").Default(defaultConfig.InfobloxWapiVersion).StringVar(&cfg.InfobloxWapiVersion)
app.Flag("infoblox-ssl-verify", "When using the Infoblox provider, specify whether to verify the SSL certificate (default: true)").Default(strconv.FormatBool(defaultConfig.InfobloxSSLVerify)).BoolVar(&cfg.InfobloxSSLVerify)
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")
Expand Down
8 changes: 8 additions & 0 deletions pkg/apis/externaldns/types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ var (
InfobloxWapiVersion: "2.3.1",
InfobloxSSLVerify: true,
InMemoryZones: []string{""},
PDNSServer: "http://localhost:8081",
PDNSAPIKey: "",
Policy: "sync",
Registry: "txt",
TXTOwnerID: "default",
Expand Down Expand Up @@ -81,6 +83,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",
Expand Down Expand Up @@ -132,6 +136,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",
Expand Down Expand Up @@ -172,6 +178,8 @@ func TestParseFlags(t *testing.T) {
"EXTERNAL_DNS_INFOBLOX_WAPI_VERSION": "2.6.1",
"EXTERNAL_DNS_INFOBLOX_SSL_VERIFY": "0",
"EXTERNAL_DNS_INMEMORY_ZONE": "example.org\ncompany.com",
"EXTERNAL_DNS_PDNS_SERVER": "http://ns.example.com:8081",
"EXTERNAL_DNS_PDNS_API_KEY": "some-secret-key",
"EXTERNAL_DNS_DOMAIN_FILTER": "example.org\ncompany.com",
"EXTERNAL_DNS_AWS_ZONE_TYPE": "private",
"EXTERNAL_DNS_POLICY": "upsert-only",
Expand Down
295 changes: 295 additions & 0 deletions provider/pdns.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,295 @@
package provider

import (
//"strings"
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"strings"

log "github.com/sirupsen/logrus"

"github.com/kubernetes-incubator/external-dns/endpoint"
"github.com/kubernetes-incubator/external-dns/plan"
pgo "github.com/kubernetes-incubator/external-dns/provider/internal/pdns-go"
)

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

maxUInt32 = ^uint32(0)
maxInt32 = maxUInt32 >> 1

// This is effectively an enum for "pgo.RrSet.changetype"
// TODO: Can we somehow get this from the pgo swagger client library itself?
pdnsDelete pdnsChangeType = "DELETE"
pdnsReplace pdnsChangeType = "REPLACE"
)

// PDNSProvider is an implementation of the Provider interface for PowerDNS
type PDNSProvider struct {
dryRun bool
// only consider hosted zones managing domains ending in this suffix
domainFilter DomainFilter
// filter hosted zones by type (e.g. private or public)
zoneTypeFilter ZoneTypeFilter

// Swagger API Client
client *pgo.APIClient

// Auth context to be passed to client requests, contains API keys etc.
authCtx context.Context
}

// Function for debug printing
func stringifyHTTPResponseBody(r *http.Response) (body string) {

buf := new(bytes.Buffer)
buf.ReadFrom(r.Body)
body = buf.String()
return body

}

// NewPDNSProvider initializes a new PowerDNS based Provider.
func NewPDNSProvider(server string, apikey string, domainFilter DomainFilter, dryRun bool) (*PDNSProvider, error) {

// Do some input validation

// We do not support dry running, exit safely instead of surprising the user
// TODO: Add Dry Run support
if dryRun {
log.Fatalf("PDNS Provider does not currently support dry-run, stopping.")
}

if server == "localhost" {
log.Warnf("PDNS Server is set to localhost, this is likely not what you want. Specify using --pdns-server=")
}

if apikey == "" {
log.Warnf("API Key for PDNS is empty. Specify using --pdns-api-key=")
}
if len(domainFilter.filters) == 0 {
log.Warnf("Domain Filter is not supported by PDNS. It will be ignored.")
}

provider := &PDNSProvider{}

cfg := pgo.NewConfiguration()
cfg.Host = server
cfg.BasePath = server + apiBase

// Initialize a single client that we can use for all requests
provider.client = pgo.NewAPIClient(cfg)

// Configure PDNS API Key, which is sent via X-API-Key header to pdns server
provider.authCtx = context.WithValue(context.TODO(), pgo.ContextAPIKey, pgo.APIKey{Key: apikey})

return provider, nil
}

func convertRRSetToEndpoints(rr pgo.RrSet) (endpoints []*endpoint.Endpoint, _ error) {
endpoints = []*endpoint.Endpoint{}

for _, record := range rr.Records {
//func NewEndpointWithTTL(dnsName, target, recordType string, ttl TTL) *Endpoint
endpoints = append(endpoints, endpoint.NewEndpointWithTTL(rr.Name, record.Content, rr.Type_, endpoint.TTL(rr.Ttl)))
}

return endpoints, nil
}

// Zones returns the list of all zones controlled by the pdns server as a list of pdns Zone structs
func (p *PDNSProvider) Zones() (zones []pgo.Zone, _ error) {
zones, _, err := p.client.ZonesApi.ListZones(p.authCtx, defaultServerID)
if err != nil {
log.Warnf("Unable to fetch zones. %v", err)
return nil, err
}

return zones, nil

}

// convertEndpointsToZones marshals endpoints into pdns compatible Zone structs
func (p *PDNSProvider) convertEndpointsToZones(endpoints []*endpoint.Endpoint, changetype pdnsChangeType) (zonelist []pgo.Zone, _ error) {
/* eg of mastermap
{ "example.com":
{ "app.example.com":
{ "A": ["192.168.0.1", "8.8.8.8"] }
{ "TXT": ["\"heritage=external-dns,external-dns/owner=example\""] }
}
}
*/
mastermap := make(map[string]map[string]map[string][]*endpoint.Endpoint)
zoneNameStructMap := map[string]pgo.Zone{}

zones, err := p.Zones()

if err != nil {
return nil, err
}
// Identify zones we control
for _, z := range zones {
mastermap[z.Name] = make(map[string]map[string][]*endpoint.Endpoint)
zoneNameStructMap[z.Name] = z
}

for _, ep := range endpoints {
// Identify which zone an endpoint belongs to
dnsname := ensureTrailingDot(ep.DNSName)
zname := ""
for z := range mastermap {
if strings.HasSuffix(dnsname, z) && len(dnsname) > len(zname) {
zname = z
}
}

// We can encounter a DNS name multiple times (different record types), we only create a map the first time
if _, ok := mastermap[zname][dnsname]; !ok {
mastermap[zname][dnsname] = make(map[string][]*endpoint.Endpoint)
}

// We can get multiple targets for the same record type (eg. Multiple A records for a service)
if _, ok := mastermap[zname][dnsname][ep.RecordType]; !ok {
mastermap[zname][dnsname][ep.RecordType] = make([]*endpoint.Endpoint, 0)
}

mastermap[zname][dnsname][ep.RecordType] = append(mastermap[zname][dnsname][ep.RecordType], ep)

}

for zname := range mastermap {

zone := zoneNameStructMap[zname]
zone.Rrsets = []pgo.RrSet{}
for rrname := range mastermap[zname] {
for rtype := range mastermap[zname][rrname] {
rrset := pgo.RrSet{}
rrset.Name = rrname
rrset.Type_ = rtype
rrset.Changetype = string(changetype)
rttl := mastermap[zname][rrname][rtype][0].RecordTTL
if int64(rttl) > int64(maxInt32) {
return nil, errors.New("Value of record TTL overflows, limited to int32")
}
rrset.Ttl = int32(rttl)
records := []pgo.Record{}
for _, e := range mastermap[zname][rrname][rtype] {
records = append(records, pgo.Record{Content: e.Target})

}
rrset.Records = records
zone.Rrsets = append(zone.Rrsets, rrset)
}

}

// Skip the empty zones (likely ones we don't control)
if len(zone.Rrsets) > 0 {
zonelist = append(zonelist, zone)
}

}

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, _ := json.Marshal(zone)
log.Debugf("Struct for PatchZone:\n%s", string(jso))

resp, err := p.client.ZonesApi.PatchZone(p.authCtx, defaultServerID, 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.Zones()
if err != nil {
return nil, err
}

for _, zone := range zones {
z, _, err := p.client.ZonesApi.ListZone(p.authCtx, defaultServerID, zone.Id)
if err != nil {
log.Warnf("Unable to fetch data for %v. %v", zone.Id, err)
return nil, err
}

for _, rr := range z.Rrsets {
e, err := 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 {

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
p.mutateRecords(changes.Create, pdnsReplace)
}

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 {
p.mutateRecords(changes.UpdateNew, pdnsReplace)
}

for _, change := range changes.Delete {
log.Debugf("DELETE: %+v", change)
}
if len(changes.Delete) > 0 {
p.mutateRecords(changes.Delete, pdnsDelete)
}
return nil
}

0 comments on commit ec0ecfb

Please sign in to comment.