external-dns/provider/digitalocean/digital_ocean.go

662 lines
17 KiB
Go

/*
Copyright 2017 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package digitalocean
import (
"context"
"fmt"
"os"
"strings"
"github.com/digitalocean/godo"
log "github.com/sirupsen/logrus"
"golang.org/x/oauth2"
"sigs.k8s.io/external-dns/endpoint"
"sigs.k8s.io/external-dns/pkg/apis/externaldns"
"sigs.k8s.io/external-dns/plan"
"sigs.k8s.io/external-dns/provider"
)
const (
// defaultTTL is the default TTL value
defaultTTL = 300
)
// DigitalOceanProvider is an implementation of Provider for Digital Ocean's DNS.
type DigitalOceanProvider struct {
provider.BaseProvider
Client godo.DomainsService
// only consider hosted zones managing domains ending in this suffix
domainFilter *endpoint.DomainFilter
// page size when querying paginated APIs
apiPageSize int
DryRun bool
}
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.
func NewDigitalOceanProvider(ctx context.Context, domainFilter *endpoint.DomainFilter, dryRun bool, apiPageSize int) (*DigitalOceanProvider, error) {
token, ok := os.LookupEnv("DO_TOKEN")
if !ok {
return nil, fmt.Errorf("no token found")
}
oauthClient := oauth2.NewClient(ctx, oauth2.StaticTokenSource(&oauth2.Token{
AccessToken: token,
}))
client, err := godo.New(oauthClient, godo.SetUserAgent(externaldns.UserAgent()))
if err != nil {
return nil, err
}
p := &DigitalOceanProvider{
Client: client.Domains,
domainFilter: domainFilter,
apiPageSize: apiPageSize,
DryRun: dryRun,
}
return p, nil
}
// Zones returns the list of hosted zones.
func (p *DigitalOceanProvider) Zones(ctx context.Context) ([]godo.Domain, error) {
result := []godo.Domain{}
zones, err := p.fetchZones(ctx)
if err != nil {
return nil, err
}
for _, zone := range zones {
if p.domainFilter.Match(zone.Name) {
result = append(result, zone)
}
}
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)
if err != nil {
return nil, err
}
for _, r := range records {
if p.SupportedRecordType(r.Type) {
name := r.Name + "." + zone.Name
data := r.Data
// root name is identified by @ and should be
// translated to zone name for the endpoint entry.
if r.Name == "@" {
name = zone.Name
}
if r.Type == endpoint.RecordTypeMX {
data = fmt.Sprintf("%d %s", r.Priority, r.Data)
}
ep := endpoint.NewEndpointWithTTL(name, r.Type, endpoint.TTL(r.TTL), 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
}
func (p *DigitalOceanProvider) fetchRecords(ctx context.Context, zoneName string) ([]godo.DomainRecord, error) {
allRecords := []godo.DomainRecord{}
listOptions := &godo.ListOptions{PerPage: p.apiPageSize}
for {
records, resp, err := p.Client.Records(ctx, zoneName, listOptions)
if err != nil {
return nil, err
}
allRecords = append(allRecords, records...)
if resp == nil || resp.Links == nil || resp.Links.IsLastPage() {
break
}
page, err := resp.Links.CurrentPage()
if err != nil {
return nil, err
}
listOptions.Page = page + 1
}
return allRecords, nil
}
func (p *DigitalOceanProvider) fetchZones(ctx context.Context) ([]godo.Domain, error) {
allZones := []godo.Domain{}
listOptions := &godo.ListOptions{PerPage: p.apiPageSize}
for {
zones, resp, err := p.Client.List(ctx, listOptions)
if err != nil {
return nil, err
}
allZones = append(allZones, zones...)
if resp == nil || resp.Links == nil || resp.Links.IsLastPage() {
break
}
page, err := resp.Links.CurrentPage()
if err != nil {
return nil, err
}
listOptions.Page = page + 1
}
return allZones, nil
}
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 || recordType == endpoint.RecordTypeMX) && !strings.HasSuffix(data, ".") {
data += "."
}
request := &godo.DomainRecordEditRequest{
Name: adjustedName,
Type: recordType,
Data: data,
TTL: ttl,
}
if recordType == endpoint.RecordTypeMX {
mxRecord, err := endpoint.NewMXRecord(data)
if err != nil {
log.WithFields(log.Fields{
"domain": domain,
"dnsName": name,
"recordType": recordType,
"data": data,
}).Warn("Unable to parse MX target")
return request
}
request.Priority = int(*mxRecord.GetPriority())
request.Data = provider.EnsureTrailingDot(*mxRecord.GetHost())
}
return request
}
// 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 changes.Empty() {
return nil
}
for _, c := range changes.Creates {
logFields := log.Fields{
"domain": c.Domain,
"dnsName": c.Options.Name,
"recordType": c.Options.Type,
"data": c.Options.Data,
"ttl": c.Options.TTL,
}
if c.Options.Type == endpoint.RecordTypeMX {
logFields["priority"] = c.Options.Priority
}
log.WithFields(logFields).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 {
logFields := log.Fields{
"domain": u.Domain,
"dnsName": u.Options.Name,
"recordType": u.Options.Type,
"data": u.Options.Data,
"ttl": u.Options.TTL,
}
if u.Options.Type == endpoint.RecordTypeMX {
logFields["priority"] = u.Options.Priority
}
log.WithFields(logFields).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 defaultTTL
}
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
}
// SupportedRecordType returns true if the record type is supported by the provider
func (p *DigitalOceanProvider) SupportedRecordType(recordType string) bool {
switch recordType {
case "MX":
return true
default:
return provider.SupportedRecordType(recordType)
}
}
// 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
}
createsByDomain := endpointsByZone(zoneNameIDMapper, planChanges.Create)
updatesByDomain := endpointsByZone(zoneNameIDMapper, planChanges.UpdateNew())
deletesByDomain := endpointsByZone(zoneNameIDMapper, planChanges.Delete)
var changes digitalOceanChanges
if err := processCreateActions(recordsByDomain, createsByDomain, &changes); err != nil {
return err
}
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)
}