diff --git a/docs/ttl.md b/docs/ttl.md index c6447007d..861c863e1 100644 --- a/docs/ttl.md +++ b/docs/ttl.md @@ -39,6 +39,7 @@ Providers - [ ] Azure - [ ] Cloudflare - [x] DigitalOcean +- [x] DNSimple - [x] Google - [ ] InMemory - [x] Linode @@ -61,6 +62,9 @@ This value is a constant in the provider code. The DigitalOcean Provider overrides the value to 300s when the TTL is 0. This value is a constant in the provider code. +### DNSimple Provider +The DNSimple Provider default TTL is used when the TTL is 0. The default TTL is 3600s. + ### Google Provider Previously with the Google Provider, TTL's were hard-coded to 300s. For safety, the Google Provider overrides the value to 300s when the TTL is 0. diff --git a/docs/tutorials/istio.md b/docs/tutorials/istio.md index 4f9495f9b..16f992020 100644 --- a/docs/tutorials/istio.md +++ b/docs/tutorials/istio.md @@ -139,12 +139,12 @@ The following are relevant snippets from that tutorial. #### Install a sample service With automatic sidecar injection: ```bash -$ kubectl apply -f https://raw.githubusercontent.com/istio/istio/release-1.0/samples/httpbin/httpbin.yaml +$ kubectl apply -f https://raw.githubusercontent.com/istio/istio/release-1.6/samples/httpbin/httpbin.yaml ``` Otherwise: ```bash -$ kubectl apply -f <(istioctl kube-inject -f https://raw.githubusercontent.com/istio/istio/release-1.0/samples/httpbin/httpbin.yaml) +$ kubectl apply -f <(istioctl kube-inject -f https://raw.githubusercontent.com/istio/istio/release-1.6/samples/httpbin/httpbin.yaml) ``` #### Create an Istio Gateway: diff --git a/go.mod b/go.mod index d49f69b50..13cdb3626 100644 --- a/go.mod +++ b/go.mod @@ -20,13 +20,14 @@ require ( github.com/cloudflare/cloudflare-go v0.10.1 github.com/cloudfoundry-community/go-cfclient v0.0.0-20190201205600-f136f9222381 github.com/denverdino/aliyungo v0.0.0-20190125010748-a747050bb1ba - github.com/digitalocean/godo v1.34.0 + github.com/digitalocean/godo v1.36.0 github.com/dnsimple/dnsimple-go v0.60.0 github.com/exoscale/egoscale v0.18.1 github.com/fatih/structs v1.1.0 // indirect github.com/ffledgling/pdns-go v0.0.0-20180219074714-524e7daccd99 github.com/gobs/pretty v0.0.0-20180724170744-09732c25a95b // indirect github.com/golang/sync v0.0.0-20180314180146-1d60e4601c6f + github.com/google/go-cmp v0.4.1 github.com/gophercloud/gophercloud v0.1.0 github.com/gorilla/mux v1.7.4 // indirect github.com/hashicorp/golang-lru v0.5.4 // indirect @@ -57,11 +58,11 @@ require ( github.com/transip/gotransip v5.8.2+incompatible github.com/ultradns/ultradns-sdk-go v0.0.0-20200616202852-e62052662f60 github.com/vinyldns/go-vinyldns v0.0.0-20190611170422-7119fe55ed92 - github.com/vultr/govultr v0.3.2 + github.com/vultr/govultr v0.4.2 go.etcd.io/etcd v0.5.0-alpha.5.0.20200401174654-e694b7bb0875 go.uber.org/ratelimit v0.1.0 - golang.org/x/net v0.0.0-20200202094626-16171245cfb2 - golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 + golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e + golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d google.golang.org/api v0.15.0 gopkg.in/ns1/ns1-go.v2 v2.0.0-20190322154155-0dafb5275fd1 gopkg.in/yaml.v2 v2.2.8 diff --git a/go.sum b/go.sum index 458f189b7..eb9cbdb33 100644 --- a/go.sum +++ b/go.sum @@ -108,7 +108,6 @@ github.com/cloudflare/cloudflare-go v0.10.1 h1:d2CL6F9k2O0Ux0w27LgogJ5UOzZRj6a/h github.com/cloudflare/cloudflare-go v0.10.1/go.mod h1:C0Y6eWnTJPMK2ceuOxx2pjh78UUHihcXeTTHb8r7QjU= github.com/cloudfoundry-community/go-cfclient v0.0.0-20190201205600-f136f9222381 h1:rdRS5BT13Iae9ssvcslol66gfOOXjaLYwqerEn/cl9s= github.com/cloudfoundry-community/go-cfclient v0.0.0-20190201205600-f136f9222381/go.mod h1:e5+USP2j8Le2M0Jo3qKPFnNhuo1wueU4nWHCXBOfQ14= -github.com/cncf/udpa/go v0.0.0-20200313221541-5f7e5dd04533 h1:8wZizuKuZVu5COB7EsBYxBQz8nRcXXn5d4Gt91eJLvU= github.com/cncf/udpa/go v0.0.0-20200313221541-5f7e5dd04533/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa h1:OaNxuTZr7kxeODyLWsRMC+OD03aFUH+mW6r2d+MWa5Y= github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8= @@ -140,8 +139,8 @@ github.com/denverdino/aliyungo v0.0.0-20190125010748-a747050bb1ba/go.mod h1:dV8l github.com/dgrijalva/jwt-go v0.0.0-20160705203006-01aeca54ebda/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= -github.com/digitalocean/godo v1.34.0 h1:OXJhLLJS2VTB5SziTyCq8valKVZ0uBHCFQsDW3/HF78= -github.com/digitalocean/godo v1.34.0/go.mod h1:gfLm3JSupWD9V/ibQygXWW3IVz7hranzckH5UimhZsI= +github.com/digitalocean/godo v1.36.0 h1:eRF8wNzHZyU7/wI3De/MQgiVSWdseDaf27bXj2gnOO0= +github.com/digitalocean/godo v1.36.0/go.mod h1:p7dOjjtSBqCTUksqtA5Fd3uaKs9kyTq2xcz76ulEJRU= github.com/dimchansky/utfbom v1.1.0 h1:FcM3g+nofKgUteL8dm/UpdRXNC9KmADgTpLKsu0TRo4= github.com/dimchansky/utfbom v1.1.0/go.mod h1:rO41eb7gLfo8SF1jd9F8HplJm1Fewwi4mQvIirEdv+8= github.com/dnaeon/go-vcr v1.0.1 h1:r8L/HqC0Hje5AXMu1ooW8oyQyOFv4GxqpL0nRP7SLLY= @@ -163,7 +162,6 @@ github.com/emicklei/go-restful v2.9.5+incompatible/go.mod h1:otzb+WCGbkyDHkqmQmT github.com/envoyproxy/go-control-plane v0.8.2/go.mod h1:EWRTAFN6uuDZIa6KOuUfrOMJ7ySgXZ44rVKiTWjKe34= github.com/envoyproxy/go-control-plane v0.9.0 h1:67WMNTvGrl7V1dWdKCeTwxDr7nio9clKoTlLhwIPnT4= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.5 h1:lRJIqDD8yjV1YyPRqecMdytjDLs2fTXq363aCib5xPU= github.com/envoyproxy/go-control-plane v0.9.5/go.mod h1:OXl5to++W0ctG+EHWTFUjiypVxC/Y4VLc/KFU+al13s= github.com/envoyproxy/protoc-gen-validate v0.0.0-20190405222122-d6164de49109/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v0.0.14/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= @@ -273,6 +271,8 @@ github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/protobuf v1.3.5 h1:F768QJ1E9tib+q5Sc8MkdJi1RxLTbRcTf8LJV56aRls= +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= github.com/golang/sync v0.0.0-20180314180146-1d60e4601c6f h1:kSqKc8ouCLIBHqdj9a9xxhtxlZhNqbePClixA4HoM44= github.com/golang/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:YCHYtYb9c8Q7XgYVYjmJBPtFPKx5QvOcPxHZWjldabE= github.com/google/btree v0.0.0-20160524151835-7d79101e329e/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= @@ -286,6 +286,8 @@ github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-github v15.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= github.com/google/go-querystring v0.0.0-20170111101155-53e6ce116135/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +github.com/google/go-cmp v0.4.1 h1:/exdXoGamhu5ONeUJH0deniYLWYvQwW66yvlfiiKTu0= +github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/gofuzz v0.0.0-20161122191042-44d81051d367/go.mod h1:HP5RmnzzSNb993RKQDq4+1A4ia9nllfqcQFTQJedwGI= @@ -336,8 +338,8 @@ github.com/hashicorp/go-cleanhttp v0.5.1 h1:dH3aiDG9Jvb5r5+bYHsikaOUIpcM0xvgMXVo github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI= github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= -github.com/hashicorp/go-retryablehttp v0.6.4 h1:BbgctKO892xEyOXnGiaAwIoSq1QZ/SS4AhjoAh9DnfY= -github.com/hashicorp/go-retryablehttp v0.6.4/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY= +github.com/hashicorp/go-retryablehttp v0.6.6 h1:HJunrbHTDDbBb/ay4kxa1n+dLmttUlnP3V9oNE4hmsM= +github.com/hashicorp/go-retryablehttp v0.6.6/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.3 h1:YPkqC67at8FYaadspW/6uE0COsBxS2656RLEr8Bppgk= @@ -600,8 +602,8 @@ github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijb github.com/urfave/cli v1.21.0/go.mod h1:lxDj6qX9Q6lWQxIrbrT0nwecwUtRnhVZAJjJZrVUZZQ= github.com/vinyldns/go-vinyldns v0.0.0-20190611170422-7119fe55ed92 h1:Q76MzqJu++vAfhj0mVf7t0F4xHUbg+V/d/Uk5PBQjRU= github.com/vinyldns/go-vinyldns v0.0.0-20190611170422-7119fe55ed92/go.mod h1:AZuEfReFWdvtU0LatbLpo70t3lqdLvph2D5mqFP0bkA= -github.com/vultr/govultr v0.3.2 h1:1tV/88jkm+4Y345qAXBe3peNbnmvCY/VAIZApklbKkI= -github.com/vultr/govultr v0.3.2/go.mod h1:81RwK1wAmb08alkFDJiZmu9gdv+IO+UamzaF0+PIieE= +github.com/vultr/govultr v0.4.2 h1:9i8xKZ+xp6vwZ9raqHoBLzhB4wCnMj7nOQTj5YIRLWY= +github.com/vultr/govultr v0.4.2/go.mod h1:TUuUizMOFc7z+PNMssb6iGjKjQfpw5arIaOLfocVudQ= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= @@ -703,12 +705,15 @@ golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553 h1:efeOvDhwQ29Dj3SdAV/MJf8ou golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200202094626-16171245cfb2 h1:CCH4IOTTfewWjGOlSp+zGcjutRKlBEZQ6wTn8ozI/nI= golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e h1:3G+cUijn7XD+S4eJFddp53Pv7+slrESplyjG25HgL+k= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190130055435-99b60b757ec1/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20190402181905-9f3314589c9a/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 h1:SVwTIAaPC2U/AvvLNZ2a7OVsmBpC8L5BlwK1whH3hm0= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d h1:TzXSXBo42m9gQenoE3b9BGiEpg5IG2JkU5FkPIawgtw= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -750,6 +755,8 @@ golang.org/x/sys v0.0.0-20191026070338-33540a1f6037 h1:YyJpGZS1sBuBCzLAR1VEpK193 golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191105231009-c1f44814a5cd h1:3x5uuvBgE6oaXJjCOvpCC1IpgJogqQ+PqGGU3ZxAgII= golang.org/x/sys v0.0.0-20191105231009-c1f44814a5cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd h1:xhmwyvizuTgC2qz7ZlMluP20uW+C3Rm0FD/WLDX8884= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20171227012246-e19ae1496984/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/kustomize/external-dns-clusterrole.yaml b/kustomize/external-dns-clusterrole.yaml index 0470770ef..6dfc98c4e 100644 --- a/kustomize/external-dns-clusterrole.yaml +++ b/kustomize/external-dns-clusterrole.yaml @@ -15,3 +15,6 @@ rules: - apiGroups: [""] resources: ["nodes"] verbs: ["list"] +- apiGroups: [""] + resources: ["endpoints"] + verbs: ["get","watch","list"] diff --git a/main.go b/main.go index 17dfbb3ce..3960673e2 100644 --- a/main.go +++ b/main.go @@ -112,7 +112,6 @@ func main() { KubeConfig: cfg.KubeConfig, KubeMaster: cfg.Master, ServiceTypeFilter: cfg.ServiceTypeFilter, - IstioIngressGatewayServices: cfg.IstioIngressGatewayServices, CFAPIEndpoint: cfg.CFAPIEndpoint, CFUsername: cfg.CFUsername, CFPassword: cfg.CFPassword, diff --git a/pkg/apis/externaldns/types.go b/pkg/apis/externaldns/types.go index ef467c4fb..5e1595d82 100644 --- a/pkg/apis/externaldns/types.go +++ b/pkg/apis/externaldns/types.go @@ -41,7 +41,6 @@ type Config struct { Master string KubeConfig string RequestTimeout time.Duration - IstioIngressGatewayServices []string ContourLoadBalancerService string SkipperRouteGroupVersion string Sources []string @@ -148,7 +147,6 @@ var defaultConfig = &Config{ Master: "", KubeConfig: "", RequestTimeout: time.Second * 30, - IstioIngressGatewayServices: []string{"istio-system/istio-ingressgateway"}, ContourLoadBalancerService: "heptio-contour/contour", SkipperRouteGroupVersion: "zalando.org/v1", Sources: nil, diff --git a/provider/digitalocean/digital_ocean.go b/provider/digitalocean/digital_ocean.go index 30af3eed2..5e63c0559 100644 --- a/provider/digitalocean/digital_ocean.go +++ b/provider/digitalocean/digital_ocean.go @@ -32,13 +32,6 @@ import ( ) const ( - // DigitalOceanCreate is a ChangeAction enum value - DigitalOceanCreate = "CREATE" - // DigitalOceanDelete is a ChangeAction enum value - DigitalOceanDelete = "DELETE" - // DigitalOceanUpdate is a ChangeAction enum value - DigitalOceanUpdate = "UPDATE" - // digitalOceanRecordTTL is the default TTL value digitalOceanRecordTTL = 300 ) @@ -54,10 +47,31 @@ type DigitalOceanProvider struct { DryRun bool } -// DigitalOceanChange differentiates between ChangActions -type DigitalOceanChange struct { - Action string - ResourceRecordSet godo.DomainRecord +type digitalOceanChangeCreate struct { + Domain string + Options *godo.DomainRecordEditRequest +} + +type digitalOceanChangeUpdate struct { + Domain string + DomainRecord godo.DomainRecord + Options *godo.DomainRecordEditRequest +} + +type digitalOceanChangeDelete struct { + Domain string + RecordID int +} + +// DigitalOceanChange contains all changes to apply to DNS +type digitalOceanChanges struct { + Creates []*digitalOceanChangeCreate + Updates []*digitalOceanChangeUpdate + Deletes []*digitalOceanChangeDelete +} + +func (c *digitalOceanChanges) Empty() bool { + return len(c.Creates) == 0 && len(c.Updates) == 0 && len(c.Deletes) == 0 } // NewDigitalOceanProvider initializes a new DigitalOcean DNS based Provider. @@ -71,13 +85,13 @@ func NewDigitalOceanProvider(ctx context.Context, domainFilter endpoint.DomainFi })) client := godo.NewClient(oauthClient) - provider := &DigitalOceanProvider{ + p := &DigitalOceanProvider{ Client: client.Domains, domainFilter: domainFilter, apiPageSize: apiPageSize, DryRun: dryRun, } - return provider, nil + return p, nil } // Zones returns the list of hosted zones. @@ -98,12 +112,45 @@ func (p *DigitalOceanProvider) Zones(ctx context.Context) ([]godo.Domain, error) return result, nil } +// Merge Endpoints with the same Name and Type into a single endpoint with multiple Targets. +func mergeEndpointsByNameType(endpoints []*endpoint.Endpoint) []*endpoint.Endpoint { + endpointsByNameType := map[string][]*endpoint.Endpoint{} + + for _, e := range endpoints { + key := fmt.Sprintf("%s-%s", e.DNSName, e.RecordType) + endpointsByNameType[key] = append(endpointsByNameType[key], e) + } + + // If no merge occurred, just return the existing endpoints. + if len(endpointsByNameType) == len(endpoints) { + return endpoints + } + + // Otherwise, construct a new list of endpoints with the endpoints merged. + var result []*endpoint.Endpoint + for _, endpoints := range endpointsByNameType { + dnsName := endpoints[0].DNSName + recordType := endpoints[0].RecordType + + targets := make([]string, len(endpoints)) + for i, e := range endpoints { + targets[i] = e.Targets[0] + } + + e := endpoint.NewEndpoint(dnsName, recordType, targets...) + result = append(result, e) + } + + return result +} + // Records returns the list of records in a given zone. func (p *DigitalOceanProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, error) { zones, err := p.Zones(ctx) if err != nil { return nil, err } + endpoints := []*endpoint.Endpoint{} for _, zone := range zones { records, err := p.fetchRecords(ctx, zone.Name) @@ -121,11 +168,21 @@ func (p *DigitalOceanProvider) Records(ctx context.Context) ([]*endpoint.Endpoin name = zone.Name } - endpoints = append(endpoints, endpoint.NewEndpoint(name, r.Type, r.Data)) + ep := endpoint.NewEndpoint(name, r.Type, r.Data) + endpoints = append(endpoints, ep) } } } + // Merge endpoints with the same name and type (e.g., multiple A records for a single + // DNS name) into one endpoint with multiple targets. + endpoints = mergeEndpointsByNameType(endpoints) + + // Log the endpoints that were found. + log.WithFields(log.Fields{ + "endpoints": endpoints, + }).Debug("Endpoints generated from DigitalOcean DNS") + return endpoints, nil } @@ -179,160 +236,379 @@ func (p *DigitalOceanProvider) fetchZones(ctx context.Context) ([]godo.Domain, e return allZones, nil } -// submitChanges takes a zone and a collection of Changes and sends them as a single transaction. -func (p *DigitalOceanProvider) submitChanges(ctx context.Context, changes []*DigitalOceanChange) error { +func (p *DigitalOceanProvider) getRecordsByDomain(ctx context.Context) (map[string][]godo.DomainRecord, provider.ZoneIDName, error) { + recordsByDomain := map[string][]godo.DomainRecord{} + + zones, err := p.Zones(ctx) + if err != nil { + return nil, nil, err + } + + zonesByDomain := make(map[string]godo.Domain) + zoneNameIDMapper := provider.ZoneIDName{} + for _, z := range zones { + zoneNameIDMapper.Add(z.Name, z.Name) + zonesByDomain[z.Name] = z + } + + // Fetch records for each zone + for _, zone := range zones { + records, err := p.fetchRecords(ctx, zone.Name) + if err != nil { + return nil, nil, err + } + + recordsByDomain[zone.Name] = append(recordsByDomain[zone.Name], records...) + } + + return recordsByDomain, zoneNameIDMapper, nil +} + +// Make a DomainRecordEditRequest that conforms to DigitalOcean API requirements: +// - Records at root of the zone have `@` as the name +// - CNAME records must end in a `.` +func makeDomainEditRequest(domain, name, recordType, data string, ttl int) *godo.DomainRecordEditRequest { + // Trim the domain off the name if present. + adjustedName := strings.TrimSuffix(name, "."+domain) + + // Record at the root should be defined as @ instead of the full domain name. + if adjustedName == domain { + adjustedName = "@" + } + + // For some reason the DO API requires the '.' at the end of "data" in case of CNAME request. + // Example: {"type":"CNAME","name":"hello","data":"www.example.com."} + if recordType == endpoint.RecordTypeCNAME && !strings.HasSuffix(data, ".") { + data += "." + } + + return &godo.DomainRecordEditRequest{ + Name: adjustedName, + Type: recordType, + Data: data, + TTL: ttl, + } +} + +// submitChanges applies an instance of `digitalOceanChanges` to the DigitalOcean API. +func (p *DigitalOceanProvider) submitChanges(ctx context.Context, changes *digitalOceanChanges) error { // return early if there is nothing to change - if len(changes) == 0 { + if changes.Empty() { return nil } - zones, err := p.Zones(ctx) + for _, c := range changes.Creates { + log.WithFields(log.Fields{ + "domain": c.Domain, + "dnsName": c.Options.Name, + "recordType": c.Options.Type, + "data": c.Options.Data, + "ttl": c.Options.TTL, + }).Debug("Creating domain record") + + if p.DryRun { + continue + } + + _, _, err := p.Client.CreateRecord(ctx, c.Domain, c.Options) + if err != nil { + return err + } + } + + for _, u := range changes.Updates { + log.WithFields(log.Fields{ + "domain": u.Domain, + "dnsName": u.Options.Name, + "recordType": u.Options.Type, + "data": u.Options.Data, + "ttl": u.Options.TTL, + }).Debug("Updating domain record") + + if p.DryRun { + continue + } + + _, _, err := p.Client.EditRecord(ctx, u.Domain, u.DomainRecord.ID, u.Options) + if err != nil { + return err + } + } + + for _, d := range changes.Deletes { + log.WithFields(log.Fields{ + "domain": d.Domain, + "recordId": d.RecordID, + }).Debug("Deleting domain record") + + if p.DryRun { + continue + } + + _, err := p.Client.DeleteRecord(ctx, d.Domain, d.RecordID) + if err != nil { + return err + } + } + + return nil +} + +func getTTLFromEndpoint(ep *endpoint.Endpoint) int { + if ep.RecordTTL.IsConfigured() { + return int(ep.RecordTTL) + } + return digitalOceanRecordTTL +} + +func endpointsByZone(zoneNameIDMapper provider.ZoneIDName, endpoints []*endpoint.Endpoint) map[string][]*endpoint.Endpoint { + endpointsByZone := make(map[string][]*endpoint.Endpoint) + + for _, ep := range endpoints { + zoneID, _ := zoneNameIDMapper.FindZone(ep.DNSName) + if zoneID == "" { + log.Debugf("Skipping record %s because no hosted zone matching record DNS Name was detected", ep.DNSName) + continue + } + endpointsByZone[zoneID] = append(endpointsByZone[zoneID], ep) + } + + return endpointsByZone +} + +func getMatchingDomainRecords(records []godo.DomainRecord, domain string, ep *endpoint.Endpoint) []godo.DomainRecord { + var name string + if ep.DNSName != domain { + name = strings.TrimSuffix(ep.DNSName, "."+domain) + } else { + name = "@" + } + + var result []godo.DomainRecord + for _, r := range records { + if r.Name == name && r.Type == ep.RecordType { + result = append(result, r) + } + } + return result +} + +func processCreateActions( + recordsByDomain map[string][]godo.DomainRecord, + createsByDomain map[string][]*endpoint.Endpoint, + changes *digitalOceanChanges, +) error { + // Process endpoints that need to be created. + for domain, endpoints := range createsByDomain { + if len(endpoints) == 0 { + log.WithFields(log.Fields{ + "domain": domain, + }).Debug("Skipping domain, no creates found.") + continue + } + + records := recordsByDomain[domain] + + for _, ep := range endpoints { + // Warn if there are existing records since we expect to create only new records. + matchingRecords := getMatchingDomainRecords(records, domain, ep) + if len(matchingRecords) > 0 { + log.WithFields(log.Fields{ + "domain": domain, + "dnsName": ep.DNSName, + "recordType": ep.RecordType, + }).Warn("Preexisting records exist which should not exist for creation actions.") + } + + ttl := getTTLFromEndpoint(ep) + + for _, target := range ep.Targets { + changes.Creates = append(changes.Creates, &digitalOceanChangeCreate{ + Domain: domain, + Options: makeDomainEditRequest(domain, ep.DNSName, ep.RecordType, target, ttl), + }) + } + } + } + + return nil +} + +func processUpdateActions( + recordsByDomain map[string][]godo.DomainRecord, + updatesByDomain map[string][]*endpoint.Endpoint, + changes *digitalOceanChanges, +) error { + // Generate creates and updates based on existing + for domain, updates := range updatesByDomain { + if len(updates) == 0 { + log.WithFields(log.Fields{ + "domain": domain, + }).Debug("Skipping Zone, no updates found.") + continue + } + + records := recordsByDomain[domain] + log.WithFields(log.Fields{ + "domain": domain, + "records": records, + }).Debug("Records for domain") + + for _, ep := range updates { + matchingRecords := getMatchingDomainRecords(records, domain, ep) + + log.WithFields(log.Fields{ + "endpoint": ep, + "matchingRecords": matchingRecords, + }).Debug("matching records") + + if len(matchingRecords) == 0 { + log.WithFields(log.Fields{ + "domain": domain, + "dnsName": ep.DNSName, + "recordType": ep.RecordType, + }).Warn("Planning an update but no existing records found.") + } + + matchingRecordsByTarget := map[string]godo.DomainRecord{} + for _, r := range matchingRecords { + matchingRecordsByTarget[r.Data] = r + } + + ttl := getTTLFromEndpoint(ep) + + // Generate create and delete actions based on existence of a record for each target. + for _, target := range ep.Targets { + if record, ok := matchingRecordsByTarget[target]; ok { + log.WithFields(log.Fields{ + "domain": domain, + "dnsName": ep.DNSName, + "recordType": ep.RecordType, + "target": target, + }).Warn("Updating existing target") + + changes.Updates = append(changes.Updates, &digitalOceanChangeUpdate{ + Domain: domain, + DomainRecord: record, + Options: makeDomainEditRequest(domain, ep.DNSName, ep.RecordType, target, ttl), + }) + + delete(matchingRecordsByTarget, target) + } else { + // Record did not previously exist, create new 'target' + log.WithFields(log.Fields{ + "domain": domain, + "dnsName": ep.DNSName, + "recordType": ep.RecordType, + "target": target, + }).Warn("Creating new target") + + changes.Creates = append(changes.Creates, &digitalOceanChangeCreate{ + Domain: domain, + Options: makeDomainEditRequest(domain, ep.DNSName, ep.RecordType, target, ttl), + }) + } + } + + // Any remaining records have been removed, delete them + for _, record := range matchingRecordsByTarget { + log.WithFields(log.Fields{ + "domain": domain, + "dnsName": ep.DNSName, + "recordType": ep.RecordType, + "target": record.Data, + }).Warn("Deleting target") + + changes.Deletes = append(changes.Deletes, &digitalOceanChangeDelete{ + Domain: domain, + RecordID: record.ID, + }) + } + } + } + + return nil +} + +func processDeleteActions( + recordsByDomain map[string][]godo.DomainRecord, + deletesByDomain map[string][]*endpoint.Endpoint, + changes *digitalOceanChanges, +) error { + // Generate delete actions for each deleted endpoint. + for domain, deletes := range deletesByDomain { + if len(deletes) == 0 { + log.WithFields(log.Fields{ + "domain": domain, + }).Debug("Skipping Zone, no deletes found.") + continue + } + + records := recordsByDomain[domain] + + for _, ep := range deletes { + matchingRecords := getMatchingDomainRecords(records, domain, ep) + + if len(matchingRecords) == 0 { + log.WithFields(log.Fields{ + "domain": domain, + "dnsName": ep.DNSName, + "recordType": ep.RecordType, + }).Warn("Records to delete not found.") + } + + for _, record := range matchingRecords { + doDelete := false + for _, t := range ep.Targets { + v1 := t + v2 := record.Data + if ep.RecordType == endpoint.RecordTypeCNAME { + v1 = strings.TrimSuffix(t, ".") + v2 = strings.TrimSuffix(t, ".") + } + if v1 == v2 { + doDelete = true + } + } + + if doDelete { + changes.Deletes = append(changes.Deletes, &digitalOceanChangeDelete{ + Domain: domain, + RecordID: record.ID, + }) + } + } + } + } + + return nil +} + +// ApplyChanges applies the given set of generic changes to the provider. +func (p *DigitalOceanProvider) ApplyChanges(ctx context.Context, planChanges *plan.Changes) error { + // TODO: This should only retrieve zones affected by the given `planChanges`. + recordsByDomain, zoneNameIDMapper, err := p.getRecordsByDomain(ctx) if err != nil { return err } - // separate into per-zone change sets to be passed to the API. - changesByZone := digitalOceanChangesByZone(zones, changes) - for zoneName, changes := range changesByZone { - records, err := p.fetchRecords(ctx, zoneName) - if err != nil { - log.Errorf("Failed to list records in the zone: %s", zoneName) - continue - } - for _, change := range changes { - logFields := log.Fields{ - "record": change.ResourceRecordSet.Name, - "type": change.ResourceRecordSet.Type, - "ttl": change.ResourceRecordSet.TTL, - "action": change.Action, - "zone": zoneName, - } + createsByDomain := endpointsByZone(zoneNameIDMapper, planChanges.Create) + updatesByDomain := endpointsByZone(zoneNameIDMapper, planChanges.UpdateNew) + deletesByDomain := endpointsByZone(zoneNameIDMapper, planChanges.Delete) - log.WithFields(logFields).Info("Changing record.") + var changes digitalOceanChanges - if p.DryRun { - continue - } - - change.ResourceRecordSet.Name = strings.TrimSuffix(change.ResourceRecordSet.Name, "."+zoneName) - - // record at the root should be defined as @ instead of - // the full domain name - if change.ResourceRecordSet.Name == zoneName { - change.ResourceRecordSet.Name = "@" - } - - // for some reason the DO API requires the '.' at the end of "data" in case of CNAME request - // Example: {"type":"CNAME","name":"hello","data":"www.example.com."} - if change.ResourceRecordSet.Type == endpoint.RecordTypeCNAME { - change.ResourceRecordSet.Data += "." - } - - switch change.Action { - case DigitalOceanCreate: - _, _, err = p.Client.CreateRecord(ctx, zoneName, - &godo.DomainRecordEditRequest{ - Data: change.ResourceRecordSet.Data, - Name: change.ResourceRecordSet.Name, - Type: change.ResourceRecordSet.Type, - TTL: change.ResourceRecordSet.TTL, - }) - if err != nil { - return err - } - case DigitalOceanDelete: - recordID := p.getRecordID(records, change.ResourceRecordSet) - _, err = p.Client.DeleteRecord(ctx, zoneName, recordID) - if err != nil { - return err - } - case DigitalOceanUpdate: - recordID := p.getRecordID(records, change.ResourceRecordSet) - _, _, err = p.Client.EditRecord(ctx, zoneName, recordID, - &godo.DomainRecordEditRequest{ - Data: change.ResourceRecordSet.Data, - Name: change.ResourceRecordSet.Name, - Type: change.ResourceRecordSet.Type, - TTL: change.ResourceRecordSet.TTL, - }) - if err != nil { - return err - } - } - } + if err := processCreateActions(recordsByDomain, createsByDomain, &changes); err != nil { + return err } - return nil -} - -// ApplyChanges applies a given set of changes in a given zone. -func (p *DigitalOceanProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { - combinedChanges := make([]*DigitalOceanChange, 0, len(changes.Create)+len(changes.UpdateNew)+len(changes.Delete)) - - combinedChanges = append(combinedChanges, newDigitalOceanChanges(DigitalOceanCreate, changes.Create)...) - combinedChanges = append(combinedChanges, newDigitalOceanChanges(DigitalOceanUpdate, changes.UpdateNew)...) - combinedChanges = append(combinedChanges, newDigitalOceanChanges(DigitalOceanDelete, changes.Delete)...) - - return p.submitChanges(ctx, combinedChanges) -} - -// newDigitalOceanChanges returns a collection of Changes based on the given records and action. -func newDigitalOceanChanges(action string, endpoints []*endpoint.Endpoint) []*DigitalOceanChange { - changes := make([]*DigitalOceanChange, 0, len(endpoints)) - - for _, endpoint := range endpoints { - changes = append(changes, newDigitalOceanChange(action, endpoint)) - } - - return changes -} - -func newDigitalOceanChange(action string, endpoint *endpoint.Endpoint) *DigitalOceanChange { - // no annotation results in a TTL of 0, default to 300 for consistency with other providers - var ttl = digitalOceanRecordTTL - if endpoint.RecordTTL.IsConfigured() { - ttl = int(endpoint.RecordTTL) - } - - change := &DigitalOceanChange{ - Action: action, - ResourceRecordSet: godo.DomainRecord{ - Name: endpoint.DNSName, - Type: endpoint.RecordType, - Data: endpoint.Targets[0], - TTL: ttl, - }, - } - return change -} - -// getRecordID returns the ID from a record. -// the ID is mandatory to update and delete records -func (p *DigitalOceanProvider) getRecordID(records []godo.DomainRecord, record godo.DomainRecord) int { - for _, zoneRecord := range records { - if zoneRecord.Name == record.Name && zoneRecord.Type == record.Type { - return zoneRecord.ID - } - } - return 0 -} - -// digitalOceanchangesByZone separates a multi-zone change into a single change per zone. -func digitalOceanChangesByZone(zones []godo.Domain, changeSet []*DigitalOceanChange) map[string][]*DigitalOceanChange { - changes := make(map[string][]*DigitalOceanChange) - zoneNameIDMapper := provider.ZoneIDName{} - for _, z := range zones { - zoneNameIDMapper.Add(z.Name, z.Name) - changes[z.Name] = []*DigitalOceanChange{} - } - - for _, c := range changeSet { - zone, _ := zoneNameIDMapper.FindZone(c.ResourceRecordSet.Name) - if zone == "" { - log.Debugf("Skipping record %s because no hosted zone matching record DNS Name was detected", c.ResourceRecordSet.Name) - continue - } - changes[zone] = append(changes[zone], c) - } - - return changes + + if err := processUpdateActions(recordsByDomain, updatesByDomain, &changes); err != nil { + return err + } + + if err := processDeleteActions(recordsByDomain, deletesByDomain, &changes); err != nil { + return err + } + + return p.submitChanges(ctx, &changes) } diff --git a/provider/digitalocean/digital_ocean_test.go b/provider/digitalocean/digital_ocean_test.go index 421b46fe3..939a38fe0 100644 --- a/provider/digitalocean/digital_ocean_test.go +++ b/provider/digitalocean/digital_ocean_test.go @@ -20,9 +20,12 @@ import ( "context" "fmt" "os" + "reflect" + "sort" "testing" "github.com/digitalocean/godo" + "github.com/google/go-cmp/cmp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -143,10 +146,111 @@ func (m *mockDigitalOceanRecordsFail) Records(ctx context.Context, domain string return []godo.DomainRecord{}, nil, fmt.Errorf("Failed to get records") } -func TestNewDigitalOceanChanges(t *testing.T) { - action := DigitalOceanCreate - endpoints := []*endpoint.Endpoint{{DNSName: "new", Targets: endpoint.Targets{"target"}}} - _ = newDigitalOceanChanges(action, endpoints) +func isEmpty(xs interface{}) bool { + if xs != nil { + objValue := reflect.ValueOf(xs) + return objValue.Len() == 0 + } + return true +} + +// This function is an adapted copy of the testify package's ElementsMatch function with the +// call to ObjectsAreEqual replaced with cmp.Equal which better handles struct's with pointers to +// other structs. It also ignores ordering when comparing unlike cmp.Equal. +func elementsMatch(t *testing.T, listA, listB interface{}, msgAndArgs ...interface{}) (ok bool) { + if listA == nil && listB == nil { + return true + } else if listA == nil { + return isEmpty(listB) + } else if listB == nil { + return isEmpty(listA) + } + + aKind := reflect.TypeOf(listA).Kind() + bKind := reflect.TypeOf(listB).Kind() + + if aKind != reflect.Array && aKind != reflect.Slice { + return assert.Fail(t, fmt.Sprintf("%q has an unsupported type %s", listA, aKind), msgAndArgs...) + } + + if bKind != reflect.Array && bKind != reflect.Slice { + return assert.Fail(t, fmt.Sprintf("%q has an unsupported type %s", listB, bKind), msgAndArgs...) + } + + aValue := reflect.ValueOf(listA) + bValue := reflect.ValueOf(listB) + + aLen := aValue.Len() + bLen := bValue.Len() + + if aLen != bLen { + return assert.Fail(t, fmt.Sprintf("lengths don't match: %d != %d", aLen, bLen), msgAndArgs...) + } + + // Mark indexes in bValue that we already used + visited := make([]bool, bLen) + for i := 0; i < aLen; i++ { + element := aValue.Index(i).Interface() + found := false + for j := 0; j < bLen; j++ { + if visited[j] { + continue + } + if cmp.Equal(bValue.Index(j).Interface(), element) { + visited[j] = true + found = true + break + } + } + if !found { + return assert.Fail(t, fmt.Sprintf("element %s appears more times in %s than in %s", element, aValue, bValue), msgAndArgs...) + } + } + + return true +} + +// Test adapted from test in testify library. +// https://github.com/stretchr/testify/blob/b8f7d52a4a7c581d5ed42333572e7fb857c687c2/assert/assertions_test.go#L768-L796 +func TestElementsMatch(t *testing.T) { + mockT := new(testing.T) + + cases := []struct { + expected interface{} + actual interface{} + result bool + }{ + // matching + {nil, nil, true}, + + {nil, nil, true}, + {[]int{}, []int{}, true}, + {[]int{1}, []int{1}, true}, + {[]int{1, 1}, []int{1, 1}, true}, + {[]int{1, 2}, []int{1, 2}, true}, + {[]int{1, 2}, []int{2, 1}, true}, + {[2]int{1, 2}, [2]int{2, 1}, true}, + {[]string{"hello", "world"}, []string{"world", "hello"}, true}, + {[]string{"hello", "hello"}, []string{"hello", "hello"}, true}, + {[]string{"hello", "hello", "world"}, []string{"hello", "world", "hello"}, true}, + {[3]string{"hello", "hello", "world"}, [3]string{"hello", "world", "hello"}, true}, + {[]int{}, nil, true}, + + // not matching + {[]int{1}, []int{1, 1}, false}, + {[]int{1, 2}, []int{2, 2}, false}, + {[]string{"hello", "hello"}, []string{"hello"}, false}, + } + + for _, c := range cases { + t.Run(fmt.Sprintf("ElementsMatch(%#v, %#v)", c.expected, c.actual), func(t *testing.T) { + res := elementsMatch(mockT, c.actual, c.expected) + + if res != c.result { + t.Errorf("elementsMatch(%#v, %#v) should return %v", c.actual, c.expected, c.result) + } + }) + } } func TestDigitalOceanZones(t *testing.T) { @@ -165,6 +269,38 @@ func TestDigitalOceanZones(t *testing.T) { }) } +func TestDigitalOceanMakeDomainEditRequest(t *testing.T) { + // Ensure that records at the root of the zone get `@` as the name. + r1 := makeDomainEditRequest("example.com", "example.com", endpoint.RecordTypeA, + "1.2.3.4", digitalOceanRecordTTL) + assert.Equal(t, &godo.DomainRecordEditRequest{ + Type: endpoint.RecordTypeA, + Name: "@", + Data: "1.2.3.4", + TTL: digitalOceanRecordTTL, + }, r1) + + // Ensure the CNAME records have a `.` appended. + r2 := makeDomainEditRequest("example.com", "foo.example.com", endpoint.RecordTypeCNAME, + "bar.example.com", digitalOceanRecordTTL) + assert.Equal(t, &godo.DomainRecordEditRequest{ + Type: endpoint.RecordTypeCNAME, + Name: "foo", + Data: "bar.example.com.", + TTL: digitalOceanRecordTTL, + }, r2) + + // Ensure that CNAME records do not have an extra `.` appended if they already have a `.` + r3 := makeDomainEditRequest("example.com", "foo.example.com", endpoint.RecordTypeCNAME, + "bar.example.com.", digitalOceanRecordTTL) + assert.Equal(t, &godo.DomainRecordEditRequest{ + Type: endpoint.RecordTypeCNAME, + Name: "foo", + Data: "bar.example.com.", + TTL: digitalOceanRecordTTL, + }, r3) +} + func TestDigitalOceanApplyChanges(t *testing.T) { changes := &plan.Changes{} provider := &DigitalOceanProvider{ @@ -185,6 +321,198 @@ func TestDigitalOceanApplyChanges(t *testing.T) { } } +func TestDigitalOceanProcessCreateActions(t *testing.T) { + recordsByDomain := map[string][]godo.DomainRecord{ + "example.com": nil, + } + + createsByDomain := map[string][]*endpoint.Endpoint{ + "example.com": { + endpoint.NewEndpoint("foo.example.com", endpoint.RecordTypeA, "1.2.3.4"), + endpoint.NewEndpoint("example.com", endpoint.RecordTypeCNAME, "foo.example.com"), + }, + } + + var changes digitalOceanChanges + err := processCreateActions(recordsByDomain, createsByDomain, &changes) + require.NoError(t, err) + + assert.Equal(t, 2, len(changes.Creates)) + assert.Equal(t, 0, len(changes.Updates)) + assert.Equal(t, 0, len(changes.Deletes)) + + expectedCreates := []*digitalOceanChangeCreate{ + { + Domain: "example.com", + Options: &godo.DomainRecordEditRequest{ + Name: "foo", + Type: endpoint.RecordTypeA, + Data: "1.2.3.4", + TTL: digitalOceanRecordTTL, + }, + }, + { + Domain: "example.com", + Options: &godo.DomainRecordEditRequest{ + Name: "@", + Type: endpoint.RecordTypeCNAME, + Data: "foo.example.com.", + TTL: digitalOceanRecordTTL, + }, + }, + } + + if !elementsMatch(t, expectedCreates, changes.Creates) { + assert.Failf(t, "diff: %s", cmp.Diff(expectedCreates, changes.Creates)) + } +} + +func TestDigitalOceanProcessUpdateActions(t *testing.T) { + recordsByDomain := map[string][]godo.DomainRecord{ + "example.com": { + { + ID: 1, + Name: "foo", + Type: endpoint.RecordTypeA, + Data: "1.2.3.4", + TTL: digitalOceanRecordTTL, + }, + { + ID: 2, + Name: "foo", + Type: endpoint.RecordTypeA, + Data: "5.6.7.8", + TTL: digitalOceanRecordTTL, + }, + { + ID: 3, + Name: "@", + Type: endpoint.RecordTypeCNAME, + Data: "foo.example.com.", + TTL: digitalOceanRecordTTL, + }, + }, + } + + updatesByDomain := map[string][]*endpoint.Endpoint{ + "example.com": { + endpoint.NewEndpoint("foo.example.com", endpoint.RecordTypeA, "10.11.12.13"), + endpoint.NewEndpoint("example.com", endpoint.RecordTypeCNAME, "bar.example.com"), + }, + } + + var changes digitalOceanChanges + err := processUpdateActions(recordsByDomain, updatesByDomain, &changes) + require.NoError(t, err) + + assert.Equal(t, 2, len(changes.Creates)) + assert.Equal(t, 0, len(changes.Updates)) + assert.Equal(t, 3, len(changes.Deletes)) + + expectedCreates := []*digitalOceanChangeCreate{ + { + Domain: "example.com", + Options: &godo.DomainRecordEditRequest{ + Name: "foo", + Type: endpoint.RecordTypeA, + Data: "10.11.12.13", + TTL: digitalOceanRecordTTL, + }, + }, + { + Domain: "example.com", + Options: &godo.DomainRecordEditRequest{ + Name: "@", + Type: endpoint.RecordTypeCNAME, + Data: "bar.example.com.", + TTL: digitalOceanRecordTTL, + }, + }, + } + + if !elementsMatch(t, expectedCreates, changes.Creates) { + assert.Failf(t, "diff: %s", cmp.Diff(expectedCreates, changes.Creates)) + } + + expectedDeletes := []*digitalOceanChangeDelete{ + { + Domain: "example.com", + RecordID: 1, + }, + { + Domain: "example.com", + RecordID: 2, + }, + { + Domain: "example.com", + RecordID: 3, + }, + } + + if !elementsMatch(t, expectedDeletes, changes.Deletes) { + assert.Failf(t, "diff: %s", cmp.Diff(expectedDeletes, changes.Deletes)) + } +} + +func TestDigitalOceanProcessDeleteActions(t *testing.T) { + recordsByDomain := map[string][]godo.DomainRecord{ + "example.com": { + { + ID: 1, + Name: "foo", + Type: endpoint.RecordTypeA, + Data: "1.2.3.4", + TTL: digitalOceanRecordTTL, + }, + // This record will not be deleted because it represents a target not specified to be deleted. + { + ID: 2, + Name: "foo", + Type: endpoint.RecordTypeA, + Data: "5.6.7.8", + TTL: digitalOceanRecordTTL, + }, + { + ID: 3, + Name: "@", + Type: endpoint.RecordTypeCNAME, + Data: "foo.example.com.", + TTL: digitalOceanRecordTTL, + }, + }, + } + + deletesByDomain := map[string][]*endpoint.Endpoint{ + "example.com": { + endpoint.NewEndpoint("foo.example.com", endpoint.RecordTypeA, "1.2.3.4"), + endpoint.NewEndpoint("example.com", endpoint.RecordTypeCNAME, "foo.example.com"), + }, + } + + var changes digitalOceanChanges + err := processDeleteActions(recordsByDomain, deletesByDomain, &changes) + require.NoError(t, err) + + assert.Equal(t, 0, len(changes.Creates)) + assert.Equal(t, 0, len(changes.Updates)) + assert.Equal(t, 2, len(changes.Deletes)) + + expectedDeletes := []*digitalOceanChangeDelete{ + { + Domain: "example.com", + RecordID: 1, + }, + { + Domain: "example.com", + RecordID: 3, + }, + } + + if !elementsMatch(t, expectedDeletes, changes.Deletes) { + assert.Failf(t, "diff: %s", cmp.Diff(expectedDeletes, changes.Deletes)) + } +} + func TestNewDigitalOceanProvider(t *testing.T) { _ = os.Setenv("DO_TOKEN", "xxxxxxxxxxxxxxxxx") _, err := NewDigitalOceanProvider(context.Background(), endpoint.NewDomainFilter([]string{"ext-dns-test.zalando.to."}), true, 50) @@ -198,29 +526,62 @@ func TestNewDigitalOceanProvider(t *testing.T) { } } -func TestDigitalOceanGetRecordID(t *testing.T) { - p := &DigitalOceanProvider{} +func TestDigitalOceanGetMatchingDomainRecords(t *testing.T) { records := []godo.DomainRecord{ { ID: 1, - Name: "foo.com", + Name: "foo", Type: endpoint.RecordTypeCNAME, + Data: "baz.org.", }, { ID: 2, - Name: "baz.de", + Name: "baz", Type: endpoint.RecordTypeA, + Data: "1.2.3.4", + }, + { + ID: 3, + Name: "baz", + Type: endpoint.RecordTypeA, + Data: "5.6.7.8", + }, + { + ID: 4, + Name: "@", + Type: endpoint.RecordTypeA, + Data: "9.10.11.12", }, } - assert.Equal(t, 1, p.getRecordID(records, godo.DomainRecord{ - Name: "foo.com", - Type: endpoint.RecordTypeCNAME, - })) - assert.Equal(t, 0, p.getRecordID(records, godo.DomainRecord{ - Name: "foo.com", - Type: endpoint.RecordTypeA, - })) + ep1 := endpoint.NewEndpoint("foo.com", endpoint.RecordTypeCNAME) + assert.Equal(t, 1, len(getMatchingDomainRecords(records, "com", ep1))) + + ep2 := endpoint.NewEndpoint("foo.com", endpoint.RecordTypeA) + assert.Equal(t, 0, len(getMatchingDomainRecords(records, "com", ep2))) + + ep3 := endpoint.NewEndpoint("baz.org", endpoint.RecordTypeA) + r := getMatchingDomainRecords(records, "org", ep3) + assert.Equal(t, 2, len(r)) + assert.ElementsMatch(t, r, []godo.DomainRecord{ + { + ID: 2, + Name: "baz", + Type: endpoint.RecordTypeA, + Data: "1.2.3.4", + }, + { + ID: 3, + Name: "baz", + Type: endpoint.RecordTypeA, + Data: "5.6.7.8", + }, + }) + + ep4 := endpoint.NewEndpoint("example.com", endpoint.RecordTypeA) + r2 := getMatchingDomainRecords(records, "example.com", ep4) + assert.Equal(t, 1, len(r2)) + assert.Equal(t, "9.10.11.12", r2[0].Data) } func validateDigitalOceanZones(t *testing.T, zones []godo.Domain, expected []godo.Domain) { @@ -265,3 +626,36 @@ func TestDigitalOceanAllRecords(t *testing.T) { t.Errorf("expected to fail, %s", err) } } + +func TestDigitalOceanMergeRecordsByNameType(t *testing.T) { + xs := []*endpoint.Endpoint{ + endpoint.NewEndpoint("foo.example.com", "A", "1.2.3.4"), + endpoint.NewEndpoint("bar.example.com", "A", "1.2.3.4"), + endpoint.NewEndpoint("foo.example.com", "A", "5.6.7.8"), + endpoint.NewEndpoint("foo.example.com", "CNAME", "somewhere.out.there.com"), + } + + merged := mergeEndpointsByNameType(xs) + + assert.Equal(t, 3, len(merged)) + sort.SliceStable(merged, func(i, j int) bool { + if merged[i].DNSName != merged[j].DNSName { + return merged[i].DNSName < merged[j].DNSName + } + return merged[i].RecordType < merged[j].RecordType + }) + assert.Equal(t, "bar.example.com", merged[0].DNSName) + assert.Equal(t, "A", merged[0].RecordType) + assert.Equal(t, 1, len(merged[0].Targets)) + assert.Equal(t, "1.2.3.4", merged[0].Targets[0]) + + assert.Equal(t, "foo.example.com", merged[1].DNSName) + assert.Equal(t, "A", merged[1].RecordType) + assert.Equal(t, 2, len(merged[1].Targets)) + assert.ElementsMatch(t, []string{"1.2.3.4", "5.6.7.8"}, merged[1].Targets) + + assert.Equal(t, "foo.example.com", merged[2].DNSName) + assert.Equal(t, "CNAME", merged[2].RecordType) + assert.Equal(t, 1, len(merged[2].Targets)) + assert.Equal(t, "somewhere.out.there.com", merged[2].Targets[0]) +} diff --git a/provider/vultr/vultr.go b/provider/vultr/vultr.go index 5de422421..cc7a04211 100644 --- a/provider/vultr/vultr.go +++ b/provider/vultr/vultr.go @@ -157,16 +157,18 @@ func (p *VultrProvider) submitChanges(ctx context.Context, changes []*VultrChang for zoneName, changes := range zoneChanges { for _, change := range changes { log.WithFields(log.Fields{ - "record": change.ResourceRecordSet.Name, - "type": change.ResourceRecordSet.Type, - "ttl": change.ResourceRecordSet.TTL, - "action": change.Action, - "zone": zoneName, + "record": change.ResourceRecordSet.Name, + "type": change.ResourceRecordSet.Type, + "ttl": change.ResourceRecordSet.TTL, + "priority": change.ResourceRecordSet.Priority, + "action": change.Action, + "zone": zoneName, }).Info("Changing record.") switch change.Action { case vultrCreate: - err = p.client.DNSRecord.Create(ctx, zoneName, change.ResourceRecordSet.Type, change.ResourceRecordSet.Name, change.ResourceRecordSet.Data, change.ResourceRecordSet.TTL, change.ResourceRecordSet.Priority) + priority := getPriority(change.ResourceRecordSet.Priority) + err = p.client.DNSRecord.Create(ctx, zoneName, change.ResourceRecordSet.Type, change.ResourceRecordSet.Name, change.ResourceRecordSet.Data, change.ResourceRecordSet.TTL, priority) if err != nil { return err } @@ -276,3 +278,11 @@ func (p *VultrProvider) getRecordID(ctx context.Context, zone string, record gov return 0, fmt.Errorf("no record was found") } + +func getPriority(priority *int) int { + p := 0 + if priority != nil { + p = *priority + } + return p +} diff --git a/source/store.go b/source/store.go index 070ef9725..92bb7dd87 100644 --- a/source/store.go +++ b/source/store.go @@ -55,7 +55,6 @@ type Config struct { KubeConfig string KubeMaster string ServiceTypeFilter []string - IstioIngressGatewayServices []string CFAPIEndpoint string CFUsername string CFPassword string diff --git a/source/store_test.go b/source/store_test.go index 15652ddcd..8b6792f7c 100644 --- a/source/store_test.go +++ b/source/store_test.go @@ -155,6 +155,5 @@ func TestByNames(t *testing.T) { } var minimalConfig = &Config{ - IstioIngressGatewayServices: []string{"istio-system/istio-ingressgateway"}, ContourLoadBalancerService: "heptio-contour/contour", }