diff --git a/CHANGELOG.md b/CHANGELOG.md index 11566133c..eb893e905 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ Features: - Improved logging - Generate DNS Name from template for services/ingress if annotation is missing but `--fqdn-template` is specified - - Route 53: Support creation of records in multiple hosted zones. + - Route 53, Google CloudDNS: 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 diff --git a/main.go b/main.go index 951db8f1e..cd6e51fab 100644 --- a/main.go +++ b/main.go @@ -105,7 +105,7 @@ func main() { var p provider.Provider switch cfg.Provider { case "google": - p, err = provider.NewGoogleProvider(cfg.GoogleProject, cfg.DryRun) + p, err = provider.NewGoogleProvider(cfg.GoogleProject, cfg.Domain, cfg.DryRun) case "aws": p, err = provider.NewAWSProvider(cfg.Domain, cfg.DryRun) default: diff --git a/provider/aws.go b/provider/aws.go index 318ed9953..05f9e0727 100644 --- a/provider/aws.go +++ b/provider/aws.go @@ -17,7 +17,6 @@ limitations under the License. package provider import ( - "net" "strings" log "github.com/Sirupsen/logrus" @@ -327,12 +326,3 @@ func canonicalHostedZone(hostname string) string { return "" } - -// 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, ".") + "." -} diff --git a/provider/google.go b/provider/google.go index b3fbf6246..bfe309ec6 100644 --- a/provider/google.go +++ b/provider/google.go @@ -17,6 +17,8 @@ limitations under the License. package provider import ( + "strings" + log "github.com/Sirupsen/logrus" "golang.org/x/net/context" @@ -33,17 +35,12 @@ type managedZonesCreateCallInterface interface { Do(opts ...googleapi.CallOption) (*dns.ManagedZone, error) } -type managedZonesDeleteCallInterface interface { - Do(opts ...googleapi.CallOption) error -} - type managedZonesListCallInterface interface { Pages(ctx context.Context, f func(*dns.ManagedZonesListResponse) error) error } type managedZonesServiceInterface interface { Create(project string, managedzone *dns.ManagedZone) managedZonesCreateCallInterface - Delete(project string, managedZone string) managedZonesDeleteCallInterface List(project string) managedZonesListCallInterface } @@ -79,10 +76,6 @@ func (m managedZonesService) Create(project string, managedzone *dns.ManagedZone return m.service.Create(project, managedzone) } -func (m managedZonesService) Delete(project string, managedZone string) managedZonesDeleteCallInterface { - return m.service.Delete(project, managedZone) -} - func (m managedZonesService) List(project string) managedZonesListCallInterface { return m.service.List(project) } @@ -101,6 +94,8 @@ type googleProvider struct { project string // Enabled dry-run will print any modifying actions rather than execute them. dryRun bool + // only consider hosted zones managing domains ending in this suffix + domain string // A client for managing resource record sets resourceRecordSetsClient resourceRecordSetsClientInterface // A client for managing hosted zones @@ -110,7 +105,7 @@ type googleProvider struct { } // NewGoogleProvider initializes a new Google CloudDNS based Provider. -func NewGoogleProvider(project string, dryRun bool) (Provider, error) { +func NewGoogleProvider(project string, domain string, dryRun bool) (Provider, error) { gcloud, err := google.DefaultClient(context.TODO(), dns.NdevClouddnsReadwriteScope) if err != nil { return nil, err @@ -123,6 +118,7 @@ func NewGoogleProvider(project string, dryRun bool) (Provider, error) { provider := &googleProvider{ project: project, + domain: domain, dryRun: dryRun, resourceRecordSetsClient: resourceRecordSetsService{dnsClient.ResourceRecordSets}, managedZonesClient: managedZonesService{dnsClient.ManagedZones}, @@ -133,49 +129,33 @@ func NewGoogleProvider(project string, dryRun bool) (Provider, error) { } // Zones returns the list of hosted zones. -func (p *googleProvider) Zones() (zones []*dns.ManagedZone, _ error) { +func (p *googleProvider) Zones() (map[string]*dns.ManagedZone, error) { + zones := make(map[string]*dns.ManagedZone) + f := func(resp *dns.ManagedZonesListResponse) error { - // each page is processed sequentially, no need for a mutex here. - zones = append(zones, resp.ManagedZones...) + for _, zone := range resp.ManagedZones { + if strings.HasSuffix(zone.DnsName, p.domain) { + zones[zone.Name] = zone + } + } + return nil } - err := p.managedZonesClient.List(p.project).Pages(context.TODO(), f) - if err != nil { + if err := p.managedZonesClient.List(p.project).Pages(context.TODO(), f); err != nil { return nil, err } return zones, nil } -// CreateZone creates a hosted zone given a name. -func (p *googleProvider) CreateZone(name, domain string) error { - zone := &dns.ManagedZone{ - Name: name, - DnsName: domain, - Description: "Automatically managed zone by kubernetes.io/external-dns", - } - - _, err := p.managedZonesClient.Create(p.project, zone).Do() +// Records returns the list of records in all relevant zones. +func (p *googleProvider) Records(_ string) (endpoints []*endpoint.Endpoint, _ error) { + zones, err := p.Zones() if err != nil { - return err + return nil, err } - return nil -} - -// DeleteZone deletes a hosted zone given a name. -func (p *googleProvider) DeleteZone(name string) error { - err := p.managedZonesClient.Delete(p.project, name).Do() - if err != nil { - return err - } - - return nil -} - -// Records returns the list of A records in a given hosted zone. -func (p *googleProvider) Records(zone string) (endpoints []*endpoint.Endpoint, _ error) { f := func(resp *dns.ResourceRecordSetsListResponse) error { for _, r := range resp.Rrsets { // TODO(linki, ownership): Remove once ownership system is in place. @@ -196,44 +176,45 @@ func (p *googleProvider) Records(zone string) (endpoints []*endpoint.Endpoint, _ return nil } - err := p.resourceRecordSetsClient.List(p.project, zone).Pages(context.TODO(), f) - if err != nil { - return nil, err + for _, z := range zones { + if err := p.resourceRecordSetsClient.List(p.project, z.Name).Pages(context.TODO(), f); err != nil { + return nil, err + } } return endpoints, nil } // CreateRecords creates a given set of DNS records in the given hosted zone. -func (p *googleProvider) CreateRecords(zone string, endpoints []*endpoint.Endpoint) error { +func (p *googleProvider) CreateRecords(endpoints []*endpoint.Endpoint) error { change := &dns.Change{} change.Additions = append(change.Additions, newRecords(endpoints)...) - return p.submitChange(zone, change) + return p.submitChange(change) } // UpdateRecords updates a given set of old records to a new set of records in a given hosted zone. -func (p *googleProvider) UpdateRecords(zone string, records, oldRecords []*endpoint.Endpoint) error { +func (p *googleProvider) UpdateRecords(records, oldRecords []*endpoint.Endpoint) error { change := &dns.Change{} change.Additions = append(change.Additions, newRecords(records)...) change.Deletions = append(change.Deletions, newRecords(oldRecords)...) - return p.submitChange(zone, change) + return p.submitChange(change) } // DeleteRecords deletes a given set of DNS records in a given zone. -func (p *googleProvider) DeleteRecords(zone string, endpoints []*endpoint.Endpoint) error { +func (p *googleProvider) DeleteRecords(endpoints []*endpoint.Endpoint) error { change := &dns.Change{} change.Deletions = append(change.Deletions, newRecords(endpoints)...) - return p.submitChange(zone, change) + return p.submitChange(change) } // ApplyChanges applies a given set of changes in a given zone. -func (p *googleProvider) ApplyChanges(zone string, changes *plan.Changes) error { +func (p *googleProvider) ApplyChanges(_ string, changes *plan.Changes) error { change := &dns.Change{} change.Additions = append(change.Additions, newRecords(changes.Create)...) @@ -243,12 +224,11 @@ func (p *googleProvider) ApplyChanges(zone string, changes *plan.Changes) error change.Deletions = append(change.Deletions, newRecords(changes.Delete)...) - return p.submitChange(zone, change) + return p.submitChange(change) } // submitChange takes a zone and a Change and sends it to Google. -func (p *googleProvider) submitChange(zone string, change *dns.Change) error { - +func (p *googleProvider) submitChange(change *dns.Change) error { if len(change.Additions) == 0 && len(change.Deletions) == 0 { log.Infoln("Received empty list of records for creation and deletion") return nil @@ -261,9 +241,20 @@ func (p *googleProvider) submitChange(zone string, change *dns.Change) error { log.Infof("Add records: %s %s %s", add.Name, add.Type, add.Rrdatas) } - if !p.dryRun { - _, err := p.changesClient.Create(p.project, zone, change).Do() - if err != nil { + if p.dryRun { + return nil + } + + zones, err := p.Zones() + if err != nil { + return err + } + + // separate into per-zone change sets to be passed to the API. + changes := separateChange(zones, change) + + for z, c := range changes { + if _, err := p.changesClient.Create(p.project, z, c).Do(); err != nil { return err } } @@ -271,6 +262,54 @@ func (p *googleProvider) submitChange(zone string, change *dns.Change) error { return nil } +// separateChange separates a multi-zone change into a single change per zone. +func separateChange(zones map[string]*dns.ManagedZone, change *dns.Change) map[string]*dns.Change { + changes := make(map[string]*dns.Change) + + for _, z := range zones { + changes[z.Name] = &dns.Change{ + Additions: []*dns.ResourceRecordSet{}, + Deletions: []*dns.ResourceRecordSet{}, + } + } + + for _, a := range change.Additions { + if zone := suitableManagedZone(ensureTrailingDot(a.Name), zones); zone != nil { + changes[zone.Name].Additions = append(changes[zone.Name].Additions, a) + } + } + + for _, d := range change.Deletions { + if zone := suitableManagedZone(ensureTrailingDot(d.Name), zones); zone != nil { + changes[zone.Name].Deletions = append(changes[zone.Name].Deletions, d) + } + } + + // separating a change could lead to empty sub changes, remove them here. + for zone, change := range changes { + if len(change.Additions) == 0 && len(change.Deletions) == 0 { + delete(changes, zone) + } + } + + return changes +} + +// suitableManagedZone returns the most suitable zone for a given hostname and a set of zones. +func suitableManagedZone(hostname string, zones map[string]*dns.ManagedZone) *dns.ManagedZone { + var zone *dns.ManagedZone + + for _, z := range zones { + if strings.HasSuffix(hostname, z.DnsName) { + if zone == nil || len(z.DnsName) > len(zone.DnsName) { + zone = z + } + } + } + + return zone +} + // newRecords returns a collection of RecordSets based on the given endpoints. func newRecords(endpoints []*endpoint.Endpoint) []*dns.ResourceRecordSet { records := make([]*dns.ResourceRecordSet, len(endpoints)) @@ -284,9 +323,17 @@ func newRecords(endpoints []*endpoint.Endpoint) []*dns.ResourceRecordSet { // newRecord returns a RecordSet based on the given endpoint. func newRecord(endpoint *endpoint.Endpoint) *dns.ResourceRecordSet { + // TODO(linki): works around appending a trailing dot to TXT records. I think + // we should go back to storing DNS names with a trailing dot internally. This + // way we can use it has is here and trim it off if it exists when necessary. + target := endpoint.Target + if suitableType(endpoint) == "CNAME" { + target = ensureTrailingDot(target) + } + return &dns.ResourceRecordSet{ Name: ensureTrailingDot(endpoint.DNSName), - Rrdatas: []string{ensureTrailingDot(endpoint.Target)}, + Rrdatas: []string{target}, Ttl: 300, Type: suitableType(endpoint), } diff --git a/provider/google_test.go b/provider/google_test.go index 1bb4d7dbd..f468f9c8d 100644 --- a/provider/google_test.go +++ b/provider/google_test.go @@ -18,6 +18,8 @@ package provider import ( "fmt" + "net/http" + "strings" "testing" "golang.org/x/net/context" @@ -25,320 +27,603 @@ import ( "github.com/kubernetes-incubator/external-dns/endpoint" "github.com/kubernetes-incubator/external-dns/plan" - dns "google.golang.org/api/dns/v1" - googleapi "google.golang.org/api/googleapi" + "google.golang.org/api/dns/v1" + "google.golang.org/api/googleapi" ) var ( - expectedZones = []*dns.ManagedZone{{Name: "expected"}} - expectedRecordSets = []*dns.ResourceRecordSet{ - { - Type: "A", - Name: "expected-1", - Rrdatas: []string{"8.8.8.8"}, - }, - { - Type: "CNAME", - Name: "expected-2", - Rrdatas: []string{"target.com"}, - }, - { - Type: "NS", - Name: "unexpected", - Rrdatas: []string{"target"}, - }, - } + testZones = map[string]*dns.ManagedZone{} + testRecords = map[string]map[string]*dns.ResourceRecordSet{} ) -type mockManagedZonesCreateCall struct{} +type mockManagedZonesCreateCall struct { + project string + managedZone *dns.ManagedZone +} func (m *mockManagedZonesCreateCall) Do(opts ...googleapi.CallOption) (*dns.ManagedZone, error) { - return nil, nil + zoneKey := zoneKey(m.project, m.managedZone.Name) + + if _, ok := testZones[zoneKey]; ok { + return nil, &googleapi.Error{Code: http.StatusConflict} + } + + testZones[zoneKey] = m.managedZone + + return m.managedZone, nil } -type mockErrManagedZonesCreateCall struct{} - -func (m *mockErrManagedZonesCreateCall) Do(opts ...googleapi.CallOption) (*dns.ManagedZone, error) { - return nil, fmt.Errorf("failed") +type mockManagedZonesListCall struct { + project string } -type mockManagedZonesDeleteCall struct{} - -func (m *mockManagedZonesDeleteCall) Do(opts ...googleapi.CallOption) error { - return nil -} - -type mockErrManagedZonesDeleteCall struct{} - -func (m *mockErrManagedZonesDeleteCall) Do(opts ...googleapi.CallOption) error { - return fmt.Errorf("failed") -} - -type mockManagedZonesListCall struct{} - func (m *mockManagedZonesListCall) Pages(ctx context.Context, f func(*dns.ManagedZonesListResponse) error) error { - return f(&dns.ManagedZonesListResponse{ManagedZones: expectedZones}) -} + zones := []*dns.ManagedZone{} -type mockErrManagedZonesListCall struct{} + for k, v := range testZones { + if strings.HasPrefix(k, m.project+"/") { + zones = append(zones, v) + } + } -func (m *mockErrManagedZonesListCall) Pages(ctx context.Context, f func(*dns.ManagedZonesListResponse) error) error { - return fmt.Errorf("failed") + return f(&dns.ManagedZonesListResponse{ManagedZones: zones}) } type mockManagedZonesClient struct{} -func (m *mockManagedZonesClient) Create(project string, managedzone *dns.ManagedZone) managedZonesCreateCallInterface { - return &mockManagedZonesCreateCall{} -} - -func (m *mockManagedZonesClient) Delete(project string, managedZone string) managedZonesDeleteCallInterface { - return &mockManagedZonesDeleteCall{} +func (m *mockManagedZonesClient) Create(project string, managedZone *dns.ManagedZone) managedZonesCreateCallInterface { + return &mockManagedZonesCreateCall{project: project, managedZone: managedZone} } func (m *mockManagedZonesClient) List(project string) managedZonesListCallInterface { - return &mockManagedZonesListCall{} + return &mockManagedZonesListCall{project: project} } -type mockErrManagedZonesClient struct{} - -func (m *mockErrManagedZonesClient) Create(project string, managedzone *dns.ManagedZone) managedZonesCreateCallInterface { - return &mockErrManagedZonesCreateCall{} +type mockResourceRecordSetsListCall struct { + project string + managedZone string } -func (m *mockErrManagedZonesClient) Delete(project string, managedZone string) managedZonesDeleteCallInterface { - return &mockErrManagedZonesDeleteCall{} -} - -func (m *mockErrManagedZonesClient) List(project string) managedZonesListCallInterface { - return &mockErrManagedZonesListCall{} -} - -type mockResourceRecordSetsListCall struct{} - func (m *mockResourceRecordSetsListCall) Pages(ctx context.Context, f func(*dns.ResourceRecordSetsListResponse) error) error { - return f(&dns.ResourceRecordSetsListResponse{Rrsets: expectedRecordSets}) -} + zoneKey := zoneKey(m.project, m.managedZone) -type mockErrResourceRecordSetsListCall struct{} + if _, ok := testZones[zoneKey]; !ok { + return &googleapi.Error{Code: http.StatusNotFound} + } -func (m *mockErrResourceRecordSetsListCall) Pages(ctx context.Context, f func(*dns.ResourceRecordSetsListResponse) error) error { - return fmt.Errorf("failed") + resp := []*dns.ResourceRecordSet{} + + for _, v := range testRecords[zoneKey] { + resp = append(resp, v) + } + + return f(&dns.ResourceRecordSetsListResponse{Rrsets: resp}) } type mockResourceRecordSetsClient struct{} func (m *mockResourceRecordSetsClient) List(project string, managedZone string) resourceRecordSetsListCallInterface { - return &mockResourceRecordSetsListCall{} + return &mockResourceRecordSetsListCall{project: project, managedZone: managedZone} } -type mockErrResourceRecordSetsClient struct{} - -func (m *mockErrResourceRecordSetsClient) List(project string, managedZone string) resourceRecordSetsListCallInterface { - return &mockErrResourceRecordSetsListCall{} +type mockChangesCreateCall struct { + project string + managedZone string + change *dns.Change } -type mockChangesCreateCall struct{} - func (m *mockChangesCreateCall) Do(opts ...googleapi.CallOption) (*dns.Change, error) { - return nil, nil -} + zoneKey := zoneKey(m.project, m.managedZone) -type mockErrChangesCreateCall struct{} + if _, ok := testZones[zoneKey]; !ok { + return nil, &googleapi.Error{Code: http.StatusNotFound} + } -func (m *mockErrChangesCreateCall) Do(opts ...googleapi.CallOption) (*dns.Change, error) { - return nil, fmt.Errorf("failed") + if _, ok := testRecords[zoneKey]; !ok { + testRecords[zoneKey] = make(map[string]*dns.ResourceRecordSet) + } + + for _, c := range append(m.change.Additions, m.change.Deletions...) { + if !isValidRecordSet(c) { + return nil, &googleapi.Error{ + Code: http.StatusBadRequest, + Message: fmt.Sprintf("invalid record: %v", c), + } + } + } + + for _, del := range m.change.Deletions { + recordKey := recordKey(del.Type, del.Name) + delete(testRecords[zoneKey], recordKey) + } + + for _, add := range m.change.Additions { + recordKey := recordKey(add.Type, add.Name) + testRecords[zoneKey][recordKey] = add + } + + return m.change, nil } type mockChangesClient struct{} func (m *mockChangesClient) Create(project string, managedZone string, change *dns.Change) changesCreateCallInterface { - return &mockChangesCreateCall{} + return &mockChangesCreateCall{project: project, managedZone: managedZone, change: change} } -type mockErrChangesClient struct{} +func zoneKey(project, zoneName string) string { + return project + "/" + zoneName +} -func (m *mockErrChangesClient) Create(project string, managedZone string, change *dns.Change) changesCreateCallInterface { - return &mockErrChangesCreateCall{} +func recordKey(recordType, recordName string) string { + return recordType + "/" + recordName +} + +func isValidRecordSet(recordSet *dns.ResourceRecordSet) bool { + if !hasTrailingDot(recordSet.Name) { + return false + } + + switch recordSet.Type { + case "CNAME": + for _, rrd := range recordSet.Rrdatas { + if !hasTrailingDot(rrd) { + return false + } + } + case "A", "TXT": + for _, rrd := range recordSet.Rrdatas { + if hasTrailingDot(rrd) { + return false + } + } + default: + panic("unhandled record type") + } + + return true +} + +func hasTrailingDot(target string) bool { + return strings.HasSuffix(target, ".") } func TestGoogleZones(t *testing.T) { - provider := &googleProvider{ - project: "project", - managedZonesClient: &mockManagedZonesClient{}, - } + provider := newGoogleProvider(t, "ext-dns-test-2.gcp.zalan.do.", false, []*endpoint.Endpoint{}) zones, err := provider.Zones() if err != nil { - t.Errorf("should not fail: %s", err) + t.Fatal(err) } - if len(zones) != len(expectedZones) { - t.Errorf("expected %d zones, got %d", len(expectedZones), len(zones)) - } - - provider.managedZonesClient = &mockErrManagedZonesClient{} - - _, err = provider.Zones() - if err == nil { - t.Errorf("expected error") - } -} - -func TestGoogleCreateZone(t *testing.T) { - provider := &googleProvider{ - project: "project", - managedZonesClient: &mockManagedZonesClient{}, - } - - err := provider.CreateZone("name", "domain") - if err != nil { - t.Errorf("should not fail: %s", err) - } - - provider.managedZonesClient = &mockErrManagedZonesClient{} - - err = provider.CreateZone("name", "domain") - if err == nil { - t.Errorf("expected error") - } -} - -func TestGoogleDeleteZone(t *testing.T) { - provider := &googleProvider{ - project: "project", - managedZonesClient: &mockManagedZonesClient{}, - } - - err := provider.DeleteZone("name") - if err != nil { - t.Errorf("should not fail: %s", err) - } - - provider.managedZonesClient = &mockErrManagedZonesClient{} - - err = provider.DeleteZone("name") - if err == nil { - t.Errorf("expected error") - } + validateZones(t, zones, map[string]*dns.ManagedZone{ + "zone-1-ext-dns-test-2-gcp-zalan-do": {Name: "zone-1-ext-dns-test-2-gcp-zalan-do", DnsName: "zone-1.ext-dns-test-2.gcp.zalan.do."}, + "zone-2-ext-dns-test-2-gcp-zalan-do": {Name: "zone-2-ext-dns-test-2-gcp-zalan-do", DnsName: "zone-2.ext-dns-test-2.gcp.zalan.do."}, + "zone-3-ext-dns-test-2-gcp-zalan-do": {Name: "zone-3-ext-dns-test-2-gcp-zalan-do", DnsName: "zone-3.ext-dns-test-2.gcp.zalan.do."}, + }) } func TestGoogleRecords(t *testing.T) { - provider := &googleProvider{ - project: "project", - resourceRecordSetsClient: &mockResourceRecordSetsClient{}, + originalEndpoints := []*endpoint.Endpoint{ + endpoint.NewEndpoint("list-test.zone-1.ext-dns-test-2.gcp.zalan.do", "1.2.3.4", "A"), + endpoint.NewEndpoint("list-test.zone-2.ext-dns-test-2.gcp.zalan.do", "8.8.8.8", "A"), + endpoint.NewEndpoint("list-test-alias.zone-1.ext-dns-test-2.gcp.zalan.do", "foo.elb.amazonaws.com", "CNAME"), } - endpoints, err := provider.Records("zone") + provider := newGoogleProvider(t, "ext-dns-test-2.gcp.zalan.do.", false, originalEndpoints) + + records, err := provider.Records("_") if err != nil { - t.Errorf("should not fail: %s", err) + t.Fatal(err) } - if len(endpoints) != len(expectedRecordSets)-1 { - t.Errorf("expected %d endpoints, got %d", len(expectedRecordSets)-1, len(endpoints)) - } - - provider.resourceRecordSetsClient = &mockErrResourceRecordSetsClient{} - - _, err = provider.Records("zone") - if err == nil { - t.Errorf("expected error") - } + validateEndpoints(t, records, originalEndpoints) } func TestGoogleCreateRecords(t *testing.T) { - provider := &googleProvider{ - project: "project", - changesClient: &mockChangesClient{}, + provider := newGoogleProvider(t, "ext-dns-test-2.gcp.zalan.do.", false, []*endpoint.Endpoint{}) + + records := []*endpoint.Endpoint{ + endpoint.NewEndpoint("create-test.zone-1.ext-dns-test-2.gcp.zalan.do", "1.2.3.4", ""), + endpoint.NewEndpoint("create-test.zone-2.ext-dns-test-2.gcp.zalan.do", "8.8.8.8", ""), + endpoint.NewEndpoint("create-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", "foo.elb.amazonaws.com", ""), } - endpoints := []*endpoint.Endpoint{ - { - DNSName: "dns-name", - Target: "target", - }, + if err := provider.CreateRecords(records); err != nil { + t.Fatal(err) } - err := provider.CreateRecords("zone", endpoints) + records, err := provider.Records("_") if err != nil { - t.Errorf("should not fail: %s", err) + t.Fatal(err) } - provider.changesClient = &mockErrChangesClient{} - - err = provider.CreateRecords("zone", endpoints) - if err == nil { - t.Errorf("expected error") - } + validateEndpoints(t, records, []*endpoint.Endpoint{ + endpoint.NewEndpoint("create-test.zone-1.ext-dns-test-2.gcp.zalan.do", "1.2.3.4", "A"), + endpoint.NewEndpoint("create-test.zone-2.ext-dns-test-2.gcp.zalan.do", "8.8.8.8", "A"), + endpoint.NewEndpoint("create-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", "foo.elb.amazonaws.com", "CNAME"), + }) } func TestGoogleUpdateRecords(t *testing.T) { - provider := &googleProvider{ - project: "project", - changesClient: &mockChangesClient{}, + provider := newGoogleProvider(t, "ext-dns-test-2.gcp.zalan.do.", false, []*endpoint.Endpoint{ + endpoint.NewEndpoint("update-test.zone-1.ext-dns-test-2.gcp.zalan.do", "8.8.8.8", "A"), + endpoint.NewEndpoint("update-test.zone-2.ext-dns-test-2.gcp.zalan.do", "8.8.4.4", "A"), + endpoint.NewEndpoint("update-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", "foo.elb.amazonaws.com", "CNAME"), + }) + + currentRecords := []*endpoint.Endpoint{ + endpoint.NewEndpoint("update-test.zone-1.ext-dns-test-2.gcp.zalan.do", "8.8.8.8", "A"), + endpoint.NewEndpoint("update-test.zone-2.ext-dns-test-2.gcp.zalan.do", "8.8.4.4", "A"), + endpoint.NewEndpoint("update-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", "foo.elb.amazonaws.com", "CNAME"), + } + updatedRecords := []*endpoint.Endpoint{ + endpoint.NewEndpoint("update-test.zone-1.ext-dns-test-2.gcp.zalan.do", "1.2.3.4", "A"), + endpoint.NewEndpoint("update-test.zone-2.ext-dns-test-2.gcp.zalan.do", "4.3.2.1", "A"), + endpoint.NewEndpoint("update-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", "bar.elb.amazonaws.com", "CNAME"), } - records := []*endpoint.Endpoint{ - { - DNSName: "dns-name", - Target: "target", - }, + if err := provider.UpdateRecords(updatedRecords, currentRecords); err != nil { + t.Fatal(err) } - oldRecords := []*endpoint.Endpoint{ - { - DNSName: "dns-name", - Target: "target", - }, - } - - err := provider.UpdateRecords("zone", records, oldRecords) + records, err := provider.Records("_") if err != nil { - t.Errorf("should not fail: %s", err) + t.Fatal(err) } - err = provider.UpdateRecords("zone", nil, nil) - if err != nil { - t.Errorf("should not fail: %s", err) - } - - provider.dryRun = true - - err = provider.UpdateRecords("zone", records, oldRecords) - if err != nil { - t.Errorf("should not fail: %s", err) - } + validateEndpoints(t, records, []*endpoint.Endpoint{ + endpoint.NewEndpoint("update-test.zone-1.ext-dns-test-2.gcp.zalan.do", "1.2.3.4", "A"), + endpoint.NewEndpoint("update-test.zone-2.ext-dns-test-2.gcp.zalan.do", "4.3.2.1", "A"), + endpoint.NewEndpoint("update-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", "bar.elb.amazonaws.com", "CNAME"), + }) } func TestGoogleDeleteRecords(t *testing.T) { - provider := &googleProvider{ - project: "project", - changesClient: &mockChangesClient{}, + originalEndpoints := []*endpoint.Endpoint{ + endpoint.NewEndpoint("delete-test.zone-1.ext-dns-test-2.gcp.zalan.do", "1.2.3.4", "A"), + endpoint.NewEndpoint("delete-test.zone-2.ext-dns-test-2.gcp.zalan.do", "8.8.8.8", "A"), + endpoint.NewEndpoint("delete-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", "baz.elb.amazonaws.com", "CNAME"), } - endpoints := []*endpoint.Endpoint{ - { - DNSName: "dns-name", - Target: "target", - }, + provider := newGoogleProvider(t, "ext-dns-test-2.gcp.zalan.do.", false, originalEndpoints) + + if err := provider.DeleteRecords(originalEndpoints); err != nil { + t.Fatal(err) } - err := provider.DeleteRecords("zone", endpoints) + records, err := provider.Records("_") if err != nil { - t.Errorf("should not fail: %s", err) + t.Fatal(err) } + + validateEndpoints(t, records, []*endpoint.Endpoint{}) } func TestGoogleApplyChanges(t *testing.T) { - provider := &googleProvider{ - project: "project", - changesClient: &mockChangesClient{}, + provider := newGoogleProvider(t, "ext-dns-test-2.gcp.zalan.do.", false, []*endpoint.Endpoint{ + endpoint.NewEndpoint("update-test.zone-1.ext-dns-test-2.gcp.zalan.do", "8.8.8.8", "A"), + endpoint.NewEndpoint("delete-test.zone-1.ext-dns-test-2.gcp.zalan.do", "8.8.8.8", "A"), + endpoint.NewEndpoint("update-test.zone-2.ext-dns-test-2.gcp.zalan.do", "8.8.4.4", "A"), + endpoint.NewEndpoint("delete-test.zone-2.ext-dns-test-2.gcp.zalan.do", "8.8.4.4", "A"), + endpoint.NewEndpoint("update-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", "bar.elb.amazonaws.com", "CNAME"), + endpoint.NewEndpoint("delete-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", "qux.elb.amazonaws.com", "CNAME"), + }) + + createRecords := []*endpoint.Endpoint{ + endpoint.NewEndpoint("create-test.zone-1.ext-dns-test-2.gcp.zalan.do", "8.8.8.8", "A"), + endpoint.NewEndpoint("create-test.zone-2.ext-dns-test-2.gcp.zalan.do", "8.8.4.4", "A"), + endpoint.NewEndpoint("create-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", "foo.elb.amazonaws.com", "CNAME"), } - changes := &plan.Changes{} + currentRecords := []*endpoint.Endpoint{ + endpoint.NewEndpoint("update-test.zone-1.ext-dns-test-2.gcp.zalan.do", "8.8.8.8", "A"), + endpoint.NewEndpoint("update-test.zone-2.ext-dns-test-2.gcp.zalan.do", "8.8.4.4", "A"), + endpoint.NewEndpoint("update-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", "bar.elb.amazonaws.com", "CNAME"), + } + updatedRecords := []*endpoint.Endpoint{ + endpoint.NewEndpoint("update-test.zone-1.ext-dns-test-2.gcp.zalan.do", "1.2.3.4", "A"), + endpoint.NewEndpoint("update-test.zone-2.ext-dns-test-2.gcp.zalan.do", "4.3.2.1", "A"), + endpoint.NewEndpoint("update-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", "baz.elb.amazonaws.com", "CNAME"), + } - err := provider.ApplyChanges("zone", changes) + deleteRecords := []*endpoint.Endpoint{ + endpoint.NewEndpoint("delete-test.zone-1.ext-dns-test-2.gcp.zalan.do", "8.8.8.8", "A"), + endpoint.NewEndpoint("delete-test.zone-2.ext-dns-test-2.gcp.zalan.do", "8.8.4.4", "A"), + endpoint.NewEndpoint("delete-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", "qux.elb.amazonaws.com", "CNAME"), + } + + changes := &plan.Changes{ + Create: createRecords, + UpdateNew: updatedRecords, + UpdateOld: currentRecords, + Delete: deleteRecords, + } + + if err := provider.ApplyChanges("_", changes); err != nil { + t.Fatal(err) + } + + records, err := provider.Records("_") if err != nil { - t.Errorf("should not fail: %s", err) + t.Fatal(err) + } + + validateEndpoints(t, records, []*endpoint.Endpoint{ + endpoint.NewEndpoint("create-test.zone-1.ext-dns-test-2.gcp.zalan.do", "8.8.8.8", "A"), + endpoint.NewEndpoint("update-test.zone-1.ext-dns-test-2.gcp.zalan.do", "1.2.3.4", "A"), + endpoint.NewEndpoint("create-test.zone-2.ext-dns-test-2.gcp.zalan.do", "8.8.4.4", "A"), + endpoint.NewEndpoint("update-test.zone-2.ext-dns-test-2.gcp.zalan.do", "4.3.2.1", "A"), + endpoint.NewEndpoint("create-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", "foo.elb.amazonaws.com", "CNAME"), + endpoint.NewEndpoint("update-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", "baz.elb.amazonaws.com", "CNAME"), + }) +} + +func TestGoogleApplyChangesDryRun(t *testing.T) { + originalEndpoints := []*endpoint.Endpoint{ + endpoint.NewEndpoint("update-test.zone-1.ext-dns-test-2.gcp.zalan.do", "8.8.8.8", "A"), + endpoint.NewEndpoint("delete-test.zone-1.ext-dns-test-2.gcp.zalan.do", "8.8.8.8", "A"), + endpoint.NewEndpoint("update-test.zone-2.ext-dns-test-2.gcp.zalan.do", "8.8.4.4", "A"), + endpoint.NewEndpoint("delete-test.zone-2.ext-dns-test-2.gcp.zalan.do", "8.8.4.4", "A"), + endpoint.NewEndpoint("update-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", "bar.elb.amazonaws.com", "CNAME"), + endpoint.NewEndpoint("delete-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", "qux.elb.amazonaws.com", "CNAME"), + } + + provider := newGoogleProvider(t, "ext-dns-test-2.gcp.zalan.do.", true, originalEndpoints) + + createRecords := []*endpoint.Endpoint{ + endpoint.NewEndpoint("create-test.zone-1.ext-dns-test-2.gcp.zalan.do", "8.8.8.8", "A"), + endpoint.NewEndpoint("create-test.zone-2.ext-dns-test-2.gcp.zalan.do", "8.8.4.4", "A"), + endpoint.NewEndpoint("create-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", "foo.elb.amazonaws.com", "CNAME"), + } + + currentRecords := []*endpoint.Endpoint{ + endpoint.NewEndpoint("update-test.zone-1.ext-dns-test-2.gcp.zalan.do", "8.8.8.8", "A"), + endpoint.NewEndpoint("update-test.zone-2.ext-dns-test-2.gcp.zalan.do", "8.8.4.4", "A"), + endpoint.NewEndpoint("update-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", "bar.elb.amazonaws.com", "CNAME"), + } + updatedRecords := []*endpoint.Endpoint{ + endpoint.NewEndpoint("update-test.zone-1.ext-dns-test-2.gcp.zalan.do", "1.2.3.4", "A"), + endpoint.NewEndpoint("update-test.zone-2.ext-dns-test-2.gcp.zalan.do", "4.3.2.1", "A"), + endpoint.NewEndpoint("update-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", "baz.elb.amazonaws.com", "CNAME"), + } + + deleteRecords := []*endpoint.Endpoint{ + endpoint.NewEndpoint("delete-test.zone-1.ext-dns-test-2.gcp.zalan.do", "8.8.8.8", "A"), + endpoint.NewEndpoint("delete-test.zone-2.ext-dns-test-2.gcp.zalan.do", "8.8.4.4", "A"), + endpoint.NewEndpoint("delete-test-cname.zone-1.ext-dns-test-2.gcp.zalan.do", "qux.elb.amazonaws.com", "CNAME"), + } + + changes := &plan.Changes{ + Create: createRecords, + UpdateNew: updatedRecords, + UpdateOld: currentRecords, + Delete: deleteRecords, + } + + if err := provider.ApplyChanges("_", changes); err != nil { + t.Fatal(err) + } + + records, err := provider.Records("_") + if err != nil { + t.Fatal(err) + } + + validateEndpoints(t, records, originalEndpoints) +} + +func TestGoogleApplyChangesEmpty(t *testing.T) { + provider := newGoogleProvider(t, "ext-dns-test-2.gcp.zalan.do.", false, []*endpoint.Endpoint{}) + + if err := provider.ApplyChanges("_", &plan.Changes{}); err != nil { + t.Error(err) + } +} + +func TestSeparateChanges(t *testing.T) { + change := &dns.Change{ + Additions: []*dns.ResourceRecordSet{ + {Name: "qux.foo.example.org.", Ttl: 1}, + {Name: "qux.bar.example.org.", Ttl: 2}, + }, + Deletions: []*dns.ResourceRecordSet{ + {Name: "wambo.foo.example.org.", Ttl: 10}, + {Name: "wambo.bar.example.org.", Ttl: 20}, + }, + } + + zones := map[string]*dns.ManagedZone{ + "foo-example-org": { + Name: "foo-example-org", + DnsName: "foo.example.org.", + }, + "bar-example-org": { + Name: "bar-example-org", + DnsName: "bar.example.org.", + }, + "baz-example-org": { + Name: "baz-example-org", + DnsName: "baz.example.org.", + }, + } + + changes := separateChange(zones, change) + + if len(changes) != 2 { + t.Fatalf("expected %d change(s), got %d", 2, len(changes)) + } + + validateChange(t, changes["foo-example-org"], &dns.Change{ + Additions: []*dns.ResourceRecordSet{ + {Name: "qux.foo.example.org.", Ttl: 1}, + }, + Deletions: []*dns.ResourceRecordSet{ + {Name: "wambo.foo.example.org.", Ttl: 10}, + }, + }) + + validateChange(t, changes["bar-example-org"], &dns.Change{ + Additions: []*dns.ResourceRecordSet{ + {Name: "qux.bar.example.org.", Ttl: 2}, + }, + Deletions: []*dns.ResourceRecordSet{ + {Name: "wambo.bar.example.org.", Ttl: 20}, + }, + }) +} + +func TestGoogleSuitableZone(t *testing.T) { + zones := map[string]*dns.ManagedZone{ + "example-org": {Name: "example-org", DnsName: "example.org."}, + "bar-example-org": {Name: "bar-example-org", DnsName: "bar.example.org."}, + } + + for _, tc := range []struct { + hostname string + expected *dns.ManagedZone + }{ + {"foo.bar.example.org.", zones["bar-example-org"]}, + {"foo.example.org.", zones["example-org"]}, + {"foo.kubernetes.io.", nil}, + } { + suitableZone := suitableManagedZone(tc.hostname, zones) + + if suitableZone != tc.expected { + t.Errorf("expected %v, got %v", tc.expected, suitableZone) + } + } +} + +func validateZones(t *testing.T, zones map[string]*dns.ManagedZone, expected map[string]*dns.ManagedZone) { + if len(zones) != len(expected) { + t.Fatalf("expected %d zone(s), got %d", len(expected), len(zones)) + } + + for i, zone := range zones { + validateZone(t, zone, expected[i]) + } +} + +func validateZone(t *testing.T, zone *dns.ManagedZone, expected *dns.ManagedZone) { + if zone.Name != expected.Name { + t.Errorf("expected %s, got %s", expected.Name, zone.Name) + } + + if zone.DnsName != expected.DnsName { + t.Errorf("expected %s, got %s", expected.DnsName, zone.DnsName) + } +} + +func validateChange(t *testing.T, change *dns.Change, expected *dns.Change) { + validateChangeRecords(t, change.Additions, expected.Additions) + validateChangeRecords(t, change.Deletions, expected.Deletions) +} + +func validateChangeRecords(t *testing.T, records []*dns.ResourceRecordSet, expected []*dns.ResourceRecordSet) { + if len(records) != len(expected) { + t.Fatalf("expected %d change(s), got %d", len(expected), len(records)) + } + + for i := range records { + validateChangeRecord(t, records[i], expected[i]) + } +} + +func validateChangeRecord(t *testing.T, record *dns.ResourceRecordSet, expected *dns.ResourceRecordSet) { + if record.Name != expected.Name { + t.Errorf("expected %s, got %s", expected.Name, record.Name) + } + + if record.Ttl != expected.Ttl { + t.Errorf("expected %d, got %d", expected.Ttl, record.Ttl) + } +} + +func newGoogleProvider(t *testing.T, domain string, dryRun bool, records []*endpoint.Endpoint) *googleProvider { + provider := &googleProvider{ + project: "zalando-external-dns-test", + domain: domain, + dryRun: false, + resourceRecordSetsClient: &mockResourceRecordSetsClient{}, + managedZonesClient: &mockManagedZonesClient{}, + changesClient: &mockChangesClient{}, + } + + createZone(t, provider, &dns.ManagedZone{ + Name: "zone-1-ext-dns-test-2-gcp-zalan-do", + DnsName: "zone-1.ext-dns-test-2.gcp.zalan.do.", + }) + + createZone(t, provider, &dns.ManagedZone{ + Name: "zone-2-ext-dns-test-2-gcp-zalan-do", + DnsName: "zone-2.ext-dns-test-2.gcp.zalan.do.", + }) + + createZone(t, provider, &dns.ManagedZone{ + Name: "zone-3-ext-dns-test-2-gcp-zalan-do", + DnsName: "zone-3.ext-dns-test-2.gcp.zalan.do.", + }) + + setupGoogleRecords(t, provider, records) + + provider.dryRun = dryRun + + return provider +} + +func createZone(t *testing.T, provider *googleProvider, zone *dns.ManagedZone) { + zone.Description = "Testing zone for kubernetes.io/external-dns" + + if _, err := provider.managedZonesClient.Create("zalando-external-dns-test", zone).Do(); err != nil { + if err, ok := err.(*googleapi.Error); !ok || err.Code != http.StatusConflict { + t.Fatal(err) + } + } +} + +func setupGoogleRecords(t *testing.T, provider *googleProvider, endpoints []*endpoint.Endpoint) { + clearGoogleRecords(t, provider, "zone-1-ext-dns-test-2-gcp-zalan-do") + clearGoogleRecords(t, provider, "zone-2-ext-dns-test-2-gcp-zalan-do") + + records, err := provider.Records("_") + if err != nil { + t.Fatal(err) + } + + validateEndpoints(t, records, []*endpoint.Endpoint{}) + + if err = provider.CreateRecords(endpoints); err != nil { + t.Fatal(err) + } + + records, err = provider.Records("_") + if err != nil { + t.Fatal(err) + } + + validateEndpoints(t, records, endpoints) +} + +func clearGoogleRecords(t *testing.T, provider *googleProvider, zone string) { + recordSets := []*dns.ResourceRecordSet{} + if err := provider.resourceRecordSetsClient.List(provider.project, zone).Pages(context.TODO(), func(resp *dns.ResourceRecordSetsListResponse) error { + for _, r := range resp.Rrsets { + switch r.Type { + case "A", "CNAME": + recordSets = append(recordSets, r) + } + } + return nil + }); err != nil { + t.Fatal(err) + } + + if len(recordSets) != 0 { + if _, err := provider.changesClient.Create(provider.project, zone, &dns.Change{ + Deletions: recordSets, + }).Do(); err != nil { + t.Fatal(err) + } } } diff --git a/provider/provider.go b/provider/provider.go index 4d6e39a28..799ff4929 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -18,6 +18,7 @@ package provider import ( "net" + "strings" "github.com/kubernetes-incubator/external-dns/endpoint" "github.com/kubernetes-incubator/external-dns/plan" @@ -40,3 +41,12 @@ func suitableType(ep *endpoint.Endpoint) string { } return "CNAME" } + +// 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, ".") + "." +} diff --git a/provider/provider_test.go b/provider/provider_test.go index da654e99d..7fb42eb36 100644 --- a/provider/provider_test.go +++ b/provider/provider_test.go @@ -43,3 +43,19 @@ func TestSuitableType(t *testing.T) { } } } + +func TestEnsureTrailingDot(t *testing.T) { + for _, tc := range []struct { + input, expected string + }{ + {"example.org", "example.org."}, + {"example.org.", "example.org."}, + {"8.8.8.8", "8.8.8.8"}, + } { + output := ensureTrailingDot(tc.input) + + if output != tc.expected { + t.Errorf("expected %s, got %s", tc.expected, output) + } + } +}