diff --git a/README.md b/README.md index d2058e41b..d04b212e1 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ ExternalDNS' current release is `v0.5`. This version allows you to keep selected * [AWS Service Discovery](https://docs.aws.amazon.com/Route53/latest/APIReference/overview-service-discovery.html) * [AzureDNS](https://azure.microsoft.com/en-us/services/dns) * [CloudFlare](https://www.cloudflare.com/dns) +* [RcodeZero](https://www.rcodezero.at/) * [DigitalOcean](https://www.digitalocean.com/products/networking) * [DNSimple](https://dnsimple.com/) * [Infoblox](https://www.infoblox.com/products/dns/) @@ -57,6 +58,7 @@ The following tutorials are provided: * [Azure](docs/tutorials/azure.md) * [CoreDNS](docs/tutorials/coredns.md) * [Cloudflare](docs/tutorials/cloudflare.md) +* [RcodeZero](docs/tutorials/rcodezero.md) * [DigitalOcean](docs/tutorials/digitalocean.md) * [Infoblox](docs/tutorials/infoblox.md) * [Dyn](docs/tutorials/dyn.md) diff --git a/docs/tutorials/rcodezero.md b/docs/tutorials/rcodezero.md new file mode 100644 index 000000000..9db91f65d --- /dev/null +++ b/docs/tutorials/rcodezero.md @@ -0,0 +1,194 @@ +# Setting up ExternalDNS for Services on RcodeZero + +This tutorial describes how to setup ExternalDNS for usage within a Kubernetes cluster using RcodeZero Anycast DNS. + +Make sure to use **>=0.5.0** version of ExternalDNS for this tutorial. + +## Creating a RcodeZero DNS zone + +After logging into RcodeZero Dashboard add a master domain under [RcodeZero Add Zone](https://my.rcodezero.at/domain/create). Use it throughout this guide (substitute example.com). + +## Creating RcodeZero Credentials + +> The RcodeZero Anycast-Network is provisioned via web interface or REST-API. + +RcodeZero API can be enabled and a key generated on [RcodeZero API](https://my.rcodezero.at/enableapi) + +The environment var `RC0_API_KEY` will be needed to run ExternalDNS with RcodeZero. + +## Deploy ExternalDNS + +Connect your `kubectl` client to the cluster you want to test ExternalDNS with. +Then apply one of the following manifests file to deploy ExternalDNS. + +### 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=rcodezero + - --rc0-enc-txt # (optional) encrypt TXT records; encryption key has to be provided with RC0_ENC_KEY env var. + env: + - name: RC0_API_KEY + value: "YOUR_RCODEZERO_API_KEY" + - name: RC0_ENC_VAR + value: "YOUR_ENCRYPTION_KEY_STRING" +``` + +### 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=rcodezero + - --rc0-enc-txt # (optional) encrypt TXT records; encryption key has to be provided with RC0_ENC_KEY env var. + env: + - name: RC0_API_KEY + value: "YOUR_RCODEZERO_API_KEY" + - name: RC0_ENC_VAR + value: "YOUR_ENCRYPTION_KEY_STRING" +``` + +## 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 +``` + +Note the annotation on the service; use the same hostname as the RcodeZero DNS zone created above. The annotation may also be a subdomain +of the DNS zone (e.g. 'www.example.com'). + +By setting the TTL annotation on the service, you have to pass a valid TTL, which must be 120 or above. +This annotation is optional, if you won't set it, it will be 1 (automatic) which is 300. + +ExternalDNS uses this annotation to determine what services should be registered with DNS. Removing the annotation +will cause ExternalDNS to remove the corresponding DNS records. + +Create the deployment and service: + +``` +$ kubectl create -f nginx.yaml +``` + +Depending where you run your service it can take a little while for your cloud provider to create an external IP for the service. + +Once the service has an external IP assigned, ExternalDNS will notice the new service IP address and synchronize +the RcodeZero DNS records. + +## Verifying RcodeZero DNS records + +Check your [RcodeZero Configured Zones](https://my.rcodezero.at/domain) and select the ExternalDNS managed domain. + +Substitute the zone for the one created above if a different domain was used. + +This should show the external IP address of the service as the A record for your domain. + +## Cleanup + +Now that we have verified that ExternalDNS will automatically manage RcodeZero DNS records, we can delete the tutorial's example: + +``` +$ kubectl delete -f nginx.yaml +$ kubectl delete -f externaldns.yaml +``` diff --git a/main.go b/main.go index 603a3ad5e..8e00aad6f 100644 --- a/main.go +++ b/main.go @@ -132,7 +132,9 @@ func main() { p, err = provider.NewAzureProvider(cfg.AzureConfigFile, domainFilter, zoneIDFilter, cfg.AzureResourceGroup, cfg.DryRun) case "cloudflare": p, err = provider.NewCloudFlareProvider(domainFilter, zoneIDFilter, cfg.CloudflareZonesPerPage, cfg.CloudflareProxied, cfg.DryRun) - case "google": + case "rcodezero": + p, err = provider.NewRcodeZeroProvider(domainFilter, cfg.DryRun, cfg.RcodezeroTXTEncrypt) + case "google": p, err = provider.NewGoogleProvider(cfg.GoogleProject, domainFilter, zoneIDFilter, cfg.DryRun) case "digitalocean": p, err = provider.NewDigitalOceanProvider(domainFilter, cfg.DryRun) diff --git a/pkg/apis/externaldns/types.go b/pkg/apis/externaldns/types.go index ecbcd480c..0db7b2452 100644 --- a/pkg/apis/externaldns/types.go +++ b/pkg/apis/externaldns/types.go @@ -67,6 +67,7 @@ type Config struct { AzureResourceGroup string CloudflareProxied bool CloudflareZonesPerPage int + RcodezeroTXTEncrypt bool InfobloxGridHost string InfobloxWapiPort int InfobloxWapiUsername string @@ -142,6 +143,7 @@ var defaultConfig = &Config{ AzureResourceGroup: "", CloudflareProxied: false, CloudflareZonesPerPage: 50, + RcodezeroTXTEncrypt: false, InfobloxGridHost: "", InfobloxWapiPort: 443, InfobloxWapiUsername: "admin", @@ -243,7 +245,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, 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", "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)").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("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) @@ -271,6 +273,7 @@ func (cfg *Config) ParseFlags(args []string) error { app.Flag("dyn-password", "When using the Dyn provider, specify the pasword").Default("").StringVar(&cfg.DynPassword) app.Flag("dyn-min-ttl", "Minimal TTL (in seconds) for records. This value will be used if the provided TTL for a service/ingress is lower than this.").IntVar(&cfg.DynMinTTLSeconds) app.Flag("oci-config-file", "When using the OCI provider, specify the OCI configuration file (required when --provider=oci").Default(defaultConfig.OCIConfigFile).StringVar(&cfg.OCIConfigFile) + app.Flag("rc0-txt-enc", "When using the Rcodezero provider with txt registry option, set if TXT rrs are encrypted (default: false)").BoolVar(&cfg.RcodezeroTXTEncrypt) 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) diff --git a/provider/rcode0.go b/provider/rcode0.go new file mode 100644 index 000000000..1b1ff9b63 --- /dev/null +++ b/provider/rcode0.go @@ -0,0 +1,336 @@ +/* +Copyright 2019 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 ( + "fmt" + "github.com/kubernetes-incubator/external-dns/endpoint" + "github.com/kubernetes-incubator/external-dns/plan" + rc0 "github.com/nic-at/rc0go" + log "github.com/sirupsen/logrus" + "net/url" + "os" + "strings" +) + +// RcodeZeroProvider implements the DNS provider for RcodeZero Anycast DNS. +type RcodeZeroProvider struct { + Client *rc0.Client + + DomainFilter DomainFilter + DryRun bool + TXTEncrypt bool + Key []byte +} + +// NewRcodeZeroProvider creates a new RcodeZero Anycast DNS provider. +// +// Returns the provider or an error if a provider could not be created. +func NewRcodeZeroProvider(domainFilter DomainFilter, dryRun bool, txtEnc bool) (*RcodeZeroProvider, error){ + + client, err := rc0.NewClient(os.Getenv("RC0_API_KEY")) + + if err != nil { + return nil, err + } + + value := os.Getenv("RC0_BASE_URL") + if len(value) != 0 { + client.BaseURL, _ = url.Parse(os.Getenv("RC0_BASE_URL")) + } + + if err != nil { + return nil, fmt.Errorf("failed to initialize rcodezero provider: %v", err) + } + + provider := &RcodeZeroProvider{ + Client: client, + DomainFilter: domainFilter, + DryRun: dryRun, + TXTEncrypt: txtEnc, + } + + if txtEnc { + provider.Key = []byte(os.Getenv("RC0_ENC_KEY")) + } + + return provider, nil +} + +// Returns filtered zones if filter is set +func (p *RcodeZeroProvider) Zones() ([]*rc0.Zone, error) { + + var result []*rc0.Zone + + zones, err := p.fetchZones() + if err != nil { + return nil, err + } + + for _, zone := range zones { + if p.DomainFilter.Match(zone.Domain) { + result = append(result, zone) + } + } + + return result, nil +} + +// Returns resource records +// +// Decrypts TXT records if TXT-Encrypt flag is set and key is provided +func (p *RcodeZeroProvider) Records() ([]*endpoint.Endpoint, error) { + + zones, err := p.Zones() + if err != nil { + return nil, err + } + + var endpoints []*endpoint.Endpoint + + for _, zone := range zones { + + rrset, err := p.fetchRecords(zone.Domain) + + if err != nil { + return nil, err + } + + for _, r := range rrset { + + if supportedRecordType(r.Type) { + + if p.TXTEncrypt && (p.Key != nil) && strings.EqualFold(r.Type, "TXT") { + p.Client.RRSet.DecryptTXT(p.Key, r) + } + + if len(r.Records) > 1 { + + for _, _r := range r.Records { + if !_r.Disabled { + endpoints = append(endpoints, endpoint.NewEndpointWithTTL(r.Name, r.Type, endpoint.TTL(r.TTL), _r.Content)) + } + } + + } else { + if !r.Records[0].Disabled { + endpoints = append(endpoints, endpoint.NewEndpointWithTTL(r.Name, r.Type, endpoint.TTL(r.TTL), r.Records[0].Content)) + } + } + + } + } + } + + return endpoints, nil +} + +// ApplyChanges applies a given set of changes in a given zone. +func (p *RcodeZeroProvider) ApplyChanges(changes *plan.Changes) error { + + combinedChanges := make([]*rc0.RRSetChange, 0, len(changes.Create)+len(changes.UpdateNew)+len(changes.Delete)) + + combinedChanges = append(combinedChanges, p.NewRcodezeroChanges(rc0.ChangeTypeADD, changes.Create)...) + combinedChanges = append(combinedChanges, p.NewRcodezeroChanges(rc0.ChangeTypeUPDATE, changes.UpdateNew)...) + combinedChanges = append(combinedChanges, p.NewRcodezeroChanges(rc0.ChangeTypeDELETE, changes.Delete)...) + + return p.submitChanges(combinedChanges) +} + +// Helper function +func rcodezeroChangesByZone(zones []*rc0.Zone, changeSet []*rc0.RRSetChange) map[string][]*rc0.RRSetChange { + + changes := make(map[string][]*rc0.RRSetChange) + zoneNameIDMapper := zoneIDName{} + for _, z := range zones { + zoneNameIDMapper.Add(z.Domain, z.Domain) + changes[z.Domain] = []*rc0.RRSetChange{} + } + + for _, c := range changeSet { + zone, _ := zoneNameIDMapper.FindZone(c.Name) + if zone == "" { + log.Debugf("Skipping record %s because no hosted zone matching record DNS Name was detected ", c.Name) + continue + } + changes[zone] = append(changes[zone], c) + } + + return changes +} + +// Helper function +func (p *RcodeZeroProvider) fetchRecords(zoneName string) ([]*rc0.RRType, error) { + + var allRecords []*rc0.RRType + + listOptions := rc0.NewListOptions() + + for { + records, page, err := p.Client.RRSet.List(zoneName, listOptions) + + if err != nil { + return nil, err + } + + allRecords = append(allRecords, records...) + + if page == nil || (page.CurrentPage == page.LastPage) { + break + } + + listOptions.SetPageNumber(page.CurrentPage + 1) + } + + return allRecords, nil +} + +// Helper function +func (p *RcodeZeroProvider) fetchZones() ([]*rc0.Zone, error) { + + var allZones []*rc0.Zone + + listOptions := rc0.NewListOptions() + + for { + zones, page, err := p.Client.Zones.List(listOptions) + if err != nil { + return nil, err + } + allZones = append(allZones, zones...) + + if page == nil || page.IsLastPage() { + break + } + + listOptions.SetPageNumber(page.CurrentPage + 1) + } + + return allZones, nil +} + +// Helper function to submit changes. +// +// Changes are submitted by change type. +func (p *RcodeZeroProvider) submitChanges(changes []*rc0.RRSetChange) error { + + if len(changes) == 0 { + return nil + } + + zones, err := p.Zones() + if err != nil { + return err + } + + // separate into per-zone change sets to be passed to the API. + changesByZone := rcodezeroChangesByZone(zones, changes) + + for zoneName, changes := range changesByZone { + + for _, change := range changes { + + logFields := log.Fields{ + "record" : change.Name, + "content" : change.Records[0].Content, + "type" : change.Type, + "action" : change.ChangeType, + "zone" : zoneName, + } + + log.WithFields(logFields).Info("Changing record.") + + if p.DryRun { + continue + } + + // to avoid accidentally adding extra dot if already present + change.Name = strings.TrimSuffix(change.Name, ".") + "." + + switch change.ChangeType { + case rc0.ChangeTypeADD: + sr, err := p.Client.RRSet.Create(zoneName, []*rc0.RRSetChange{change}) + + if err != nil { + return err + } + + if sr.HasError() { + return fmt.Errorf("adding new RR resulted in an error: %v", sr.Message) + } + + case rc0.ChangeTypeUPDATE: + sr, err := p.Client.RRSet.Edit(zoneName, []*rc0.RRSetChange{change}) + + if err != nil { + return err + } + + if sr.HasError() { + return fmt.Errorf("updating existing RR resulted in an error: %v", sr.Message) + } + + case rc0.ChangeTypeDELETE: + sr, err := p.Client.RRSet.Delete(zoneName, []*rc0.RRSetChange{change}) + + if err != nil { + return err + } + + if sr.HasError() { + return fmt.Errorf("deleting existing RR resulted in an error: %v", sr.Message) + } + + default: + return fmt.Errorf("unsupported changeType submitted: %v", change.ChangeType) + } + } + } + return nil +} + +// Returns a RcodeZero specific array with rrset change objects. +func (p *RcodeZeroProvider) NewRcodezeroChanges(action string, endpoints []*endpoint.Endpoint) []*rc0.RRSetChange { + + changes := make([]*rc0.RRSetChange, 0, len(endpoints)) + + for _, _endpoint := range endpoints { + changes = append(changes, p.NewRcodezeroChange(action, _endpoint)) + } + + return changes +} + +// Returns a RcodeZero specific rrset change object. +func (p *RcodeZeroProvider) NewRcodezeroChange(action string, endpoint *endpoint.Endpoint) *rc0.RRSetChange { + + change := &rc0.RRSetChange{ + Type: endpoint.RecordType, + ChangeType: action, + Name: endpoint.DNSName, + Records: []*rc0.Record{{ + Disabled: false, + Content: endpoint.Targets[0], + }}, + } + + if p.TXTEncrypt && (p.Key != nil) && strings.EqualFold(endpoint.RecordType, "TXT") { + p.Client.RRSet.EncryptTXT(p.Key, change) + } + + return change +} \ No newline at end of file diff --git a/provider/rcode0_test.go b/provider/rcode0_test.go new file mode 100644 index 000000000..d848ad006 --- /dev/null +++ b/provider/rcode0_test.go @@ -0,0 +1,417 @@ +/* +Copyright 2019 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 ( + "fmt" + "github.com/kubernetes-incubator/external-dns/endpoint" + "github.com/kubernetes-incubator/external-dns/plan" + rc0 "github.com/nic-at/rc0go" + "github.com/stretchr/testify/require" + "os" + "testing" +) + +const ( + testZoneOne = "testzone1.at" + testZoneTwo = "testzone2.at" + + rrsetChangesUnsupportedChangeType = 0 + +) + +type mockRcodeZeroClient rc0.Client + +type mockZoneManagementService struct { + TestNilZonesReturned bool + TestErrorReturned bool +} + +type mockRRSetService struct { + TestErrorReturned bool +} + +func (m *mockRcodeZeroClient) resetMockServices() { + m.Zones = &mockZoneManagementService{} + m.RRSet = &mockRRSetService{} +} + +func (m *mockZoneManagementService) resetTestConditions() { + m.TestNilZonesReturned = false + m.TestErrorReturned = false +} + +func (m *mockRRSetService) resetTestConditions() { + m.TestErrorReturned = false +} + +func TestRcodeZeroProvider_Records(t *testing.T) { + + mockRRSetService := &mockRRSetService{} + mockZoneManagementService := &mockZoneManagementService{} + + provider := &RcodeZeroProvider{ + Client: (*rc0.Client)(&mockRcodeZeroClient{ + Zones: mockZoneManagementService, + RRSet: mockRRSetService, + }), + } + + endpoints, err := provider.Records() // should return 6 rrs + + if err != nil { + t.Errorf("should not fail, %s", err) + } + require.Equal(t, 6, len(endpoints)) + + mockRRSetService.TestErrorReturned = true + + _, err = provider.Records() + if err == nil { + t.Errorf("expected to fail, %s", err) + } + +} + +func TestRcodeZeroProvider_ApplyChanges(t *testing.T) { + + mockRRSetService := &mockRRSetService{} + mockZoneManagementService := &mockZoneManagementService{} + + provider := &RcodeZeroProvider{ + Client: (*rc0.Client)(&mockRcodeZeroClient{ + Zones: mockZoneManagementService, + RRSet: mockRRSetService, + }), + DomainFilter: NewDomainFilter([]string{testZoneOne}), + } + + changes := mockChanges() + + err := provider.ApplyChanges(changes) + + if err != nil { + t.Errorf("should not fail, %s", err) + } + +} + +func TestRcodeZeroProvider_NewRcodezeroChanges(t *testing.T) { + + provider := &RcodeZeroProvider{} + + changes := mockChanges() + + createChanges := provider.NewRcodezeroChanges(testZoneOne, changes.Create) + require.Equal(t, 4, len(createChanges)) + + deleteChanges := provider.NewRcodezeroChanges(testZoneOne, changes.Delete) + require.Equal(t, 1, len(deleteChanges)) + + updateOldChanges := provider.NewRcodezeroChanges(testZoneOne, changes.UpdateOld) + require.Equal(t, 1, len(updateOldChanges)) + + updateNewChanges := provider.NewRcodezeroChanges(testZoneOne, changes.UpdateNew) + require.Equal(t, 1, len(updateNewChanges)) +} + +func TestRcodeZeroProvider_NewRcodezeroChange(t *testing.T) { + + _endpoint := &endpoint.Endpoint{ + RecordType: "A", + DNSName: "app." + testZoneOne, + RecordTTL: 300, + Targets: endpoint.Targets{"target"}, + } + + provider := &RcodeZeroProvider{} + + rrsetChange := provider.NewRcodezeroChange(testZoneOne, _endpoint) + + require.Equal(t, _endpoint.RecordType, rrsetChange.Type) + require.Equal(t, _endpoint.DNSName, rrsetChange.Name) + require.Equal(t, _endpoint.Targets[0], rrsetChange.Records[0].Content) + //require.Equal(t, endpoint.RecordTTL, rrsetChange.TTL) + +} + +func Test_submitChanges(t *testing.T) { + + mockRRSetService := &mockRRSetService{} + mockZoneManagementService := &mockZoneManagementService{} + + provider := &RcodeZeroProvider{ + Client: (*rc0.Client)(&mockRcodeZeroClient{ + Zones: mockZoneManagementService, + RRSet: mockRRSetService, + }), + DomainFilter: NewDomainFilter([]string{testZoneOne}), + } + + changes := mockRRSetChanges(rrsetChangesUnsupportedChangeType) + + err := provider.submitChanges(changes) + + if err == nil { + t.Errorf("expected to fail, %s", err) + } + +} + +func mockRRSetChanges(condition int) []*rc0.RRSetChange { + + switch condition { + case rrsetChangesUnsupportedChangeType: + return []*rc0.RRSetChange{ + { + Name: testZoneOne, + Type: "A", + ChangeType: "UNSUPPORTED", + Records: []*rc0.Record{{Content:"fail"}}, + + }, + } + default: + return nil + } +} + +func mockChanges() *plan.Changes { + + changes := &plan.Changes{} + + changes.Create = []*endpoint.Endpoint{ + {DNSName: "new.ext-dns-test."+testZoneOne, Targets: endpoint.Targets{"target"}, RecordType: "A"}, + {DNSName: "new.ext-dns-test-with-ttl."+testZoneOne, Targets: endpoint.Targets{"target"}, RecordType: "A", RecordTTL: 100}, + {DNSName: "new.ext-dns-test.unexpected.com", Targets: endpoint.Targets{"target"}, RecordType: "AAAA"}, + {DNSName: testZoneOne, Targets: endpoint.Targets{"target"}, RecordType: "CNAME"}, + } + changes.Delete = []*endpoint.Endpoint{{DNSName: "foobar.ext-dns-test."+testZoneOne, Targets: endpoint.Targets{"target"}}} + changes.UpdateOld = []*endpoint.Endpoint{{DNSName: "foobar.ext-dns-test."+testZoneOne, Targets: endpoint.Targets{"target-old"}}} + changes.UpdateNew = []*endpoint.Endpoint{{DNSName: "foobar.ext-dns-test."+testZoneOne, Targets: endpoint.Targets{"target-new"}, RecordType: "CNAME", RecordTTL: 100}} + + return changes +} + +func TestRcodeZeroProvider_Zones(t *testing.T) { + + mockRRSetService := &mockRRSetService{} + mockZoneManagementService := &mockZoneManagementService{} + + provider := &RcodeZeroProvider{ + Client: (*rc0.Client)(&mockRcodeZeroClient{ + Zones: mockZoneManagementService, + RRSet: mockRRSetService, + }), + } + + mockZoneManagementService.TestNilZonesReturned = true + + zones, err := provider.Zones() + + if err != nil { + t.Fatal(err) + } + require.Equal(t, 0, len(zones)) + mockZoneManagementService.resetTestConditions() + + mockZoneManagementService.TestErrorReturned = true + + zones, err = provider.Zones() + if err == nil { + t.Errorf("expected to fail, %s", err) + } + +} + +func TestNewRcodeZeroProvider(t *testing.T) { + + _ = os.Setenv("RC0_API_KEY", "123") + p, err := NewRcodeZeroProvider(NewDomainFilter([]string{"ext-dns-test."+testZoneOne+"."}), true, true) + + if err != nil { + t.Errorf("should not fail, %s", err) + } + + require.Equal(t, true, p.DryRun) + require.Equal(t, true, p.TXTEncrypt) + require.Equal(t, true, p.DomainFilter.IsConfigured()) + require.Equal(t, false, p.DomainFilter.Match("ext-dns-test."+testZoneTwo+".")) // filter is set, so it should match only provided domains + + p, err = NewRcodeZeroProvider(DomainFilter{}, false, false) + + if err != nil { + t.Errorf("should not fail, %s", err) + } + + require.Equal(t, false, p.DryRun) + require.Equal(t, false, p.DomainFilter.IsConfigured()) + require.Equal(t, true, p.DomainFilter.Match("ext-dns-test."+testZoneOne+".")) // filter is not set, so it should match any + + _ = os.Unsetenv("RC0_API_KEY") + _, err = NewRcodeZeroProvider(DomainFilter{}, false, false) + + if err == nil { + t.Errorf("expected to fail") + } + +} + +/* mocking mockRRSetServiceInterface */ + +func (m *mockRRSetService) List(zone string, options *rc0.ListOptions) ([]*rc0.RRType, *rc0.Page, error) { + + if m.TestErrorReturned { + return nil, nil, fmt.Errorf("operation RRSet.List failed") + } + + return mockRRSet(zone), nil, nil +} + +func mockRRSet(zone string) []*rc0.RRType { + return []*rc0.RRType{ + { + Name: "app."+zone+".", + Type: "TXT", + TTL: 300, + Records: []*rc0.Record{ + { + Content: "\"heritage=external-dns,external-dns/owner=default,external-dns/resource=ingress/default/app\"", + Disabled: false, + }, + }, + }, + { + Name: "app."+zone+".", + Type: "A", + TTL: 300, + Records: []*rc0.Record{ + { + Content: "127.0.0.1", + Disabled: false, + }, + }, + }, + { + Name: "www."+zone+".", + Type: "A", + TTL: 300, + Records: []*rc0.Record{ + { + Content: "127.0.0.1", + Disabled: false, + }, + }, + }, + { + Name: zone+".", + Type: "SOA", + TTL: 3600, + Records: []*rc0.Record{ + { + Content: "sec1.rcode0.net. rcodezero-soa.ipcom.at. 2019011616 10800 3600 604800 3600", + Disabled: false, + }, + }, + }, + { + Name: zone+".", + Type: "NS", + TTL: 3600, + Records: []*rc0.Record{ + { + Content: "sec2.rcode0.net.", + Disabled: false, + }, + { + Content: "sec1.rcode0.net.", + Disabled: false, + }, + }, + }, + } +} + +func (m *mockRRSetService) Create(zone string, rrsetCreate []*rc0.RRSetChange) (*rc0.StatusResponse, error) { + + return &rc0.StatusResponse{Status: "ok", Message: "pass"}, nil + +} +func (m *mockRRSetService) Edit(zone string, rrsetEdit []*rc0.RRSetChange) (*rc0.StatusResponse, error) { + + return &rc0.StatusResponse{Status: "ok", Message: "pass"}, nil +} +func (m *mockRRSetService) Delete(zone string, rrsetDelete []*rc0.RRSetChange) (*rc0.StatusResponse, error) { + + return &rc0.StatusResponse{Status: "ok", Message: "pass"}, nil +} +func (m *mockRRSetService) SubmitChangeSet(zone string, changeSet []*rc0.RRSetChange) (*rc0.StatusResponse, error) { + + return &rc0.StatusResponse{Status: "ok", Message: "pass"}, nil +} + +func (m *mockRRSetService) EncryptTXT(key []byte, rrType *rc0.RRSetChange) {} + +func (m *mockRRSetService) DecryptTXT(key []byte, rrType *rc0.RRType) {} + +/* mocking ZoneManagementServiceInterface */ + +func (m *mockZoneManagementService) List(options *rc0.ListOptions) ([]*rc0.Zone, *rc0.Page, error) { + + if m.TestNilZonesReturned { + return nil, nil, nil + } + + if m.TestErrorReturned { + return nil, nil, fmt.Errorf("operation Zone.List failed") + } + + zones := []*rc0.Zone{ + { + Domain: testZoneOne, + Type: "SLAVE", + // "dnssec": "yes", @todo: add this + // "created": "2018-04-09T09:27:31Z", @todo: add this + LastCheck: "", + Serial: 20180411, + Masters: []string{ + "193.0.2.2", + "2001:db8::2", + }, + }, + { + Domain: testZoneTwo, + Type: "MASTER", + // "dnssec": "no", @todo: add this + // "created": "2019-01-15T13:20:10Z", @todo: add this + LastCheck: "", + Serial: 2019011616, + Masters: []string{ + "", + }, + }, + } + + return zones, nil, nil +} + +func (m *mockZoneManagementService) Get(zone string) (*rc0.Zone, error) { return nil, nil } +func (m *mockZoneManagementService) Create(zoneCreate *rc0.ZoneCreate) (*rc0.StatusResponse, error) { return nil, nil } +func (m *mockZoneManagementService) Edit(zone string, zoneEdit *rc0.ZoneEdit) (*rc0.StatusResponse, error) { return nil, nil } +func (m *mockZoneManagementService) Delete(zone string) (*rc0.StatusResponse, error) { return nil, nil } +func (m *mockZoneManagementService) Transfer(zone string) (*rc0.StatusResponse, error) { return nil ,nil } +