From bff09c20c9512dd70b1b4048c717d87eb19b124b Mon Sep 17 00:00:00 2001 From: mburtless Date: Wed, 17 Oct 2018 21:55:52 -0400 Subject: [PATCH 01/12] Initial Skeleton From NS1 Provider This needs unit tests and a full integration test. Pushing this as an initial checkpoint. --- main.go | 8 ++ provider/ns1.go | 241 +++++++++++++++++++++++++++++++++++++++++++ provider/ns1_test.go | 1 + 3 files changed, 250 insertions(+) create mode 100644 provider/ns1.go create mode 100644 provider/ns1_test.go diff --git a/main.go b/main.go index 5e2354cd4..bebb7444c 100644 --- a/main.go +++ b/main.go @@ -201,6 +201,14 @@ func main() { } case "rfc2136": p, err = provider.NewRfc2136Provider(cfg.RFC2136Host, cfg.RFC2136Port, cfg.RFC2136Zone, cfg.RFC2136Insecure, cfg.RFC2136TSIGKeyName, cfg.RFC2136TSIGSecret, cfg.RFC2136TSIGSecretAlg, cfg.RFC2136TAXFR, domainFilter, cfg.DryRun, nil) + case "ns1": + p, err = provider.NewNS1Provider( + provider.NS1Config{ + DomainFilter: domainFilter, + ZoneIDFilter: zoneIDFilter, + DryRun: cfg.DryRun, + }, + ) default: log.Fatalf("unknown dns provider: %s", cfg.Provider) } diff --git a/provider/ns1.go b/provider/ns1.go new file mode 100644 index 000000000..3f20e1247 --- /dev/null +++ b/provider/ns1.go @@ -0,0 +1,241 @@ +package provider + +import ( + "fmt" + "net/http" + "os" + "strings" + + log "github.com/sirupsen/logrus" + + api "gopkg.in/ns1/ns1-go.v2/rest" + "gopkg.in/ns1/ns1-go.v2/rest/model/dns" + + "github.com/kubernetes-incubator/external-dns/endpoint" + "github.com/kubernetes-incubator/external-dns/plan" +) + +const ( + // ns1Create is a ChangeAction enum value + ns1Create = "CREATE" + // ns1Delete is a ChangeAction enum value + ns1Delete = "DELETE" + // ns1Update is a ChangeAction enum value + ns1Update = "UPDATE" + // ns1DefaultTTL is the default ttl for ttls that are not set + ns1DefaultTTL = 10 +) + +// NS1Config passes cli args to the NS1Provider +type NS1Config struct { + DomainFilter DomainFilter + ZoneIDFilter ZoneIDFilter + DryRun bool +} + +// NS1Provider is the NS1 provider +type NS1Provider struct { + client *api.Client + domainFilter DomainFilter + zoneIDFilter ZoneIDFilter + dryRun bool +} + +// NewNS1Provider creates a new NS1 Provider +func NewNS1Provider(config NS1Config) (*NS1Provider, error) { + return newNS1ProviderWithHTTPClient(config, http.DefaultClient) +} + +func newNS1ProviderWithHTTPClient(config NS1Config, client *http.Client) (*NS1Provider, error) { + token, ok := os.LookupEnv("NS1_APIKEY") + if !ok { + return nil, fmt.Errorf("NS1_APIKEY environment variable is not set") + } + + apiClient := api.NewClient(client, api.SetAPIKey(token)) + + provider := &NS1Provider{ + client: apiClient, + domainFilter: config.DomainFilter, + zoneIDFilter: config.ZoneIDFilter, + } + return provider, nil +} + +func (p *NS1Provider) matchEither(id string) bool { + return p.domainFilter.Match(id) || p.zoneIDFilter.Match(id) +} + +// Records returns the endpoints this provider knows about +func (p *NS1Provider) Records() ([]*endpoint.Endpoint, error) { + zones, err := p.zonesFiltered() + if err != nil { + return nil, err + } + + var endpoints []*endpoint.Endpoint + + for _, zone := range zones { + + // TODO handle Header Codes + zoneData, _, err := p.client.Zones.Get(zone.String()) + if err != nil { + return nil, err + } + + for _, record := range zoneData.Records { + if supportedRecordType(record.Type) { + name := fmt.Sprintf("%s.%s", record.Domain, zoneData.Zone) + endpoints = append(endpoints, endpoint.NewEndpointWithTTL( + name, + record.Type, + endpoint.TTL(record.TTL), + record.ShortAns..., + ), + ) + } + } + } + + return endpoints, nil +} + +func ns1BuildRecord(zoneName string, change *ns1Change) *dns.Record { + record := dns.NewRecord(zoneName, change.Endpoint.DNSName, change.Endpoint.RecordType) + for _, v := range change.Endpoint.Targets { + record.AddAnswer(dns.NewAnswer(strings.Split(v, " "))) + } + // set detault ttl + var ttl = ns1DefaultTTL + if change.Endpoint.RecordTTL.IsConfigured() { + ttl = int(change.Endpoint.RecordTTL) + } + record.TTL = ttl + + return record +} + +func (p *NS1Provider) ns1SubmitChanges(changes []*ns1Change) error { + // return early if there is nothing to change + if len(changes) == 0 { + return nil + } + + zones, err := p.zonesFiltered() + if err != nil { + return err + } + + // separate into per-zone change sets to be passed to the API. + changesByZone := ns1ChangesByZone(zones, changes) + for zoneName, changes := range changesByZone { + for _, change := range changes { + record := ns1BuildRecord(zoneName, change) + logFields := log.Fields{ + "record": record.Domain, + "type": record.Type, + "ttl": record.TTL, + "action": change.Action, + "zone": zoneName, + } + + log.WithFields(logFields).Info("Changing record.") + + if p.dryRun { + continue + } + + switch change.Action { + case ns1Create: + _, err := p.client.Records.Create(record) + if err != nil { + return err + } + case ns1Delete: + _, err := p.client.Records.Delete(zoneName, record.Domain, record.Type) + if err != nil { + return err + } + case ns1Update: + _, err := p.client.Records.Update(record) + if err != nil { + return err + } + } + } + } + return nil +} + +// Zones returns the list of hosted zones. +func (p *NS1Provider) zonesFiltered() ([]*dns.Zone, error) { + // TODO handle Header Codes + zones, _, err := p.client.Zones.List() + if err != nil { + return nil, err + } + + toReturn := []*dns.Zone{} + + for _, z := range zones { + if !p.matchEither(z.Zone) && !p.matchEither(z.ID) { + continue + } + toReturn = append(toReturn, z) + } + + return toReturn, nil +} + +// ns1Change differentiates between ChangActions +type ns1Change struct { + Action string + Endpoint *endpoint.Endpoint +} + +// ApplyChanges applies a given set of changes in a given zone. +func (p *NS1Provider) ApplyChanges(changes *plan.Changes) error { + combinedChanges := make([]*ns1Change, 0, len(changes.Create)+len(changes.UpdateNew)+len(changes.Delete)) + + combinedChanges = append(combinedChanges, newNS1Changes(ns1Create, changes.Create)...) + combinedChanges = append(combinedChanges, newNS1Changes(ns1Update, changes.UpdateNew)...) + combinedChanges = append(combinedChanges, newNS1Changes(ns1Delete, changes.Delete)...) + + return p.ns1SubmitChanges(combinedChanges) +} + +// newNS1Changes returns a collection of Changes based on the given records and action. +func newNS1Changes(action string, endpoints []*endpoint.Endpoint) []*ns1Change { + changes := make([]*ns1Change, 0, len(endpoints)) + + for _, endpoint := range endpoints { + changes = append(changes, &ns1Change{ + Action: action, + Endpoint: endpoint, + }, + ) + } + + return changes +} + +// ns1ChangesByZone separates a multi-zone change into a single change per zone. +func ns1ChangesByZone(zones []*dns.Zone, changeSets []*ns1Change) map[string][]*ns1Change { + changes := make(map[string][]*ns1Change) + zoneNameIDMapper := zoneIDName{} + for _, z := range zones { + zoneNameIDMapper.Add(z.Zone, z.Zone) + changes[z.Zone] = []*ns1Change{} + } + + for _, c := range changeSets { + zone, _ := zoneNameIDMapper.FindZone(c.Endpoint.DNSName) + if zone == "" { + log.Debugf("Skipping record %s because no hosted zone matching record DNS Name was detected ", c.Endpoint.DNSName) + continue + } + changes[zone] = append(changes[zone], c) + } + + return changes +} diff --git a/provider/ns1_test.go b/provider/ns1_test.go new file mode 100644 index 000000000..4f504f668 --- /dev/null +++ b/provider/ns1_test.go @@ -0,0 +1 @@ +package provider From bd791ebdf44da4aefa58a351312983d1e0a43739 Mon Sep 17 00:00:00 2001 From: mburtless Date: Wed, 27 Mar 2019 18:18:25 -0400 Subject: [PATCH 02/12] Add ns1 option for provider flag --- pkg/apis/externaldns/types.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/apis/externaldns/types.go b/pkg/apis/externaldns/types.go index ad497fb87..18ba6e00e 100644 --- a/pkg/apis/externaldns/types.go +++ b/pkg/apis/externaldns/types.go @@ -253,7 +253,7 @@ func (cfg *Config) ParseFlags(args []string) error { app.Flag("service-type-filter", "The service types to take care about (default: all, expected: ClusterIP, NodePort, LoadBalancer or ExternalName)").StringsVar(&cfg.ServiceTypeFilter) // Flags related to providers - app.Flag("provider", "The DNS provider where the DNS records will be created (required, options: aws, aws-sd, google, azure, cloudflare, rcodezero, digitalocean, dnsimple, infoblox, dyn, designate, coredns, skydns, inmemory, pdns, oci, exoscale, linode, rfc2136)").Required().PlaceHolder("provider").EnumVar(&cfg.Provider, "aws", "aws-sd", "google", "azure", "alibabacloud", "cloudflare", "rcodezero", "digitalocean", "dnsimple", "infoblox", "dyn", "designate", "coredns", "skydns", "inmemory", "pdns", "oci", "exoscale", "linode", "rfc2136") + app.Flag("provider", "The DNS provider where the DNS records will be created (required, options: aws, aws-sd, google, azure, cloudflare, rcodezero, digitalocean, dnsimple, infoblox, dyn, designate, coredns, skydns, inmemory, pdns, oci, exoscale, linode, rfc2136, ns1)").Required().PlaceHolder("provider").EnumVar(&cfg.Provider, "aws", "aws-sd", "google", "azure", "alibabacloud", "cloudflare", "rcodezero", "digitalocean", "dnsimple", "infoblox", "dyn", "designate", "coredns", "skydns", "inmemory", "pdns", "oci", "exoscale", "linode", "rfc2136", "ns1") 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) From b9392049a6f6a5b6ecbc109be5e63886875b54bf Mon Sep 17 00:00:00 2001 From: mburtless Date: Wed, 27 Mar 2019 20:11:03 -0400 Subject: [PATCH 03/12] Fix construction of record name in endpoint --- provider/ns1.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/provider/ns1.go b/provider/ns1.go index 3f20e1247..b608633c2 100644 --- a/provider/ns1.go +++ b/provider/ns1.go @@ -85,9 +85,8 @@ func (p *NS1Provider) Records() ([]*endpoint.Endpoint, error) { for _, record := range zoneData.Records { if supportedRecordType(record.Type) { - name := fmt.Sprintf("%s.%s", record.Domain, zoneData.Zone) endpoints = append(endpoints, endpoint.NewEndpointWithTTL( - name, + record.Domain, record.Type, endpoint.TTL(record.TTL), record.ShortAns..., From 9acbecefad6bf9a6c3c44105b7c0d1d3e9263543 Mon Sep 17 00:00:00 2001 From: mburtless Date: Thu, 28 Mar 2019 16:36:25 -0400 Subject: [PATCH 04/12] Adds comments and fixes filtering --- provider/ns1.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/provider/ns1.go b/provider/ns1.go index b608633c2..6cfcffbb2 100644 --- a/provider/ns1.go +++ b/provider/ns1.go @@ -62,10 +62,6 @@ func newNS1ProviderWithHTTPClient(config NS1Config, client *http.Client) (*NS1Pr return provider, nil } -func (p *NS1Provider) matchEither(id string) bool { - return p.domainFilter.Match(id) || p.zoneIDFilter.Match(id) -} - // Records returns the endpoints this provider knows about func (p *NS1Provider) Records() ([]*endpoint.Endpoint, error) { zones, err := p.zonesFiltered() @@ -99,6 +95,7 @@ func (p *NS1Provider) Records() ([]*endpoint.Endpoint, error) { return endpoints, nil } +// ns1BuildRecord returns a dns.Record for a change set func ns1BuildRecord(zoneName string, change *ns1Change) *dns.Record { record := dns.NewRecord(zoneName, change.Endpoint.DNSName, change.Endpoint.RecordType) for _, v := range change.Endpoint.Targets { @@ -114,6 +111,7 @@ func ns1BuildRecord(zoneName string, change *ns1Change) *dns.Record { return record } +// ns1SubmitChanges takes an array of changes and sends them to NS1 func (p *NS1Provider) ns1SubmitChanges(changes []*ns1Change) error { // return early if there is nothing to change if len(changes) == 0 { @@ -177,16 +175,18 @@ func (p *NS1Provider) zonesFiltered() ([]*dns.Zone, error) { toReturn := []*dns.Zone{} for _, z := range zones { - if !p.matchEither(z.Zone) && !p.matchEither(z.ID) { - continue + if p.domainFilter.Match(z.Zone) && p.zoneIDFilter.Match(z.ID) { + toReturn = append(toReturn, z) + log.Debugf("Matched %s", z.Zone) + } else { + log.Debugf("Filtered %s", z.Zone) } - toReturn = append(toReturn, z) } return toReturn, nil } -// ns1Change differentiates between ChangActions +// ns1Change differentiates between ChangeActions type ns1Change struct { Action string Endpoint *endpoint.Endpoint From 23cca93afe9f06eaa446ddcc517713bc842947c8 Mon Sep 17 00:00:00 2001 From: mburtless Date: Thu, 28 Mar 2019 18:07:59 -0400 Subject: [PATCH 05/12] Wrap ns1 client with interface for easier testing --- provider/ns1.go | 48 +++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 41 insertions(+), 7 deletions(-) diff --git a/provider/ns1.go b/provider/ns1.go index 6cfcffbb2..490ab0326 100644 --- a/provider/ns1.go +++ b/provider/ns1.go @@ -26,6 +26,39 @@ const ( ns1DefaultTTL = 10 ) +// NS1DomainClient interface to ease testing +type NS1DomainClient interface { + CreateRecord(r *dns.Record) (*http.Response, error) + DeleteRecord(zone string, domain string, t string) (*http.Response, error) + UpdateRecord(r *dns.Record) (*http.Response, error) + GetZone(zone string) (*dns.Zone, *http.Response, error) + ListZones() ([]*dns.Zone, *http.Response, error) +} + +type NS1DomainService struct { + service *api.Client +} + +func (n NS1DomainService) CreateRecord(r *dns.Record) (*http.Response, error) { + return n.service.Records.Create(r) +} + +func (n NS1DomainService) DeleteRecord(zone string, domain string, t string) (*http.Response, error) { + return n.service.Records.Delete(zone, domain, t) +} + +func (n NS1DomainService) UpdateRecord(r *dns.Record) (*http.Response, error) { + return n.service.Records.Update(r) +} + +func (n NS1DomainService) GetZone(zone string) (*dns.Zone, *http.Response, error) { + return n.service.Zones.Get(zone) +} + +func (n NS1DomainService) ListZones() ([]*dns.Zone, *http.Response, error) { + return n.service.Zones.List() +} + // NS1Config passes cli args to the NS1Provider type NS1Config struct { DomainFilter DomainFilter @@ -35,7 +68,7 @@ type NS1Config struct { // NS1Provider is the NS1 provider type NS1Provider struct { - client *api.Client + client NS1DomainClient domainFilter DomainFilter zoneIDFilter ZoneIDFilter dryRun bool @@ -55,7 +88,8 @@ func newNS1ProviderWithHTTPClient(config NS1Config, client *http.Client) (*NS1Pr apiClient := api.NewClient(client, api.SetAPIKey(token)) provider := &NS1Provider{ - client: apiClient, + //client: apiClient, + client: NS1DomainService{apiClient}, domainFilter: config.DomainFilter, zoneIDFilter: config.ZoneIDFilter, } @@ -74,7 +108,7 @@ func (p *NS1Provider) Records() ([]*endpoint.Endpoint, error) { for _, zone := range zones { // TODO handle Header Codes - zoneData, _, err := p.client.Zones.Get(zone.String()) + zoneData, _, err := p.client.GetZone(zone.String()) if err != nil { return nil, err } @@ -144,17 +178,17 @@ func (p *NS1Provider) ns1SubmitChanges(changes []*ns1Change) error { switch change.Action { case ns1Create: - _, err := p.client.Records.Create(record) + _, err := p.client.CreateRecord(record) if err != nil { return err } case ns1Delete: - _, err := p.client.Records.Delete(zoneName, record.Domain, record.Type) + _, err := p.client.DeleteRecord(zoneName, record.Domain, record.Type) if err != nil { return err } case ns1Update: - _, err := p.client.Records.Update(record) + _, err := p.client.UpdateRecord(record) if err != nil { return err } @@ -167,7 +201,7 @@ func (p *NS1Provider) ns1SubmitChanges(changes []*ns1Change) error { // Zones returns the list of hosted zones. func (p *NS1Provider) zonesFiltered() ([]*dns.Zone, error) { // TODO handle Header Codes - zones, _, err := p.client.Zones.List() + zones, _, err := p.client.ListZones() if err != nil { return nil, err } From 7b6155c9a662a6c41e864767e4b22a5d28f14358 Mon Sep 17 00:00:00 2001 From: mburtless Date: Mon, 1 Apr 2019 17:14:00 -0400 Subject: [PATCH 06/12] add comments --- provider/ns1.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/provider/ns1.go b/provider/ns1.go index 490ab0326..6f2c37a79 100644 --- a/provider/ns1.go +++ b/provider/ns1.go @@ -26,7 +26,7 @@ const ( ns1DefaultTTL = 10 ) -// NS1DomainClient interface to ease testing +// NS1DomainClient is a subset of the NS1 API the the provider uses, to ease testing type NS1DomainClient interface { CreateRecord(r *dns.Record) (*http.Response, error) DeleteRecord(zone string, domain string, t string) (*http.Response, error) @@ -35,26 +35,32 @@ type NS1DomainClient interface { ListZones() ([]*dns.Zone, *http.Response, error) } +// NS1DomainService wraps the API and fulfills the NS1DomainClient interface type NS1DomainService struct { service *api.Client } +// CreateRecord wraps the Create method of the API's Record service func (n NS1DomainService) CreateRecord(r *dns.Record) (*http.Response, error) { return n.service.Records.Create(r) } +// DeleteRecord wraps the Delete method of the API's Record service func (n NS1DomainService) DeleteRecord(zone string, domain string, t string) (*http.Response, error) { return n.service.Records.Delete(zone, domain, t) } +// UpdateRecord wraps the Update method of the API's Record service func (n NS1DomainService) UpdateRecord(r *dns.Record) (*http.Response, error) { return n.service.Records.Update(r) } +// GetZone wraps the Get method of the API's Zones service func (n NS1DomainService) GetZone(zone string) (*dns.Zone, *http.Response, error) { return n.service.Zones.Get(zone) } +// ListZones wraps the List method of the API's Zones service func (n NS1DomainService) ListZones() ([]*dns.Zone, *http.Response, error) { return n.service.Zones.List() } From 31201344e5b6566df132905cffcd0fe2f370aca8 Mon Sep 17 00:00:00 2001 From: mburtless Date: Mon, 1 Apr 2019 17:14:22 -0400 Subject: [PATCH 07/12] add acceptance tests for ns1 provider --- provider/ns1_test.go | 289 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 289 insertions(+) diff --git a/provider/ns1_test.go b/provider/ns1_test.go index 4f504f668..979de8e45 100644 --- a/provider/ns1_test.go +++ b/provider/ns1_test.go @@ -1 +1,290 @@ package provider + +import ( + "fmt" + "net/http" + "os" + "testing" + + "github.com/kubernetes-incubator/external-dns/endpoint" + "github.com/kubernetes-incubator/external-dns/plan" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + api "gopkg.in/ns1/ns1-go.v2/rest" + "gopkg.in/ns1/ns1-go.v2/rest/model/dns" +) + +type MockNS1DomainClient struct { + mock.Mock +} + +func (m *MockNS1DomainClient) CreateRecord(r *dns.Record) (*http.Response, error) { + return nil, nil +} + +func (m *MockNS1DomainClient) DeleteRecord(zone string, domain string, t string) (*http.Response, error) { + return nil, nil +} + +func (m *MockNS1DomainClient) UpdateRecord(r *dns.Record) (*http.Response, error) { + return nil, nil +} + +func (m *MockNS1DomainClient) GetZone(zone string) (*dns.Zone, *http.Response, error) { + r := &dns.ZoneRecord{ + Domain: "test.foo.com", + ShortAns: []string{"2.2.2.2"}, + TTL: 3600, + Type: "A", + ID: "123456789abcdefghijklmno", + } + z := &dns.Zone{ + Zone: "foo.com", + Records: []*dns.ZoneRecord{r}, + TTL: 3600, + ID: "12345678910111213141516a", + } + + if zone == "foo.com" { + return z, nil, nil + } + return nil, nil, nil +} + +func (m *MockNS1DomainClient) ListZones() ([]*dns.Zone, *http.Response, error) { + zones := []*dns.Zone{ + {Zone: "foo.com", ID: "12345678910111213141516a"}, + {Zone: "bar.com", ID: "12345678910111213141516b"}, + } + return zones, nil, nil +} + +type MockNS1GetZoneFail struct{} + +func (m *MockNS1GetZoneFail) CreateRecord(r *dns.Record) (*http.Response, error) { + return nil, nil +} + +func (m *MockNS1GetZoneFail) DeleteRecord(zone string, domain string, t string) (*http.Response, error) { + return nil, nil +} + +func (m *MockNS1GetZoneFail) UpdateRecord(r *dns.Record) (*http.Response, error) { + return nil, nil +} + +func (m *MockNS1GetZoneFail) GetZone(zone string) (*dns.Zone, *http.Response, error) { + return nil, nil, api.ErrZoneMissing +} + +func (m *MockNS1GetZoneFail) ListZones() ([]*dns.Zone, *http.Response, error) { + zones := []*dns.Zone{ + {Zone: "foo.com", ID: "12345678910111213141516a"}, + {Zone: "bar.com", ID: "12345678910111213141516b"}, + } + return zones, nil, nil +} + +type MockNS1ListZonesFail struct{} + +func (m *MockNS1ListZonesFail) CreateRecord(r *dns.Record) (*http.Response, error) { + return nil, nil +} + +func (m *MockNS1ListZonesFail) DeleteRecord(zone string, domain string, t string) (*http.Response, error) { + return nil, nil +} + +func (m *MockNS1ListZonesFail) UpdateRecord(r *dns.Record) (*http.Response, error) { + return nil, nil +} + +func (m *MockNS1ListZonesFail) GetZone(zone string) (*dns.Zone, *http.Response, error) { + return &dns.Zone{}, nil, nil +} + +func (m *MockNS1ListZonesFail) ListZones() ([]*dns.Zone, *http.Response, error) { + return nil, nil, fmt.Errorf("no zones available") +} + +func TestNS1Records(t *testing.T) { + provider := &NS1Provider{ + client: &MockNS1DomainClient{}, + domainFilter: NewDomainFilter([]string{"foo.com."}), + zoneIDFilter: NewZoneIDFilter([]string{""}), + } + records, err := provider.Records() + require.NoError(t, err) + assert.Equal(t, 1, len(records)) + + provider.client = &MockNS1GetZoneFail{} + _, err = provider.Records() + require.Error(t, err) + + provider.client = &MockNS1ListZonesFail{} + _, err = provider.Records() + require.Error(t, err) +} + +func TestNewNS1Provider(t *testing.T) { + _ = os.Setenv("NS1_APIKEY", "xxxxxxxxxxxxxxxxx") + testNS1Config := NS1Config{ + DomainFilter: NewDomainFilter([]string{"foo.com."}), + ZoneIDFilter: NewZoneIDFilter([]string{""}), + DryRun: false, + } + _, err := NewNS1Provider(testNS1Config) + require.NoError(t, err) + + _ = os.Unsetenv("NS1_APIKEY") + _, err = NewNS1Provider(testNS1Config) + require.Error(t, err) +} + +func TestNS1Zones(t *testing.T) { + provider := &NS1Provider{ + client: &MockNS1DomainClient{}, + domainFilter: NewDomainFilter([]string{"foo.com."}), + zoneIDFilter: NewZoneIDFilter([]string{""}), + } + + zones, err := provider.zonesFiltered() + require.NoError(t, err) + + validateNS1Zones(t, zones, []*dns.Zone{ + {Zone: "foo.com"}, + }) +} + +func validateNS1Zones(t *testing.T, zones []*dns.Zone, expected []*dns.Zone) { + require.Len(t, zones, len(expected)) + + for i, zone := range zones { + assert.Equal(t, expected[i].Zone, zone.Zone) + } +} + +func TestNS1BuildRecord(t *testing.T) { + change := &ns1Change{ + Action: ns1Create, + Endpoint: &endpoint.Endpoint{ + DNSName: "new", + Targets: endpoint.Targets{"target"}, + RecordType: "A", + }, + } + record := ns1BuildRecord("foo.com", change) + assert.Equal(t, "foo.com", record.Zone) + assert.Equal(t, "new.foo.com", record.Domain) + assert.Equal(t, ns1DefaultTTL, record.TTL) + + changeWithTTL := &ns1Change{ + Action: ns1Create, + Endpoint: &endpoint.Endpoint{ + DNSName: "new-b", + Targets: endpoint.Targets{"target"}, + RecordType: "A", + RecordTTL: 100, + }, + } + record = ns1BuildRecord("foo.com", changeWithTTL) + assert.Equal(t, "foo.com", record.Zone) + assert.Equal(t, "new-b.foo.com", record.Domain) + assert.Equal(t, 100, record.TTL) +} + +func TestNS1ApplyChanges(t *testing.T) { + changes := &plan.Changes{} + provider := &NS1Provider{ + client: &MockNS1DomainClient{}, + } + changes.Create = []*endpoint.Endpoint{ + {DNSName: "new.foo.com", Targets: endpoint.Targets{"target"}}, + {DNSName: "new.subdomain.bar.com", Targets: endpoint.Targets{"target"}}, + } + changes.Delete = []*endpoint.Endpoint{{DNSName: "test.foo.com", Targets: endpoint.Targets{"target"}}} + changes.UpdateNew = []*endpoint.Endpoint{{DNSName: "test.foo.com", Targets: endpoint.Targets{"target-new"}}} + err := provider.ApplyChanges(changes) + require.NoError(t, err) + + // empty changes + changes.Create = []*endpoint.Endpoint{} + changes.Delete = []*endpoint.Endpoint{} + changes.UpdateNew = []*endpoint.Endpoint{} + err = provider.ApplyChanges(changes) + require.NoError(t, err) +} + +func TestNewNS1Changes(t *testing.T) { + endpoints := []*endpoint.Endpoint{ + { + DNSName: "testa.foo.com", + Targets: endpoint.Targets{"target-old"}, + RecordType: "A", + }, + { + DNSName: "testba.bar.com", + Targets: endpoint.Targets{"target-new"}, + RecordType: "A", + }, + } + expected := []*ns1Change{ + { + Action: "ns1Create", + Endpoint: endpoints[0], + }, + { + Action: "ns1Create", + Endpoint: endpoints[1], + }, + } + changes := newNS1Changes("ns1Create", endpoints) + require.Len(t, changes, len(expected)) + assert.Equal(t, expected, changes) +} + +func TestNewNS1ChangesByZone(t *testing.T) { + provider := &NS1Provider{ + client: &MockNS1DomainClient{}, + } + zones, _ := provider.zonesFiltered() + changeSets := []*ns1Change{ + { + Action: "ns1Create", + Endpoint: &endpoint.Endpoint{ + DNSName: "new.foo.com", + Targets: endpoint.Targets{"target"}, + RecordType: "A", + }, + }, + { + Action: "ns1Create", + Endpoint: &endpoint.Endpoint{ + DNSName: "unrelated.bar.com", + Targets: endpoint.Targets{"target"}, + RecordType: "A", + }, + }, + { + Action: "ns1Delete", + Endpoint: &endpoint.Endpoint{ + DNSName: "test.foo.com", + Targets: endpoint.Targets{"target"}, + RecordType: "A", + }, + }, + { + Action: "ns1Update", + Endpoint: &endpoint.Endpoint{ + DNSName: "test.foo.com", + Targets: endpoint.Targets{"target-new"}, + RecordType: "A", + }, + }, + } + + changes := ns1ChangesByZone(zones, changeSets) + assert.Len(t, changes["bar.com"], 1) + assert.Len(t, changes["foo.com"], 3) +} From 60666d8757bbe5d983a3a34082f7428ac6e7f1a3 Mon Sep 17 00:00:00 2001 From: mburtless Date: Wed, 3 Apr 2019 15:48:45 -0400 Subject: [PATCH 08/12] Remove comment --- provider/ns1.go | 1 - 1 file changed, 1 deletion(-) diff --git a/provider/ns1.go b/provider/ns1.go index 6f2c37a79..e4a0d0395 100644 --- a/provider/ns1.go +++ b/provider/ns1.go @@ -94,7 +94,6 @@ func newNS1ProviderWithHTTPClient(config NS1Config, client *http.Client) (*NS1Pr apiClient := api.NewClient(client, api.SetAPIKey(token)) provider := &NS1Provider{ - //client: apiClient, client: NS1DomainService{apiClient}, domainFilter: config.DomainFilter, zoneIDFilter: config.ZoneIDFilter, From 3d46e95f65afc5e89589487c1b2d7d70be04b804 Mon Sep 17 00:00:00 2001 From: mburtless Date: Wed, 3 Apr 2019 16:30:40 -0400 Subject: [PATCH 09/12] Add boilerplate to ns1 provider and tests and simplfy code for inititializing provider --- main.go | 6 +++--- provider/ns1.go | 16 ++++++++++++++++ provider/ns1_test.go | 16 ++++++++++++++++ 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/main.go b/main.go index bebb7444c..174e03bba 100644 --- a/main.go +++ b/main.go @@ -204,9 +204,9 @@ func main() { case "ns1": p, err = provider.NewNS1Provider( provider.NS1Config{ - DomainFilter: domainFilter, - ZoneIDFilter: zoneIDFilter, - DryRun: cfg.DryRun, + DomainFilter: domainFilter, + ZoneIDFilter: zoneIDFilter, + DryRun: cfg.DryRun, }, ) default: diff --git a/provider/ns1.go b/provider/ns1.go index e4a0d0395..31341f0d9 100644 --- a/provider/ns1.go +++ b/provider/ns1.go @@ -1,3 +1,19 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + package provider import ( diff --git a/provider/ns1_test.go b/provider/ns1_test.go index 979de8e45..7ff0aa9b5 100644 --- a/provider/ns1_test.go +++ b/provider/ns1_test.go @@ -1,3 +1,19 @@ +/* +Copyright 2017 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + package provider import ( From e4f40b4db896e0664dc816ee9388057ac748f3d9 Mon Sep 17 00:00:00 2001 From: mburtless Date: Mon, 22 Apr 2019 14:14:27 -0400 Subject: [PATCH 10/12] Add ns1-go to go.mod and go.sum --- go.mod | 1 + go.sum | 2 ++ 2 files changed, 3 insertions(+) diff --git a/go.mod b/go.mod index 3ad9578aa..254a49d8f 100644 --- a/go.mod +++ b/go.mod @@ -91,6 +91,7 @@ require ( gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/ini.v1 v1.42.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect + gopkg.in/ns1/ns1-go.v2 v2.0.0-20190322154155-0dafb5275fd1 gopkg.in/yaml.v2 v2.2.2 istio.io/api v0.0.0-20190321180614-db16d82d3672 istio.io/istio v0.0.0-20190322063008-2b1331886076 diff --git a/go.sum b/go.sum index 56c515220..1629195f3 100644 --- a/go.sum +++ b/go.sum @@ -375,6 +375,8 @@ gopkg.in/ini.v1 v1.42.0 h1:7N3gPTt50s8GuLortA00n8AqRTk75qOP98+mTPpgzRk= gopkg.in/ini.v1 v1.42.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/natefinch/lumberjack.v2 v2.0.0 h1:1Lc07Kr7qY4U2YPouBjpCLxpiyxIVoxqXgkXLknAOE8= gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= +gopkg.in/ns1/ns1-go.v2 v2.0.0-20190322154155-0dafb5275fd1 h1:+fgY/3ngqdBW9oLQCMwL5g+QRkKFPJH05fx2/pipqRQ= +gopkg.in/ns1/ns1-go.v2 v2.0.0-20190322154155-0dafb5275fd1/go.mod h1:VV+3haRsgDiVLxyifmMBrBIuCWFBPYKbRssXB9z67Hw= gopkg.in/resty.v1 v1.12.0 h1:CuXP0Pjfw9rOuY6EP+UvtNvt5DSqHpIxILZKT/quCZI= gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= From 46e475b059015ed3d951f77f313686167a2a3a02 Mon Sep 17 00:00:00 2001 From: mburtless Date: Mon, 22 Apr 2019 14:24:45 -0400 Subject: [PATCH 11/12] Update README with references to NS1 provider --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 85471e96c..a369e159a 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ ExternalDNS' current release is `v0.5`. This version allows you to keep selected * [Oracle Cloud Infrastructure DNS](https://docs.cloud.oracle.com/iaas/Content/DNS/Concepts/dnszonemanagement.htm) * [Linode DNS](https://www.linode.com/docs/networking/dns/) * [RFC2136](https://tools.ietf.org/html/rfc2136) +* [NS1](https://ns1.com/) From this release, ExternalDNS can become aware of the records it is managing (enabled via `--registry=txt`), therefore ExternalDNS can safely manage non-empty hosted zones. We strongly encourage you to use `v0.5` (or greater) with `--registry=txt` enabled and `--txt-owner-id` set to a unique value that doesn't change for the lifetime of your cluster. You might also want to run ExternalDNS in a dry run mode (`--dry-run` flag) to see the changes to be submitted to your DNS Provider API. @@ -78,6 +79,7 @@ The following table clarifies the current status of the providers according to t | Oracle Cloud Infrastructure DNS | Alpha | | Linode DNS | Alpha | | RFC2136 | Alpha | +| NS1 | Alpha | ## Running ExternalDNS: @@ -230,6 +232,7 @@ Here's a rough outline on what is to come (subject to change): - [x] Support for PowerDNS - [x] Support for Linode - [x] Support for RcodeZero +- [x] Support for NS1 ### v0.6 From b7c10bb80fd94f530f0f9361a09e11727c03dc68 Mon Sep 17 00:00:00 2001 From: mburtless Date: Tue, 23 Apr 2019 11:27:25 -0400 Subject: [PATCH 12/12] Add tutorial for NS1 and link in README --- README.md | 1 + docs/tutorials/ns1.md | 200 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 docs/tutorials/ns1.md diff --git a/README.md b/README.md index a369e159a..356cd91d1 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,7 @@ The following tutorials are provided: * [Oracle Cloud Infrastructure (OCI) DNS](docs/tutorials/oracle.md) * [Linode](docs/tutorials/linode.md) * [RFC2136](docs/tutorials/rfc2136.md) +* [NS1](docs/tutorials/ns1.md) ### Running Locally diff --git a/docs/tutorials/ns1.md b/docs/tutorials/ns1.md new file mode 100644 index 000000000..607cf8221 --- /dev/null +++ b/docs/tutorials/ns1.md @@ -0,0 +1,200 @@ +# Setting up ExternalDNS for Services on NS1 + +This tutorial describes how to setup ExternalDNS for use within a +Kubernetes cluster using NS1 DNS. + +Make sure to use **>=0.5** version of ExternalDNS for this tutorial. + +## Creating a zone with NS1 DNS + +If you are new to NS1, we recommend you first read the following +instructions for creating a zone. + +[Creating a zone using the NS1 +portal](https://ns1.com/knowledgebase/creating-a-zone) + +[Creating a zone using the NS1 +API](https://ns1.com/api#put-create-a-new-dns-zone) + +## Creating NS1 Credentials + +All NS1 products are API-first, meaning everything that can be done on +the portal---including managing zones and records, data sources and +feeds, and account settings and users---can be done via API. + +The NS1 API is a standard REST API with JSON responses. The environment +var `NS1_APIKEY` will be needed to run ExternalDNS with NS1. + +### To add or delete an API key + +1. Log into the NS1 portal at [my.nsone.net](http://my.nsone.net). + +2. Click your username in the upper-right corner, and navigate to **Account Settings** \> **Users & Teams**. + +3. Navigate to the _API Keys_ tab, and click **Add Key**. + +4. Enter the name of the application and modify permissions and settings as desired. Once complete, click **Create Key**. The new API key appears in the list. + + Note: Set the permissions for your API keys just as you would for a user or team associated with your organization's NS1 account. For more information, refer to the article [Creating and Managing API Keys](https://ns1.com/knowledgebase/creating-and-managing-users) in the NS1 Knowledge Base. + +## Deploy ExternalDNS + +Connect your `kubectl` client to the cluster with which you want to test ExternalDNS, and then apply one of the following manifest files for deployment: + +### Manifest (for clusters without RBAC enabled) + +```yaml +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: external-dns +spec: + strategy: + type: Recreate + template: + metadata: + labels: + app: external-dns + spec: + containers: + - name: external-dns + image: registry.opensource.zalan.do/teapot/external-dns:latest + args: + - --source=service # ingress is also possible + - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above. + - --provider=ns1 + env: + - name: NS1_APIKEY + value: "YOUR_NS1_API_KEY" +``` + +### Manifest (for clusters with RBAC enabled) + +```yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: external-dns +--- +apiVersion: rbac.authorization.k8s.io/v1beta1 +kind: ClusterRole +metadata: + name: external-dns +rules: +- apiGroups: [""] + resources: ["services"] + verbs: ["get","watch","list"] +- apiGroups: [""] + resources: ["pods"] + verbs: ["get","watch","list"] +- apiGroups: ["extensions"] + resources: ["ingresses"] + verbs: ["get","watch","list"] +- apiGroups: [""] + resources: ["nodes"] + verbs: ["list"] +--- +apiVersion: rbac.authorization.k8s.io/v1beta1 +kind: ClusterRoleBinding +metadata: + name: external-dns-viewer +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: external-dns +subjects: +- kind: ServiceAccount + name: external-dns + namespace: default +--- +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: external-dns +spec: + strategy: + type: Recreate + template: + metadata: + labels: + app: external-dns + spec: + serviceAccountName: external-dns + containers: + - name: external-dns + image: registry.opensource.zalan.do/teapot/external-dns:latest + args: + - --source=service # ingress is also possible + - --domain-filter=example.com # (optional) limit to only example.com domains; change to match the zone created above. + - --provider=ns1 + env: + - name: NS1_APIKEY + value: "YOUR_NS1_API_KEY" +``` + +## Deploying an Nginx Service + +Create a service file called 'nginx.yaml' with the following contents: + +```yaml +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: nginx +spec: + template: + metadata: + labels: + app: nginx + spec: + containers: + - image: nginx + name: nginx + ports: + - containerPort: 80 +--- +apiVersion: v1 +kind: Service +metadata: + name: nginx + annotations: + external-dns.alpha.kubernetes.io/hostname: example.com + external-dns.alpha.kubernetes.io/ttl: "120" #optional +spec: + selector: + app: nginx + type: LoadBalancer + ports: + - protocol: TCP + port: 80 + targetPort: 80 +``` + +**A note about annotations** + +Verify that the annotation on the service uses the same hostname as the NS1 DNS zone created above. The annotation may also be a subdomain of the DNS zone (e.g. 'www.example.com'). + +The TTL annotation can be used to configure the TTL on DNS records managed by ExternalDNS and is optional. If this annotation is not set, the TTL on records managed by ExternalDNS will default to 10. + +ExternalDNS uses the hostname annotation to determine which services should be registered with DNS. Removing the hostname annotation will cause ExternalDNS to remove the corresponding DNS records. + +### Create the deployment and service + +``` +$ kubectl create -f nginx.yaml +``` + +Depending on where you run your service, it may take some time for your cloud provider to create an external IP for the service. Once an external IP is assigned, ExternalDNS detects the new service IP address and synchronizes the NS1 DNS records. + +## Verifying NS1 DNS records + +Use the NS1 portal or API to verify that the A record for your domain shows the external IP address of the services. + +## Cleanup + +Once you successfully configure and verify record management via ExternalDNS, you can delete the tutorial's example: + +``` +$ kubectl delete -f nginx.yaml +$ kubectl delete -f externaldns.yaml +```