From 88da61e742ff6d34a70147be667c0dc6adaa7f7b Mon Sep 17 00:00:00 2001 From: Andrew Pryde Date: Tue, 17 Apr 2018 14:51:44 +0100 Subject: [PATCH 1/2] Implement Oracle Cloud Infrastructure DNS provider --- Gopkg.lock | 24 +- Gopkg.toml | 6 +- README.md | 1 + docs/tutorials/oracle.md | 153 ++++++ main.go | 2 + pkg/apis/externaldns/types.go | 5 +- pkg/apis/externaldns/types_test.go | 2 + provider/oci.go | 298 +++++++++++ provider/oci_test.go | 765 +++++++++++++++++++++++++++++ 9 files changed, 1248 insertions(+), 8 deletions(-) create mode 100644 docs/tutorials/oracle.md create mode 100644 provider/oci.go create mode 100644 provider/oci_test.go diff --git a/Gopkg.lock b/Gopkg.lock index 9276b163b..0d8e69189 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -149,7 +149,8 @@ [[projects]] name = "github.com/davecgh/go-spew" packages = ["spew"] - revision = "5215b55f46b2b919f50a1df0eaa5886afe4e3b3d" + revision = "346938d642f2ec3594ed81d874461961cd0faa76" + version = "v1.1.0" [[projects]] name = "github.com/dgrijalva/jwt-go" @@ -334,6 +335,15 @@ revision = "cdd946344b54bdf7dbeac406c2f1fe93150f08ea" version = "v0.6.0" +[[projects]] + name = "github.com/oracle/oci-go-sdk" + packages = [ + "common", + "dns" + ] + revision = "a2ded717dc4bb4916c0416ec79f81718b576dbc4" + version = "v1.8.0" + [[projects]] name = "github.com/pkg/errors" packages = ["."] @@ -342,7 +352,8 @@ [[projects]] name = "github.com/pmezard/go-difflib" packages = ["difflib"] - revision = "d8ed2627bdf02c080bf22230dbb337003b7aba2d" + revision = "792786c7400a136282c1664665ae0a8db921c6c2" + version = "v1.0.0" [[projects]] name = "github.com/prometheus/client_golang" @@ -391,7 +402,8 @@ [[projects]] name = "github.com/stretchr/objx" packages = ["."] - revision = "cbeaeb16a013161a98496fad62933b1d21786672" + revision = "facf9a85c22f48d2f52f2380e4efce1768749a89" + version = "v0.1" [[projects]] name = "github.com/stretchr/testify" @@ -401,8 +413,8 @@ "require", "suite" ] - revision = "69483b4bd14f5845b5a1e55bca19e954e827f1d0" - version = "v1.1.4" + revision = "12b6f73e6084dad08a7c6e575284b177ecafbc71" + version = "v1.2.1" [[projects]] branch = "master" @@ -667,6 +679,6 @@ [solve-meta] analyzer-name = "dep" analyzer-version = 1 - inputs-digest = "84dd4d46f1682b174b47c24afd13104daa26c16da187d07513cb9a58bb3f4820" + inputs-digest = "66dc2d3612a3cea92d6533aef837db593aa9b49b2eeffe724d7211ceba87294b" solver-name = "gps-cdcl" solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml index fadbc3ae4..1e317c202 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -54,7 +54,7 @@ ignored = ["github.com/kubernetes/repo-infra/kazel"] [[constraint]] name = "github.com/stretchr/testify" - version = "~1.1.4" + version = "~1.2.1" [[constraint]] name = "k8s.io/client-go" @@ -67,3 +67,7 @@ ignored = ["github.com/kubernetes/repo-infra/kazel"] [[constraint]] name = "github.com/nesv/go-dynect" version = "0.6.0" + +[[constraint]] + name = "github.com/oracle/oci-go-sdk" + version = "1.8.0" diff --git a/README.md b/README.md index ac810f12a..1c75e5ce1 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ ExternalDNS' current release is `v0.5`. This version allows you to keep selected * [OpenStack Designate](https://docs.openstack.org/designate/latest/) * [PowerDNS](https://www.powerdns.com/) * [CoreDNS](https://coredns.io/) +* [Oracle Cloud Infrastructure DNS](https://docs.cloud.oracle.com/iaas/Content/DNS/Concepts/dnszonemanagement.htm) 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. diff --git a/docs/tutorials/oracle.md b/docs/tutorials/oracle.md new file mode 100644 index 000000000..9c4a87583 --- /dev/null +++ b/docs/tutorials/oracle.md @@ -0,0 +1,153 @@ +# Setting up ExternalDNS for Oracle Cloud Infrastructure (OCI) + +This tutorial describes how to setup ExternalDNS for usage within a Kubernetes cluster using OCI DNS. + +Make sure to use the latest version of ExternalDNS for this tutorial. + +## Creating an OCI DNS Zone + +Create a DNS zone which will contain the managed DNS records. Let's use `example.com` as an reference here. + +For more information about OCI DNS see the documentation [here][1]. + +## Deploy ExternalDNS + +Connect your `kubectl` client to the cluster you want to test ExternalDNS with. +We first need to create a config file containing the information needed to connect with the OCI API. + +Create a new file (oci.yaml) and modify the contents to match the example below. Be sure to adjust the values to match your own credentials: + +```yaml +auth: + region: us-phoenix-1 + tenancy: ocid1.tenancy.oc1... + user: ocid1.user.oc1... + -----BEGIN RSA PRIVATE KEY----- + -----END RSA PRIVATE KEY----- + fingerprint: af:81:71:8e... +compartment: ocid1.compartment.oc1... +``` + +Create a secret using the config file above: + +```shell +$ kubectl create secret generic external-dns-config --from-file=oci.yaml +``` + +### Manifest (for clusters with RBAC enabled) + +Apply the following manifest to deploy ExternalDNS. + +```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"] +--- +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 + - --source=ingress + - --provider=oci + - --policy=upsert-only # prevent ExternalDNSfrom deleting any records, omit to enable full synchronization + - --registry=txt + - --txt-owner-id=my-identifier + volumeMounts: + - name: config + mountPath: /etc/kubernetes/ + volumes: + - name: config + secret: + secretName: external-dns-config +``` + +## Verify ExternalDNS works (Service example) + +Create the following sample application to test that ExternalDNS works. + +> For services ExternalDNS will look for the annotation `external-dns.alpha.kubernetes.io/hostname` on the service and use the corresponding value. + +```yaml +apiVersion: v1 +kind: Service +metadata: + name: nginx + annotations: + external-dns.alpha.kubernetes.io/hostname: example.com +spec: + type: LoadBalancer + ports: + - port: 80 + name: http + targetPort: 80 + selector: + app: nginx +--- +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: nginx +spec: + template: + metadata: + labels: + app: nginx + spec: + containers: + - image: nginx + name: nginx + ports: + - containerPort: 80 + name: http +``` + +Apply the manifest above and wait roughly two minutes and check that a corresponding DNS record for your service was created. + +``` +$ kubectl apply -f nginx.yaml +``` + +[1]: https://docs.cloud.oracle.com/iaas/Content/DNS/Concepts/dnszonemanagement.htm diff --git a/main.go b/main.go index 95cd7f730..ad622d330 100644 --- a/main.go +++ b/main.go @@ -170,6 +170,8 @@ func main() { }, }, ) + case "oci": + p, err = provider.NewOCIProvider(cfg.OCIConfigFile, domainFilter, zoneIDFilter, cfg.DryRun) default: log.Fatalf("unknown dns provider: %s", cfg.Provider) } diff --git a/pkg/apis/externaldns/types.go b/pkg/apis/externaldns/types.go index 796798ba5..c43ffc229 100644 --- a/pkg/apis/externaldns/types.go +++ b/pkg/apis/externaldns/types.go @@ -67,6 +67,7 @@ type Config struct { DynUsername string DynPassword string DynMinTTLSeconds int + OCIConfigFile string InMemoryZones []string PDNSServer string PDNSAPIKey string @@ -114,6 +115,7 @@ var defaultConfig = &Config{ InfobloxWapiPassword: "", InfobloxWapiVersion: "2.3.1", InfobloxSSLVerify: true, + OCIConfigFile: "/etc/kubernetes/oci.yaml", InMemoryZones: []string{}, PDNSServer: "http://localhost:8081", PDNSAPIKey: "", @@ -185,7 +187,7 @@ func (cfg *Config) ParseFlags(args []string) error { app.Flag("connector-source-server", "The server to connect for connector source, valid only when using connector source").Default(defaultConfig.ConnectorSourceServer).StringVar(&cfg.ConnectorSourceServer) // 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)").Required().PlaceHolder("provider").EnumVar(&cfg.Provider, "aws", "aws-sd", "google", "azure", "cloudflare", "digitalocean", "dnsimple", "infoblox", "dyn", "designate", "coredns", "skydns", "inmemory", "pdns") + 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)").Required().PlaceHolder("provider").EnumVar(&cfg.Provider, "aws", "aws-sd", "google", "azure", "cloudflare", "digitalocean", "dnsimple", "infoblox", "dyn", "designate", "coredns", "skydns", "inmemory", "pdns", "oci") 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) @@ -206,6 +208,7 @@ func (cfg *Config) ParseFlags(args []string) error { app.Flag("dyn-username", "When using the Dyn provider, specify the Username").Default("").StringVar(&cfg.DynUsername) 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("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/pkg/apis/externaldns/types_test.go b/pkg/apis/externaldns/types_test.go index 2d749d5fb..f91201e7c 100644 --- a/pkg/apis/externaldns/types_test.go +++ b/pkg/apis/externaldns/types_test.go @@ -52,6 +52,7 @@ var ( InfobloxWapiPassword: "", InfobloxWapiVersion: "2.3.1", InfobloxSSLVerify: true, + OCIConfigFile: "/etc/kubernetes/oci.yaml", InMemoryZones: []string{""}, PDNSServer: "http://localhost:8081", PDNSAPIKey: "", @@ -93,6 +94,7 @@ var ( InfobloxWapiPassword: "infoblox", InfobloxWapiVersion: "2.6.1", InfobloxSSLVerify: false, + OCIConfigFile: "/etc/kubernetes/oci.yaml", InMemoryZones: []string{"example.org", "company.com"}, PDNSServer: "http://ns.example.com:8081", PDNSAPIKey: "some-secret-key", diff --git a/provider/oci.go b/provider/oci.go new file mode 100644 index 000000000..e40e891b6 --- /dev/null +++ b/provider/oci.go @@ -0,0 +1,298 @@ +/* +Copyright 2018 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 ( + "context" + "io/ioutil" + "strings" + + "github.com/oracle/oci-go-sdk/common" + "github.com/oracle/oci-go-sdk/dns" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + yaml "gopkg.in/yaml.v2" + + "github.com/kubernetes-incubator/external-dns/endpoint" + "github.com/kubernetes-incubator/external-dns/plan" +) + +const ociRecordTTL = 300 + +// OCIAuthConfig holds connection parameters for the OCI API. +type OCIAuthConfig struct { + Region string `yaml:"region"` + TenancyID string `yaml:"tenancy"` + UserID string `yaml:"user"` + PrivateKey string `yaml:"key"` + Fingerprint string `yaml:"fingerprint"` + Passphrase string `yaml:"passphrase"` +} + +// OCIConfig holds the configuration for the OCI Provider. +type OCIConfig struct { + Auth OCIAuthConfig `yaml:"auth"` + CompartmentID string `yaml:"compartment"` +} + +// OCIProvider is an implementation of Provider for Oracle Cloud Infrastructure +// (OCI) DNS. +type OCIProvider struct { + client ociDNSClient + cfg OCIConfig + + domainFilter DomainFilter + zoneIDFilter ZoneIDFilter + dryRun bool +} + +// ociDNSClient is the subset of the OCI DNS API required by the OCI Provider. +type ociDNSClient interface { + ListZones(ctx context.Context, request dns.ListZonesRequest) (response dns.ListZonesResponse, err error) + GetZoneRecords(ctx context.Context, request dns.GetZoneRecordsRequest) (response dns.GetZoneRecordsResponse, err error) + PatchZoneRecords(ctx context.Context, request dns.PatchZoneRecordsRequest) (response dns.PatchZoneRecordsResponse, err error) +} + +// NewOCIProvider initialises a new OCI DNS based Provider. +func NewOCIProvider(configFile string, domainFilter DomainFilter, zoneIDFilter ZoneIDFilter, dryRun bool) (*OCIProvider, error) { + contents, err := ioutil.ReadFile(configFile) + if err != nil { + return nil, errors.Wrapf(err, "failed to read OCI config file %q", configFile) + } + cfg := OCIConfig{} + err = yaml.Unmarshal(contents, &cfg) + if err != nil { + return nil, errors.Wrapf(err, "failed to read OCI config file %q", configFile) + } + // TODO(apryde): validate config. + + var client ociDNSClient + client, err = dns.NewDnsClientWithConfigurationProvider(common.NewRawConfigurationProvider( + cfg.Auth.TenancyID, + cfg.Auth.UserID, + cfg.Auth.Region, + cfg.Auth.Fingerprint, + cfg.Auth.PrivateKey, + &cfg.Auth.Passphrase, + )) + if err != nil { + return nil, errors.Wrap(err, "initialising OCI DNS API client") + } + + return &OCIProvider{ + client: client, + cfg: cfg, + domainFilter: domainFilter, + zoneIDFilter: zoneIDFilter, + dryRun: dryRun, + }, nil +} + +func (p *OCIProvider) zones(ctx context.Context) (map[string]*dns.ZoneSummary, error) { + zones := make(map[string]*dns.ZoneSummary) + + log.Debugf("Matching zones against domain filters: %v", p.domainFilter.filters) + var page *string + for { + resp, err := p.client.ListZones(ctx, dns.ListZonesRequest{ + CompartmentId: &p.cfg.CompartmentID, + ZoneType: dns.ListZonesZoneTypePrimary, + Page: page, + }) + if err != nil { + return nil, errors.Wrapf(err, "listing zones in %q", p.cfg.CompartmentID) + } + + for _, zone := range resp.Items { + if p.domainFilter.Match(*zone.Name) && p.zoneIDFilter.Match(*zone.Id) { + zones[*zone.Name] = &zone + log.Debugf("Matched %q (%q)", *zone.Name, *zone.Id) + } else { + log.Debugf("Filtered %q (%q)", *zone.Name, *zone.Id) + } + } + + if page = resp.OpcNextPage; resp.OpcNextPage == nil { + break + } + } + + if len(zones) == 0 { + if p.domainFilter.IsConfigured() { + log.Warnf("No zones in compartment %q match domain filters %v", p.cfg.CompartmentID, p.domainFilter.filters) + } else { + log.Warnf("No zones found in compartment %q", p.cfg.CompartmentID) + } + } + + return zones, nil +} + +func (p *OCIProvider) newFilteredRecordOperations(endpoints []*endpoint.Endpoint, opType dns.RecordOperationOperationEnum) []dns.RecordOperation { + ops := []dns.RecordOperation{} + for _, endpoint := range endpoints { + if p.domainFilter.Match(endpoint.DNSName) { + ops = append(ops, newRecordOperation(endpoint, opType)) + } + } + return ops +} + +// Records returns the list of records in a given hosted zone. +func (p *OCIProvider) Records() ([]*endpoint.Endpoint, error) { + ctx := context.Background() + zones, err := p.zones(ctx) + if err != nil { + return nil, errors.Wrap(err, "getting zones") + } + + endpoints := []*endpoint.Endpoint{} + for _, zone := range zones { + var page *string + for { + resp, err := p.client.GetZoneRecords(ctx, dns.GetZoneRecordsRequest{ + ZoneNameOrId: zone.Id, + Page: page, + CompartmentId: &p.cfg.CompartmentID, + }) + if err != nil { + return nil, errors.Wrapf(err, "getting records for zone %q", *zone.Id) + } + + for _, record := range resp.Items { + if !supportedRecordType(*record.Rtype) { + continue + } + endpoints = append(endpoints, + endpoint.NewEndpointWithTTL( + *record.Domain, + *record.Rtype, + endpoint.TTL(*record.Ttl), + *record.Rdata, + ), + ) + } + + if page = resp.OpcNextPage; resp.OpcNextPage == nil { + break + } + } + } + + return endpoints, nil +} + +// ApplyChanges applies a given set of changes to a given zone. +func (p *OCIProvider) ApplyChanges(changes *plan.Changes) error { + log.Debugf("Processing chages: %+v", changes) + + ops := []dns.RecordOperation{} + ops = append(ops, p.newFilteredRecordOperations(changes.Create, dns.RecordOperationOperationAdd)...) + + ops = append(ops, p.newFilteredRecordOperations(changes.UpdateNew, dns.RecordOperationOperationAdd)...) + ops = append(ops, p.newFilteredRecordOperations(changes.UpdateOld, dns.RecordOperationOperationRemove)...) + + ops = append(ops, p.newFilteredRecordOperations(changes.Delete, dns.RecordOperationOperationRemove)...) + + if len(ops) == 0 { + log.Info("All records are already up to date") + return nil + } + + ctx := context.Background() + zones, err := p.zones(ctx) + if err != nil { + return errors.Wrap(err, "fetching zones") + } + + // Separate into per-zone change sets to be passed to OCI API. + opsByZone := operationsByZone(zones, ops) + for zoneID, ops := range opsByZone { + log.Infof("Change zone: %q", zoneID) + for _, op := range ops { + log.Info(op) + } + } + + if p.dryRun { + return nil + } + + for zoneID, ops := range opsByZone { + if _, err := p.client.PatchZoneRecords(ctx, dns.PatchZoneRecordsRequest{ + CompartmentId: &p.cfg.CompartmentID, + ZoneNameOrId: &zoneID, + PatchZoneRecordsDetails: dns.PatchZoneRecordsDetails{Items: ops}, + }); err != nil { + return err + } + } + + return nil +} + +// newRecordOperation returns a RecordOperation based on a given endpoint. +func newRecordOperation(ep *endpoint.Endpoint, opType dns.RecordOperationOperationEnum) dns.RecordOperation { + // NOTE(apryde): works around appending a trailing dot to TXT records. + targets := make([]string, len(ep.Targets)) + copy(targets, []string(ep.Targets)) + if ep.RecordType == endpoint.RecordTypeCNAME { + targets[0] = ensureTrailingDot(targets[0]) + } + rdata := strings.Join(targets, " ") + + ttl := ociRecordTTL + if ep.RecordTTL.IsConfigured() { + ttl = int(ep.RecordTTL) + } + + return dns.RecordOperation{ + Domain: &ep.DNSName, + Rdata: &rdata, + Ttl: &ttl, + Rtype: &ep.RecordType, + Operation: opType, + } +} + +// operationsByZone segments a slice of RecordOperations by their zone. +func operationsByZone(zones map[string]*dns.ZoneSummary, ops []dns.RecordOperation) map[string][]dns.RecordOperation { + changes := make(map[string][]dns.RecordOperation) + + zoneNameIDMapper := zoneIDName{} + for _, z := range zones { + zoneNameIDMapper.Add(*z.Id, *z.Name) + changes[*z.Id] = []dns.RecordOperation{} + } + + for _, op := range ops { + if zoneID, _ := zoneNameIDMapper.FindZone(*op.Domain); zoneID != "" { + changes[zoneID] = append(changes[zoneID], op) + } else { + log.Warnf("No matching zone for record operation %s", op) + } + } + + // Remove zones that don't have have any changes. + for zone, ops := range changes { + if len(ops) == 0 { + delete(changes, zone) + } + } + + return changes +} diff --git a/provider/oci_test.go b/provider/oci_test.go new file mode 100644 index 000000000..8b7f74126 --- /dev/null +++ b/provider/oci_test.go @@ -0,0 +1,765 @@ +/* +Copyright 2018 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 ( + "context" + "sort" + "testing" + + "github.com/oracle/oci-go-sdk/common" + "github.com/oracle/oci-go-sdk/dns" + "github.com/pkg/errors" + "github.com/stretchr/testify/require" + + "github.com/kubernetes-incubator/external-dns/endpoint" + "github.com/kubernetes-incubator/external-dns/plan" +) + +type mockOCIDNSClient struct{} + +func (c *mockOCIDNSClient) ListZones(ctx context.Context, request dns.ListZonesRequest) (response dns.ListZonesResponse, err error) { + if request.Page == nil || *request.Page == "0" { + return dns.ListZonesResponse{ + Items: []dns.ZoneSummary{ + { + Id: common.String("ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959"), + Name: common.String("foo.com"), + }, + }, + OpcNextPage: common.String("1"), + }, nil + } + return dns.ListZonesResponse{ + Items: []dns.ZoneSummary{ + { + Id: common.String("ocid1.dns-zone.oc1..502aeddba262b92fd13ed7874f6f1404"), + Name: common.String("bar.com"), + }, + }, + }, nil +} + +func (c *mockOCIDNSClient) GetZoneRecords(ctx context.Context, request dns.GetZoneRecordsRequest) (response dns.GetZoneRecordsResponse, err error) { + if request.ZoneNameOrId == nil { + return + } + + switch *request.ZoneNameOrId { + case "ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959": + if request.Page == nil || *request.Page == "0" { + response.Items = []dns.Record{{ + Domain: common.String("foo.foo.com"), + Rdata: common.String("127.0.0.1"), + Rtype: common.String(endpoint.RecordTypeA), + Ttl: common.Int(ociRecordTTL), + }, { + Domain: common.String("foo.foo.com"), + Rdata: common.String("heritage=external-dns,external-dns/owner=default,external-dns/resource=service/default/my-svc"), + Rtype: common.String(endpoint.RecordTypeTXT), + Ttl: common.Int(ociRecordTTL), + }} + response.OpcNextPage = common.String("1") + } else { + response.Items = []dns.Record{{Domain: common.String("bar.foo.com"), + Rdata: common.String("bar.com."), + Rtype: common.String(endpoint.RecordTypeCNAME), + Ttl: common.Int(ociRecordTTL), + }} + } + case "ocid1.dns-zone.oc1..502aeddba262b92fd13ed7874f6f1404": + if request.Page == nil || *request.Page == "0" { + response.Items = []dns.Record{{ + Domain: common.String("foo.bar.com"), + Rdata: common.String("127.0.0.1"), + Rtype: common.String(endpoint.RecordTypeA), + Ttl: common.Int(ociRecordTTL), + }} + } + } + + return +} + +func (c *mockOCIDNSClient) PatchZoneRecords(ctx context.Context, request dns.PatchZoneRecordsRequest) (response dns.PatchZoneRecordsResponse, err error) { + return // Provider does not use the response so nothing to do here. +} + +// newOCIProvider creates an OCI provider with API calls mocked out. +func newOCIProvider(client ociDNSClient, domainFilter DomainFilter, zoneIDFilter ZoneIDFilter, dryRun bool) *OCIProvider { + return &OCIProvider{ + client: client, + cfg: OCIConfig{ + CompartmentID: "ocid1.compartment.oc1..aaaaaaaaujjg4lf3v6uaqeml7xfk7stzvrxeweaeyolhh75exuoqxpqjb4qq", + }, + domainFilter: domainFilter, + zoneIDFilter: zoneIDFilter, + dryRun: dryRun, + } +} + +func validateOCIZones(t *testing.T, actual, expected map[string]*dns.ZoneSummary) { + require.Len(t, actual, len(expected)) + + for k, a := range actual { + e, ok := expected[k] + require.True(t, ok, "unexpected zone %q (%q)", *a.Name, *a.Id) + require.Equal(t, e, a) + } +} + +func TestOCIZones(t *testing.T) { + testCases := []struct { + name string + domainFilter DomainFilter + zoneIDFilter ZoneIDFilter + expected map[string]*dns.ZoneSummary + }{ + { + name: "DomainFilter_com", + domainFilter: NewDomainFilter([]string{"com"}), + zoneIDFilter: NewZoneIDFilter([]string{""}), + expected: map[string]*dns.ZoneSummary{ + "foo.com": { + Id: common.String("ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959"), + Name: common.String("foo.com"), + }, + "bar.com": { + Id: common.String("ocid1.dns-zone.oc1..502aeddba262b92fd13ed7874f6f1404"), + Name: common.String("bar.com"), + }, + }, + }, { + name: "DomainFilter_foo.com", + domainFilter: NewDomainFilter([]string{"foo.com"}), + zoneIDFilter: NewZoneIDFilter([]string{""}), + expected: map[string]*dns.ZoneSummary{ + "foo.com": { + Id: common.String("ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959"), + Name: common.String("foo.com"), + }, + }, + }, { + name: "ZoneIDFilter_ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959", + domainFilter: NewDomainFilter([]string{""}), + zoneIDFilter: NewZoneIDFilter([]string{"ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959"}), + expected: map[string]*dns.ZoneSummary{ + "foo.com": { + Id: common.String("ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959"), + Name: common.String("foo.com"), + }, + }, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + provider := newOCIProvider(&mockOCIDNSClient{}, tc.domainFilter, tc.zoneIDFilter, false) + zones, err := provider.zones(context.Background()) + require.NoError(t, err) + validateOCIZones(t, zones, tc.expected) + }) + } +} + +func TestOCIRecords(t *testing.T) { + testCases := []struct { + name string + domainFilter DomainFilter + zoneIDFilter ZoneIDFilter + expected []*endpoint.Endpoint + }{ + { + name: "unfiltered", + domainFilter: NewDomainFilter([]string{""}), + zoneIDFilter: NewZoneIDFilter([]string{""}), + expected: []*endpoint.Endpoint{ + endpoint.NewEndpointWithTTL("foo.foo.com", endpoint.RecordTypeA, endpoint.TTL(ociRecordTTL), "127.0.0.1"), + endpoint.NewEndpointWithTTL("foo.foo.com", endpoint.RecordTypeTXT, endpoint.TTL(ociRecordTTL), "heritage=external-dns,external-dns/owner=default,external-dns/resource=service/default/my-svc"), + endpoint.NewEndpointWithTTL("bar.foo.com", endpoint.RecordTypeCNAME, endpoint.TTL(ociRecordTTL), "bar.com."), + endpoint.NewEndpointWithTTL("foo.bar.com", endpoint.RecordTypeA, endpoint.TTL(ociRecordTTL), "127.0.0.1"), + }, + }, { + name: "DomainFilter_foo.com", + domainFilter: NewDomainFilter([]string{"foo.com"}), + zoneIDFilter: NewZoneIDFilter([]string{""}), + expected: []*endpoint.Endpoint{ + endpoint.NewEndpointWithTTL("foo.foo.com", endpoint.RecordTypeA, endpoint.TTL(ociRecordTTL), "127.0.0.1"), + endpoint.NewEndpointWithTTL("foo.foo.com", endpoint.RecordTypeTXT, endpoint.TTL(ociRecordTTL), "heritage=external-dns,external-dns/owner=default,external-dns/resource=service/default/my-svc"), + endpoint.NewEndpointWithTTL("bar.foo.com", endpoint.RecordTypeCNAME, endpoint.TTL(ociRecordTTL), "bar.com."), + }, + }, { + name: "ZoneIDFilter_ocid1.dns-zone.oc1..502aeddba262b92fd13ed7874f6f1404", + domainFilter: NewDomainFilter([]string{""}), + zoneIDFilter: NewZoneIDFilter([]string{"ocid1.dns-zone.oc1..502aeddba262b92fd13ed7874f6f1404"}), + expected: []*endpoint.Endpoint{ + endpoint.NewEndpointWithTTL("foo.bar.com", endpoint.RecordTypeA, endpoint.TTL(ociRecordTTL), "127.0.0.1"), + }, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + provider := newOCIProvider(&mockOCIDNSClient{}, tc.domainFilter, tc.zoneIDFilter, false) + endpoints, err := provider.Records() + require.NoError(t, err) + require.ElementsMatch(t, tc.expected, endpoints) + }) + } +} + +func TestNewRecordOperation(t *testing.T) { + testCases := []struct { + name string + ep *endpoint.Endpoint + opType dns.RecordOperationOperationEnum + expected dns.RecordOperation + }{ + { + name: "A_record", + opType: dns.RecordOperationOperationAdd, + ep: endpoint.NewEndpointWithTTL( + "foo.foo.com", + endpoint.RecordTypeA, + endpoint.TTL(ociRecordTTL), + "127.0.0.1"), + expected: dns.RecordOperation{ + Domain: common.String("foo.foo.com"), + Rdata: common.String("127.0.0.1"), + Rtype: common.String("A"), + Ttl: common.Int(300), + Operation: dns.RecordOperationOperationAdd, + }, + }, { + name: "TXT_record", + opType: dns.RecordOperationOperationAdd, + ep: endpoint.NewEndpointWithTTL( + "foo.foo.com", + endpoint.RecordTypeTXT, + endpoint.TTL(ociRecordTTL), + "heritage=external-dns,external-dns/owner=default,external-dns/resource=service/default/my-svc"), + expected: dns.RecordOperation{ + Domain: common.String("foo.foo.com"), + Rdata: common.String("heritage=external-dns,external-dns/owner=default,external-dns/resource=service/default/my-svc"), + Rtype: common.String("TXT"), + Ttl: common.Int(300), + Operation: dns.RecordOperationOperationAdd, + }, + }, { + name: "CNAME_record", + opType: dns.RecordOperationOperationAdd, + ep: endpoint.NewEndpointWithTTL( + "foo.foo.com", + endpoint.RecordTypeCNAME, + endpoint.TTL(ociRecordTTL), + "bar.com."), + expected: dns.RecordOperation{ + Domain: common.String("foo.foo.com"), + Rdata: common.String("bar.com."), + Rtype: common.String("CNAME"), + Ttl: common.Int(300), + Operation: dns.RecordOperationOperationAdd, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + op := newRecordOperation(tc.ep, tc.opType) + require.Equal(t, tc.expected, op) + }) + } +} + +func TestOperationsByZone(t *testing.T) { + testCases := []struct { + name string + zones map[string]*dns.ZoneSummary + ops []dns.RecordOperation + expected map[string][]dns.RecordOperation + }{ + { + name: "basic", + zones: map[string]*dns.ZoneSummary{ + "foo": { + Id: common.String("foo"), + Name: common.String("foo.com"), + }, + "bar": { + Id: common.String("bar"), + Name: common.String("bar.com"), + }, + }, + ops: []dns.RecordOperation{ + { + Domain: common.String("foo.foo.com"), + Rdata: common.String("127.0.0.1"), + Rtype: common.String("A"), + Ttl: common.Int(300), + Operation: dns.RecordOperationOperationAdd, + }, + { + Domain: common.String("foo.bar.com"), + Rdata: common.String("127.0.0.1"), + Rtype: common.String("A"), + Ttl: common.Int(300), + Operation: dns.RecordOperationOperationAdd, + }, + }, + expected: map[string][]dns.RecordOperation{ + "foo": { + { + Domain: common.String("foo.foo.com"), + Rdata: common.String("127.0.0.1"), + Rtype: common.String("A"), + Ttl: common.Int(300), + Operation: dns.RecordOperationOperationAdd, + }, + }, + "bar": { + { + Domain: common.String("foo.bar.com"), + Rdata: common.String("127.0.0.1"), + Rtype: common.String("A"), + Ttl: common.Int(300), + Operation: dns.RecordOperationOperationAdd, + }, + }, + }, + }, { + name: "does_not_include_zones_with_no_changes", + zones: map[string]*dns.ZoneSummary{ + "foo": { + Id: common.String("foo"), + Name: common.String("foo.com"), + }, + "bar": { + Id: common.String("bar"), + Name: common.String("bar.com"), + }, + }, + ops: []dns.RecordOperation{ + { + Domain: common.String("foo.foo.com"), + Rdata: common.String("127.0.0.1"), + Rtype: common.String("A"), + Ttl: common.Int(300), + Operation: dns.RecordOperationOperationAdd, + }, + }, + expected: map[string][]dns.RecordOperation{ + "foo": { + { + Domain: common.String("foo.foo.com"), + Rdata: common.String("127.0.0.1"), + Rtype: common.String("A"), + Ttl: common.Int(300), + Operation: dns.RecordOperationOperationAdd, + }, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := operationsByZone(tc.zones, tc.ops) + require.Equal(t, tc.expected, result) + }) + } +} + +type mutableMockOCIDNSClient struct { + zones map[string]dns.ZoneSummary + records map[string]map[string]dns.Record +} + +func newMutableMockOCIDNSClient(zones []dns.ZoneSummary, recordsByZone map[string][]dns.Record) *mutableMockOCIDNSClient { + c := &mutableMockOCIDNSClient{ + zones: make(map[string]dns.ZoneSummary), + records: make(map[string]map[string]dns.Record), + } + + for _, zone := range zones { + c.zones[*zone.Id] = zone + c.records[*zone.Id] = make(map[string]dns.Record) + } + + for zoneID, records := range recordsByZone { + for _, record := range records { + c.records[zoneID][ociRecordKey(*record.Rtype, *record.Domain)] = record + } + } + + return c +} + +func (c *mutableMockOCIDNSClient) ListZones(ctx context.Context, request dns.ListZonesRequest) (response dns.ListZonesResponse, err error) { + var zones []dns.ZoneSummary + for _, v := range c.zones { + zones = append(zones, v) + } + return dns.ListZonesResponse{Items: zones}, nil +} + +func (c *mutableMockOCIDNSClient) GetZoneRecords(ctx context.Context, request dns.GetZoneRecordsRequest) (response dns.GetZoneRecordsResponse, err error) { + if request.ZoneNameOrId == nil { + err = errors.New("no name or id") + return + } + + records, ok := c.records[*request.ZoneNameOrId] + if !ok { + err = errors.New("zone not found") + return + } + + var items []dns.Record + for _, v := range records { + items = append(items, v) + } + + response.Items = items + return +} + +func ociRecordKey(rType, domain string) string { + return rType + "/" + domain +} + +func (c *mutableMockOCIDNSClient) PatchZoneRecords(ctx context.Context, request dns.PatchZoneRecordsRequest) (response dns.PatchZoneRecordsResponse, err error) { + if request.ZoneNameOrId == nil { + err = errors.New("no name or id") + return + } + + records, ok := c.records[*request.ZoneNameOrId] + if !ok { + err = errors.New("zone not found") + return + } + + // Ensure that ADD operations occur after REMOVE. + sort.Slice(request.Items, func(i, j int) bool { + return request.Items[i].Operation > request.Items[j].Operation + }) + + for _, op := range request.Items { + k := ociRecordKey(*op.Rtype, *op.Domain) + switch op.Operation { + case dns.RecordOperationOperationAdd: + records[k] = dns.Record{ + Domain: op.Domain, + Rtype: op.Rtype, + Rdata: op.Rdata, + Ttl: op.Ttl, + } + case dns.RecordOperationOperationRemove: + delete(records, k) + default: + err = errors.Errorf("unsupported operation %q", op.Operation) + return + } + } + return +} + +// TestMutableMockOCIDNSClient exists because one must always test one's tests +// right...? +func TestMutableMockOCIDNSClient(t *testing.T) { + zones := []dns.ZoneSummary{{ + Id: common.String("ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959"), + Name: common.String("foo.com"), + }} + records := map[string][]dns.Record{ + "ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959": {{ + Domain: common.String("foo.foo.com"), + Rdata: common.String("127.0.0.1"), + Rtype: common.String(endpoint.RecordTypeA), + Ttl: common.Int(ociRecordTTL), + }, { + Domain: common.String("foo.foo.com"), + Rdata: common.String("heritage=external-dns,external-dns/owner=default,external-dns/resource=service/default/my-svc"), + Rtype: common.String(endpoint.RecordTypeTXT), + Ttl: common.Int(ociRecordTTL), + }}, + } + client := newMutableMockOCIDNSClient(zones, records) + + // First ListZones. + zonesResponse, err := client.ListZones(context.Background(), dns.ListZonesRequest{}) + require.NoError(t, err) + require.Len(t, zonesResponse.Items, 1) + require.Equal(t, zonesResponse.Items, zones) + + // GetZoneRecords for that zone. + recordsResponse, err := client.GetZoneRecords(context.Background(), dns.GetZoneRecordsRequest{ + ZoneNameOrId: zones[0].Id, + }) + require.NoError(t, err) + require.Len(t, recordsResponse.Items, 2) + require.ElementsMatch(t, recordsResponse.Items, records["ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959"]) + + // Remove the A record. + _, err = client.PatchZoneRecords(context.Background(), dns.PatchZoneRecordsRequest{ + ZoneNameOrId: common.String("ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959"), + PatchZoneRecordsDetails: dns.PatchZoneRecordsDetails{ + Items: []dns.RecordOperation{{ + Domain: common.String("foo.foo.com"), + Rdata: common.String("127.0.0.1"), + Rtype: common.String("A"), + Ttl: common.Int(300), + Operation: dns.RecordOperationOperationRemove, + }}, + }, + }) + require.NoError(t, err) + + // GetZoneRecords again and check the A record was removed. + recordsResponse, err = client.GetZoneRecords(context.Background(), dns.GetZoneRecordsRequest{ + ZoneNameOrId: zones[0].Id, + }) + require.NoError(t, err) + require.Len(t, recordsResponse.Items, 1) + require.Equal(t, recordsResponse.Items[0], records["ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959"][1]) + + // Add the A record back. + _, err = client.PatchZoneRecords(context.Background(), dns.PatchZoneRecordsRequest{ + ZoneNameOrId: common.String("ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959"), + PatchZoneRecordsDetails: dns.PatchZoneRecordsDetails{ + Items: []dns.RecordOperation{{ + Domain: common.String("foo.foo.com"), + Rdata: common.String("127.0.0.1"), + Rtype: common.String("A"), + Ttl: common.Int(300), + Operation: dns.RecordOperationOperationAdd, + }}, + }, + }) + require.NoError(t, err) + + // GetZoneRecords and check we're back in the origional state + recordsResponse, err = client.GetZoneRecords(context.Background(), dns.GetZoneRecordsRequest{ + ZoneNameOrId: zones[0].Id, + }) + require.NoError(t, err) + require.Len(t, recordsResponse.Items, 2) + require.ElementsMatch(t, recordsResponse.Items, records["ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959"]) +} + +func TestOCIApplyChanges(t *testing.T) { + testCases := []struct { + name string + zones []dns.ZoneSummary + records map[string][]dns.Record + changes *plan.Changes + dryRun bool + err error + expectedEndpoints []*endpoint.Endpoint + }{ + { + name: "add", + zones: []dns.ZoneSummary{{ + Id: common.String("ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959"), + Name: common.String("foo.com"), + }}, + changes: &plan.Changes{ + Create: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL( + "foo.foo.com", + endpoint.RecordTypeA, + endpoint.TTL(ociRecordTTL), + "127.0.0.1", + )}, + }, + expectedEndpoints: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL( + "foo.foo.com", + endpoint.RecordTypeA, + endpoint.TTL(ociRecordTTL), + "127.0.0.1", + )}, + }, { + name: "remove", + zones: []dns.ZoneSummary{{ + Id: common.String("ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959"), + Name: common.String("foo.com"), + }}, + records: map[string][]dns.Record{ + "ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959": {{ + Domain: common.String("foo.foo.com"), + Rdata: common.String("127.0.0.1"), + Rtype: common.String(endpoint.RecordTypeA), + Ttl: common.Int(ociRecordTTL), + }, { + Domain: common.String("foo.foo.com"), + Rdata: common.String("heritage=external-dns,external-dns/owner=default,external-dns/resource=service/default/my-svc"), + Rtype: common.String(endpoint.RecordTypeTXT), + Ttl: common.Int(ociRecordTTL), + }}, + }, + changes: &plan.Changes{ + Delete: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL( + "foo.foo.com", + endpoint.RecordTypeTXT, + endpoint.TTL(ociRecordTTL), + "127.0.0.1", + )}, + }, + expectedEndpoints: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL( + "foo.foo.com", + endpoint.RecordTypeA, + endpoint.TTL(ociRecordTTL), + "127.0.0.1", + )}, + }, { + name: "update", + zones: []dns.ZoneSummary{{ + Id: common.String("ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959"), + Name: common.String("foo.com"), + }}, + records: map[string][]dns.Record{ + "ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959": {{ + Domain: common.String("foo.foo.com"), + Rdata: common.String("127.0.0.1"), + Rtype: common.String(endpoint.RecordTypeA), + Ttl: common.Int(ociRecordTTL), + }}, + }, + changes: &plan.Changes{ + UpdateOld: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL( + "foo.foo.com", + endpoint.RecordTypeA, + endpoint.TTL(ociRecordTTL), + "127.0.0.1", + )}, + UpdateNew: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL( + "foo.foo.com", + endpoint.RecordTypeA, + endpoint.TTL(ociRecordTTL), + "10.0.0.1", + )}, + }, + expectedEndpoints: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL( + "foo.foo.com", + endpoint.RecordTypeA, + endpoint.TTL(ociRecordTTL), + "10.0.0.1", + )}, + }, { + name: "dry_run_no_changes", + zones: []dns.ZoneSummary{{ + Id: common.String("ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959"), + Name: common.String("foo.com"), + }}, + records: map[string][]dns.Record{ + "ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959": {{ + Domain: common.String("foo.foo.com"), + Rdata: common.String("127.0.0.1"), + Rtype: common.String(endpoint.RecordTypeA), + Ttl: common.Int(ociRecordTTL), + }}, + }, + changes: &plan.Changes{ + Delete: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL( + "foo.foo.com", + endpoint.RecordTypeA, + endpoint.TTL(ociRecordTTL), + "127.0.0.1", + )}, + }, + dryRun: true, + expectedEndpoints: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL( + "foo.foo.com", + endpoint.RecordTypeA, + endpoint.TTL(ociRecordTTL), + "127.0.0.1", + )}, + }, { + name: "add_remove_update", + zones: []dns.ZoneSummary{{ + Id: common.String("ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959"), + Name: common.String("foo.com"), + }}, + records: map[string][]dns.Record{ + "ocid1.dns-zone.oc1..e1e042ef0bfbb5c251b9713fd7bf8959": {{ + Domain: common.String("foo.foo.com"), + Rdata: common.String("127.0.0.1"), + Rtype: common.String(endpoint.RecordTypeA), + Ttl: common.Int(ociRecordTTL), + }, { + Domain: common.String("bar.foo.com"), + Rdata: common.String("bar.com."), + Rtype: common.String(endpoint.RecordTypeCNAME), + Ttl: common.Int(ociRecordTTL), + }}, + }, + changes: &plan.Changes{ + Delete: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL( + "foo.foo.com", + endpoint.RecordTypeA, + endpoint.TTL(ociRecordTTL), + "baz.com.", + )}, + UpdateOld: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL( + "bar.foo.com", + endpoint.RecordTypeCNAME, + endpoint.TTL(ociRecordTTL), + "baz.com.", + )}, + UpdateNew: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL( + "bar.foo.com", + endpoint.RecordTypeCNAME, + endpoint.TTL(ociRecordTTL), + "foo.bar.com.", + )}, + Create: []*endpoint.Endpoint{endpoint.NewEndpointWithTTL( + "baz.foo.com", + endpoint.RecordTypeA, + endpoint.TTL(ociRecordTTL), + "127.0.0.1", + )}, + }, + expectedEndpoints: []*endpoint.Endpoint{ + endpoint.NewEndpointWithTTL( + "bar.foo.com", + endpoint.RecordTypeCNAME, + endpoint.TTL(ociRecordTTL), + "foo.bar.com.", + ), + endpoint.NewEndpointWithTTL( + "baz.foo.com", + endpoint.RecordTypeA, + endpoint.TTL(ociRecordTTL), + "127.0.0.1"), + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + client := newMutableMockOCIDNSClient(tc.zones, tc.records) + provider := newOCIProvider( + client, + NewDomainFilter([]string{""}), + NewZoneIDFilter([]string{""}), + tc.dryRun, + ) + err := provider.ApplyChanges(tc.changes) + require.Equal(t, tc.err, err) + endpoints, err := provider.Records() + require.NoError(t, err) + require.ElementsMatch(t, tc.expectedEndpoints, endpoints) + }) + } +} From 3c9a944fec4530d55046af8e75d2308a82ba9722 Mon Sep 17 00:00:00 2001 From: Andrew Pryde Date: Fri, 6 Jul 2018 11:44:33 +0100 Subject: [PATCH 2/2] Code review comments --- README.md | 1 + docs/tutorials/oracle.md | 4 +- main.go | 6 ++- pkg/apis/externaldns/types_test.go | 4 +- provider/oci.go | 27 ++++++----- provider/oci_test.go | 74 ++++++++++++++++++++++++++++++ 6 files changed, 101 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 1c75e5ce1..62599c72c 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ The following tutorials are provided: * Google Container Engine * [Using Google's Default Ingress Controller](docs/tutorials/gke.md) * [Using the Nginx Ingress Controller](docs/tutorials/nginx-ingress.md) +* [Oracle Cloud Infrastructure (OCI) DNS](docs/tutorials/oracle.md) ## Running Locally diff --git a/docs/tutorials/oracle.md b/docs/tutorials/oracle.md index 9c4a87583..adfab29c2 100644 --- a/docs/tutorials/oracle.md +++ b/docs/tutorials/oracle.md @@ -58,6 +58,9 @@ rules: - apiGroups: ["extensions"] resources: ["ingresses"] verbs: ["get","watch","list"] +- apiGroups: [""] + resources: ["nodes"] + verbs: ["list"] --- apiVersion: rbac.authorization.k8s.io/v1beta1 kind: ClusterRoleBinding @@ -93,7 +96,6 @@ spec: - --source=ingress - --provider=oci - --policy=upsert-only # prevent ExternalDNSfrom deleting any records, omit to enable full synchronization - - --registry=txt - --txt-owner-id=my-identifier volumeMounts: - name: config diff --git a/main.go b/main.go index ad622d330..bc6d656d2 100644 --- a/main.go +++ b/main.go @@ -171,7 +171,11 @@ func main() { }, ) case "oci": - p, err = provider.NewOCIProvider(cfg.OCIConfigFile, domainFilter, zoneIDFilter, cfg.DryRun) + var config *provider.OCIConfig + config, err = provider.LoadOCIConfig(cfg.OCIConfigFile) + if err == nil { + p, err = provider.NewOCIProvider(*config, domainFilter, zoneIDFilter, cfg.DryRun) + } default: log.Fatalf("unknown dns provider: %s", cfg.Provider) } diff --git a/pkg/apis/externaldns/types_test.go b/pkg/apis/externaldns/types_test.go index f91201e7c..a5dfa2fbc 100644 --- a/pkg/apis/externaldns/types_test.go +++ b/pkg/apis/externaldns/types_test.go @@ -94,7 +94,7 @@ var ( InfobloxWapiPassword: "infoblox", InfobloxWapiVersion: "2.6.1", InfobloxSSLVerify: false, - OCIConfigFile: "/etc/kubernetes/oci.yaml", + OCIConfigFile: "oci.yaml", InMemoryZones: []string{"example.org", "company.com"}, PDNSServer: "http://ns.example.com:8081", PDNSAPIKey: "some-secret-key", @@ -159,6 +159,7 @@ func TestParseFlags(t *testing.T) { "--pdns-server=http://ns.example.com:8081", "--pdns-api-key=some-secret-key", "--pdns-tls-enabled", + "--oci-config-file=oci.yaml", "--tls-ca=/path/to/ca.crt", "--tls-client-cert=/path/to/cert.pem", "--tls-client-cert-key=/path/to/key.pem", @@ -208,6 +209,7 @@ func TestParseFlags(t *testing.T) { "EXTERNAL_DNS_INFOBLOX_WAPI_PASSWORD": "infoblox", "EXTERNAL_DNS_INFOBLOX_WAPI_VERSION": "2.6.1", "EXTERNAL_DNS_INFOBLOX_SSL_VERIFY": "0", + "EXTERNAL_DNS_OCI_CONFIG_FILE": "oci.yaml", "EXTERNAL_DNS_INMEMORY_ZONE": "example.org\ncompany.com", "EXTERNAL_DNS_DOMAIN_FILTER": "example.org\ncompany.com", "EXTERNAL_DNS_PDNS_SERVER": "http://ns.example.com:8081", diff --git a/provider/oci.go b/provider/oci.go index e40e891b6..5ff1ba8f8 100644 --- a/provider/oci.go +++ b/provider/oci.go @@ -67,21 +67,25 @@ type ociDNSClient interface { PatchZoneRecords(ctx context.Context, request dns.PatchZoneRecordsRequest) (response dns.PatchZoneRecordsResponse, err error) } -// NewOCIProvider initialises a new OCI DNS based Provider. -func NewOCIProvider(configFile string, domainFilter DomainFilter, zoneIDFilter ZoneIDFilter, dryRun bool) (*OCIProvider, error) { - contents, err := ioutil.ReadFile(configFile) +// LoadOCIConfig reads and parses the OCI ExternalDNS config file at the given +// path. +func LoadOCIConfig(path string) (*OCIConfig, error) { + contents, err := ioutil.ReadFile(path) if err != nil { - return nil, errors.Wrapf(err, "failed to read OCI config file %q", configFile) + return nil, errors.Wrapf(err, "reading OCI config file %q", path) } - cfg := OCIConfig{} - err = yaml.Unmarshal(contents, &cfg) - if err != nil { - return nil, errors.Wrapf(err, "failed to read OCI config file %q", configFile) - } - // TODO(apryde): validate config. + cfg := OCIConfig{} + if err := yaml.Unmarshal(contents, &cfg); err != nil { + return nil, errors.Wrapf(err, "parsing OCI config file %q", path) + } + return &cfg, nil +} + +// NewOCIProvider initialises a new OCI DNS based Provider. +func NewOCIProvider(cfg OCIConfig, domainFilter DomainFilter, zoneIDFilter ZoneIDFilter, dryRun bool) (*OCIProvider, error) { var client ociDNSClient - client, err = dns.NewDnsClientWithConfigurationProvider(common.NewRawConfigurationProvider( + client, err := dns.NewDnsClientWithConfigurationProvider(common.NewRawConfigurationProvider( cfg.Auth.TenancyID, cfg.Auth.UserID, cfg.Auth.Region, @@ -247,7 +251,6 @@ func (p *OCIProvider) ApplyChanges(changes *plan.Changes) error { // newRecordOperation returns a RecordOperation based on a given endpoint. func newRecordOperation(ep *endpoint.Endpoint, opType dns.RecordOperationOperationEnum) dns.RecordOperation { - // NOTE(apryde): works around appending a trailing dot to TXT records. targets := make([]string, len(ep.Targets)) copy(targets, []string(ep.Targets)) if ep.RecordType == endpoint.RecordTypeCNAME { diff --git a/provider/oci_test.go b/provider/oci_test.go index 8b7f74126..89056812c 100644 --- a/provider/oci_test.go +++ b/provider/oci_test.go @@ -122,6 +122,80 @@ func validateOCIZones(t *testing.T, actual, expected map[string]*dns.ZoneSummary } } +func TestNewOCIProvider(t *testing.T) { + testCases := map[string]struct { + config OCIConfig + err error + }{ + "valid": { + config: OCIConfig{ + Auth: OCIAuthConfig{ + TenancyID: "ocid1.tenancy.oc1..aaaaaaaaxf3fuazosc6xng7l75rj6uist5jb6ken64t3qltimxnkymddqbma", + UserID: "ocid1.user.oc1..aaaaaaaahx2vpvm4of5nqq3t274ike7ygyk2aexvokk3gyv4eyumzqajcrvq", + Region: "us-ashburn-1", + Fingerprint: "48:ba:d4:21:63:53:db:10:65:20:d4:09:ce:01:f5:97", + PrivateKey: `-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAv2JspZyO14kqcO/X4iz3ZdcyAf1GQJqYsBb6wyrlU0PB9Fee +H23/HLtMSqeqo+2KQHmdV1OHFQ/S6tx7zcBaby/+2b+z3/gJO4PGxohe2812AJ/J +W8Fp/4EnwbaRqDhoLN7ms0/e566zE3z40kCSW0NAIzv/F+0nNaka1xrypBqzvaNm +N49dAGvqWRpzFFUb8CbvKmgE6c/H4a2zVNW3G7/K6Og4HQGeEP3NKSVvi0BiQlvd +tVJTg7084kKcrngsS2N3qI3pzsr5wgpzPPefuPHWRKokZ20kpu8tXdFt+mAC2NHh +eWbtY3jsR6JFaXCyZLMXInwDvRgdP0T5+uh8WwIDAQABAoIBAG0rr94omDLKw7L4 +naUfEWC+iIAqAdEIXuDTuudpqLb+h7zh3gj/re6tyK8tRWGNNrfgp6gQtZWGGUJv +0w9jEjMqpa2AdRLlYh7Y5KKLV9D6Or3QaAQ3KEffXNZbVmsnAgXWgLL4dKakOPJ8 +71LAEryMeCGhL7puRVeOxwi9Dnwc4pcloimdggw/uwVHMK9eY5ylyt5ziiiWfhAo +cnNJNPHRSTqSiCoEhk/8BLZT5gxf1YX0hVSEdQh2WNyxmPmVSC9uuzKOqcEBfHf5 +hmLnsUET1REM9IxCLqC9ebW263lIO/KdGiCu+YgIdwIi3wrLhaKXAZQmp4oMvWlE +n5eYlcECgYEA5AhctPWCQBCJhcD39pSWgnSq1O9bt8yQi2P2stqlxKV9ZBepCK49 +OT42OYPUgWn7/y//6/LLzsPY58VTDHF3xZN1qu+fU0IM22D3Jqc19pnfVEb6TXSc +0jJIiaYCWTdqRQ4p2DuDcI+EzRB+V1Z7tFWxshZWXwNvtMXNoYPOYaUCgYEA1ttn +R3pCuGYJ5XbBwPzD5J+hvdZ6TQf8oTDraUBPxjtFOr7ea42T6KeYRFvnK2AQDnKL +Mw3I55lNO4I2W9gahUFG28dhxEuxeyvXGqXEJvPCUYePstab/BkUrm7/jkS3CLcJ +dlRXjqOfGwi5+NPUZMoOkZ54ZR4ZpdhIAeEpBf8CgYEAyMyMRlVCowNs9jkcoSfq ++Wme3O8BhvI9/mDCZnCfNHC94Bvtn1U/WF7uBOuPf35Ch05PQAiHa8WOBVn/bZ+l +ZngZT7K+S+SHyc6zFHh9zm9k96Og2f/r8DSTJ5Ll0oY3sCNuuZh+f+oBeUoi1umy ++PPVDAsbd4NhJIBiOO4GGHkCgYA1p4i9Es0Cm4ixItzzwqtwtmR/scXM4se1wS+o +kwTY7gg1yWBl328mVGPz/jdWX6Di2rvkPfcDzwa4a6YDfY3x5QE69Sl3CagCqEoJ +P4giahEGpyG9eVZuuBywCswKzSIgLQVR5XIQDtA2whEfEFcj7EmDF93c8o1ZGw+w +WHgUJQKBgEXr0HgxGG+v8bsXdrJ87Avx/nuA2rrFfECDPa4zuPkEK+cSFibdAq/H +u6OIV+z59AD2s84gxR+KLzEDfQAqBt7cVA5ZH6hrO+bkCtK9ycLL+koOuB+1EV+Y +hKRtDhmSdWBo3tJK12RrAe4t7CUe8gMgTvU7ExlcA3xQkseFPx9K +-----END RSA PRIVATE KEY----- +`, + }, + }, + }, + "invalid": { + config: OCIConfig{ + Auth: OCIAuthConfig{ + TenancyID: "ocid1.tenancy.oc1..aaaaaaaaxf3fuazosc6xng7l75rj6uist5jb6ken64t3qltimxnkymddqbma", + UserID: "ocid1.user.oc1..aaaaaaaahx2vpvm4of5nqq3t274ike7ygyk2aexvokk3gyv4eyumzqajcrvq", + Region: "us-ashburn-1", + Fingerprint: "48:ba:d4:21:63:53:db:10:65:20:d4:09:ce:01:f5:97", + PrivateKey: `-----BEGIN RSA PRIVATE KEY----- +`, + }, + }, + err: errors.New("initialising OCI DNS API client: can not create client, bad configuration: PEM data was not found in buffer"), + }, + } + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + _, err := NewOCIProvider( + tc.config, + NewDomainFilter([]string{"com"}), + NewZoneIDFilter([]string{""}), + false, + ) + if err == nil { + require.NoError(t, err) + } else { + require.Equal(t, tc.err.Error(), err.Error()) + } + }) + } +} + func TestOCIZones(t *testing.T) { testCases := []struct { name string