Skip to content

Commit

Permalink
support multiple hosted zones and automatic lookup (#152)
Browse files Browse the repository at this point in the history
* feat(aws): support multiple hosted zones and automatic lookup

* chore: run gofmt with the simplified command

* fix(aws): add missing method from google provider

* fix: remove superflous parameter from google provider

* feat: make domain configurable via flag

* fix(aws): remove unused constant

* fix(aws): don't log actions that were filtered out

* feat(aws): detect best possible zone to put dns entries in

* fix(aws): log error instead of failing if a change batch fails

* chore: update changelog with support for multiple zones
  • Loading branch information
linki committed Apr 13, 2017
1 parent d751358 commit 03d7620
Show file tree
Hide file tree
Showing 8 changed files with 489 additions and 312 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
Features:

- Route 53: Support creation of records in multiple hosted zones.
- Route 53: Support creation of ALIAS records when endpoint target is a ELB/ALB.
- Ownership via TXT records
1. Create TXT records to mark the records managed by External DNS
Expand Down
2 changes: 1 addition & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ func main() {
case "google":
p, err = provider.NewGoogleProvider(cfg.GoogleProject, cfg.DryRun)
case "aws":
p, err = provider.NewAWSProvider(cfg.DryRun)
p, err = provider.NewAWSProvider(cfg.Domain, cfg.DryRun)
default:
log.Fatalf("unknown dns provider: %s", cfg.Provider)
}
Expand Down
2 changes: 2 additions & 0 deletions pkg/apis/externaldns/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ type Config struct {
KubeConfig string
Namespace string
Zone string
Domain string
Sources []string
Provider string
GoogleProject string
Expand Down Expand Up @@ -64,6 +65,7 @@ func (cfg *Config) ParseFlags(args []string) error {
flags.StringVar(&cfg.KubeConfig, "kubeconfig", "", "path to a local kubeconfig file")
flags.StringVar(&cfg.Namespace, "namespace", v1.NamespaceAll, "the namespace to look for endpoints; all namespaces by default")
flags.StringVar(&cfg.Zone, "zone", "", "the ID of the hosted zone to target")
flags.StringVar(&cfg.Domain, "domain", "example.org.", "the name of the top-level domain to manage")
flags.StringArrayVar(&cfg.Sources, "source", nil, "the sources to gather endpoints: [service, ingress], e.g. --source service --source ingress")
flags.StringVar(&cfg.Provider, "provider", "", "the DNS provider to materialize the records in: <aws|google>")
flags.StringVar(&cfg.GoogleProject, "google-project", "", "gcloud project to target")
Expand Down
6 changes: 6 additions & 0 deletions pkg/apis/externaldns/types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ func TestParseFlags(t *testing.T) {
KubeConfig: "",
Namespace: "",
Zone: "",
Domain: "example.org.",
Sources: nil,
Provider: "",
GoogleProject: "",
Expand All @@ -62,6 +63,7 @@ func TestParseFlags(t *testing.T) {
KubeConfig: "",
Namespace: "",
Zone: "",
Domain: "example.org.",
Sources: nil,
Provider: "",
GoogleProject: "",
Expand All @@ -87,6 +89,7 @@ func TestParseFlags(t *testing.T) {
KubeConfig: "myhome",
Namespace: "",
Zone: "",
Domain: "example.org.",
Sources: nil,
Provider: "",
GoogleProject: "",
Expand Down Expand Up @@ -117,6 +120,7 @@ func TestParseFlags(t *testing.T) {
KubeConfig: "",
Namespace: "",
Zone: "",
Domain: "example.org.",
Sources: nil,
Provider: "",
GoogleProject: "",
Expand All @@ -141,6 +145,7 @@ func TestParseFlags(t *testing.T) {
"--kubeconfig", "/some/path",
"--namespace", "namespace",
"--zone", "zone",
"--domain", "kubernetes.io.",
"--source", "source",
"--provider", "provider",
"--google-project", "project",
Expand All @@ -160,6 +165,7 @@ func TestParseFlags(t *testing.T) {
KubeConfig: "/some/path",
Namespace: "namespace",
Zone: "zone",
Domain: "kubernetes.io.",
Sources: []string{"source"},
Provider: "provider",
GoogleProject: "project",
Expand Down
151 changes: 119 additions & 32 deletions provider/aws.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ limitations under the License.
package provider

import (
"net"
"strings"

log "github.com/Sirupsen/logrus"
Expand All @@ -30,7 +31,6 @@ import (
)

const (
hostedZonePrefix = "/hostedzone/"
elbHostnameSuffix = ".elb.amazonaws.com"
evaluateTargetHealth = true
recordTTL = 300
Expand Down Expand Up @@ -62,16 +62,19 @@ type Route53API interface {
ListResourceRecordSetsPages(input *route53.ListResourceRecordSetsInput, fn func(resp *route53.ListResourceRecordSetsOutput, lastPage bool) (shouldContinue bool)) error
ChangeResourceRecordSets(*route53.ChangeResourceRecordSetsInput) (*route53.ChangeResourceRecordSetsOutput, error)
CreateHostedZone(*route53.CreateHostedZoneInput) (*route53.CreateHostedZoneOutput, error)
ListHostedZonesPages(input *route53.ListHostedZonesInput, fn func(resp *route53.ListHostedZonesOutput, lastPage bool) (shouldContinue bool)) error
}

// AWSProvider is an implementation of Provider for AWS Route53.
type AWSProvider struct {
Client Route53API
DryRun bool
// only consider hosted zones managing domains ending in this suffix
Domain string
}

// NewAWSProvider initializes a new AWS Route53 based Provider.
func NewAWSProvider(dryRun bool) (Provider, error) {
func NewAWSProvider(domain string, dryRun bool) (Provider, error) {
config := aws.NewConfig()

session, err := session.NewSessionWithOptions(session.Options{
Expand All @@ -84,15 +87,41 @@ func NewAWSProvider(dryRun bool) (Provider, error) {

provider := &AWSProvider{
Client: route53.New(session),
Domain: domain,
DryRun: dryRun,
}

return provider, nil
}

// Zones returns the list of hosted zones.
func (p *AWSProvider) Zones() (map[string]*route53.HostedZone, error) {
zones := make(map[string]*route53.HostedZone)

f := func(resp *route53.ListHostedZonesOutput, lastPage bool) (shouldContinue bool) {
for _, zone := range resp.HostedZones {
if strings.HasSuffix(aws.StringValue(zone.Name), p.Domain) {
zones[aws.StringValue(zone.Id)] = zone
}
}

return true
}

err := p.Client.ListHostedZonesPages(&route53.ListHostedZonesInput{}, f)
if err != nil {
return nil, err
}

return zones, nil
}

// Records returns the list of records in a given hosted zone.
func (p *AWSProvider) Records(zone string) ([]*endpoint.Endpoint, error) {
endpoints := []*endpoint.Endpoint{}
func (p *AWSProvider) Records(_ string) (endpoints []*endpoint.Endpoint, _ error) {
zones, err := p.Zones()
if err != nil {
return nil, err
}

f := func(resp *route53.ListResourceRecordSetsOutput, lastPage bool) (shouldContinue bool) {
for _, r := range resp.ResourceRecordSets {
Expand All @@ -117,70 +146,106 @@ func (p *AWSProvider) Records(zone string) ([]*endpoint.Endpoint, error) {
return true
}

params := &route53.ListResourceRecordSetsInput{
HostedZoneId: aws.String(expandedHostedZoneID(zone)),
}
for _, z := range zones {
params := &route53.ListResourceRecordSetsInput{
HostedZoneId: z.Id,
}

if err := p.Client.ListResourceRecordSetsPages(params, f); err != nil {
return nil, err
if err := p.Client.ListResourceRecordSetsPages(params, f); err != nil {
return nil, err
}
}

return endpoints, nil
}

// CreateRecords creates a given set of DNS records in the given hosted zone.
func (p *AWSProvider) CreateRecords(zone string, endpoints []*endpoint.Endpoint) error {
return p.submitChanges(zone, newChanges(route53.ChangeActionCreate, endpoints))
func (p *AWSProvider) CreateRecords(endpoints []*endpoint.Endpoint) error {
return p.submitChanges(newChanges(route53.ChangeActionCreate, endpoints))
}

// UpdateRecords updates a given set of old records to a new set of records in a given hosted zone.
func (p *AWSProvider) UpdateRecords(zone string, endpoints, _ []*endpoint.Endpoint) error {
return p.submitChanges(zone, newChanges(route53.ChangeActionUpsert, endpoints))
func (p *AWSProvider) UpdateRecords(endpoints, _ []*endpoint.Endpoint) error {
return p.submitChanges(newChanges(route53.ChangeActionUpsert, endpoints))
}

// DeleteRecords deletes a given set of DNS records in a given zone.
func (p *AWSProvider) DeleteRecords(zone string, endpoints []*endpoint.Endpoint) error {
return p.submitChanges(zone, newChanges(route53.ChangeActionDelete, endpoints))
func (p *AWSProvider) DeleteRecords(endpoints []*endpoint.Endpoint) error {
return p.submitChanges(newChanges(route53.ChangeActionDelete, endpoints))
}

// ApplyChanges applies a given set of changes in a given zone.
func (p *AWSProvider) ApplyChanges(zone string, changes *plan.Changes) error {
func (p *AWSProvider) ApplyChanges(_ string, changes *plan.Changes) error {
combinedChanges := make([]*route53.Change, 0, len(changes.Create)+len(changes.UpdateNew)+len(changes.Delete))

combinedChanges = append(combinedChanges, newChanges(route53.ChangeActionCreate, changes.Create)...)
combinedChanges = append(combinedChanges, newChanges(route53.ChangeActionUpsert, changes.UpdateNew)...)
combinedChanges = append(combinedChanges, newChanges(route53.ChangeActionDelete, changes.Delete)...)

return p.submitChanges(zone, combinedChanges)
return p.submitChanges(combinedChanges)
}

// submitChanges takes a zone and a collection of Changes and sends them as a single transaction.
func (p *AWSProvider) submitChanges(zone string, changes []*route53.Change) error {
func (p *AWSProvider) submitChanges(changes []*route53.Change) error {
// return early if there is nothing to change
if len(changes) == 0 {
return nil
}

if p.DryRun {
for _, change := range changes {
log.Infof("Changing records: %s %s", aws.StringValue(change.Action), change.String())
zones, err := p.Zones()
if err != nil {
return err
}

// separate into per-zone change sets to be passed to the API.
changesByZone := changesByZone(zones, changes)

for z, cs := range changesByZone {
if p.DryRun {
for _, c := range cs {
log.Infof("Changing records: %s %s", aws.StringValue(c.Action), c.String())
}
} else {
params := &route53.ChangeResourceRecordSetsInput{
HostedZoneId: aws.String(z),
ChangeBatch: &route53.ChangeBatch{
Changes: cs,
},
}

if _, err := p.Client.ChangeResourceRecordSets(params); err != nil {
log.Error(err)
}
}
}

return nil
return nil
}

// changesByZone separates a multi-zone change into a single change per zone.
func changesByZone(zones map[string]*route53.HostedZone, changeSet []*route53.Change) map[string][]*route53.Change {
changes := make(map[string][]*route53.Change)

for _, z := range zones {
changes[aws.StringValue(z.Id)] = []*route53.Change{}
}

params := &route53.ChangeResourceRecordSetsInput{
HostedZoneId: aws.String(expandedHostedZoneID(zone)),
ChangeBatch: &route53.ChangeBatch{
Changes: changes,
},
for _, c := range changeSet {
hostname := ensureTrailingDot(aws.StringValue(c.ResourceRecordSet.Name))

if zone := suitableZone(hostname, zones); zone != nil {
changes[aws.StringValue(zone.Id)] = append(changes[aws.StringValue(zone.Id)], c)
}
}

if _, err := p.Client.ChangeResourceRecordSets(params); err != nil {
return err
// separating a change could lead to empty sub changes, remove them here.
for zone, change := range changes {
if len(change) == 0 {
delete(changes, zone)
}
}

return nil
return changes
}

// newChanges returns a collection of Changes based on the given records and action.
Expand Down Expand Up @@ -225,6 +290,22 @@ func newChange(action string, endpoint *endpoint.Endpoint) *route53.Change {
return change
}

// suitableZone returns the most suitable zone for a given hostname and a set of zones.
func suitableZone(hostname string, zones map[string]*route53.HostedZone) *route53.HostedZone {
var zone *route53.HostedZone

for _, z := range zones {
if strings.HasSuffix(hostname, aws.StringValue(z.Name)) {
if zone == nil || len(aws.StringValue(z.Name)) > len(aws.StringValue(zone.Name)) {
zone = z
}
}
}

return zone
}

// isAWSLoadBalancer determines if a given hostname belongs to an AWS load balancer.
func isAWSLoadBalancer(ep *endpoint.Endpoint) bool {
if ep.RecordType == "" {
return canonicalHostedZone(ep.Target) != ""
Expand All @@ -233,6 +314,7 @@ func isAWSLoadBalancer(ep *endpoint.Endpoint) bool {
return ep.RecordType == "ALIAS"
}

// canonicalHostedZone returns the matching canonical zone for a given hostname.
func canonicalHostedZone(hostname string) string {
for suffix, zone := range canonicalHostedZones {
if strings.HasSuffix(hostname, suffix) {
Expand All @@ -243,6 +325,11 @@ func canonicalHostedZone(hostname string) string {
return ""
}

func expandedHostedZoneID(zone string) string {
return hostedZonePrefix + strings.TrimPrefix(zone, hostedZonePrefix)
// ensureTrailingDot ensures that the hostname receives a trailing dot if it hasn't already.
func ensureTrailingDot(hostname string) string {
if net.ParseIP(hostname) != nil {
return hostname
}

return strings.TrimSuffix(hostname, ".") + "."
}
Loading

0 comments on commit 03d7620

Please sign in to comment.