mirror of
https://github.com/kubernetes-sigs/external-dns.git
synced 2025-08-05 17:16:59 +02:00
441 lines
15 KiB
Go
441 lines
15 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 plan
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/google/go-cmp/cmp"
|
|
log "github.com/sirupsen/logrus"
|
|
|
|
"sigs.k8s.io/external-dns/endpoint"
|
|
"sigs.k8s.io/external-dns/internal/idna"
|
|
)
|
|
|
|
// PropertyComparator is used in Plan for comparing the previous and current custom annotations.
|
|
type PropertyComparator func(name string, previous string, current string) bool
|
|
|
|
// Plan can convert a list of desired and current records to a series of create,
|
|
// update and delete actions.
|
|
type Plan struct {
|
|
// List of current records
|
|
Current []*endpoint.Endpoint
|
|
// List of desired records
|
|
Desired []*endpoint.Endpoint
|
|
// Policies under which the desired changes are calculated
|
|
Policies []Policy
|
|
// List of changes necessary to move towards desired state
|
|
// Populated after calling Calculate()
|
|
Changes *Changes
|
|
// DomainFilter matches DNS names
|
|
DomainFilter endpoint.MatchAllDomainFilters
|
|
// ManagedRecords are DNS record types that will be considered for management.
|
|
ManagedRecords []string
|
|
// ExcludeRecords are DNS record types that will be excluded from management.
|
|
ExcludeRecords []string
|
|
// OwnerID of records to manage
|
|
OwnerID string
|
|
}
|
|
|
|
// Changes holds lists of actions to be executed by dns providers
|
|
type Changes struct {
|
|
// Records that need to be created
|
|
Create []*endpoint.Endpoint
|
|
// Records that need to be updated
|
|
Update []*Update
|
|
// Records that need to be deleted
|
|
Delete []*endpoint.Endpoint
|
|
}
|
|
|
|
type Update struct {
|
|
// current data
|
|
Old *endpoint.Endpoint
|
|
// desired data
|
|
New *endpoint.Endpoint
|
|
}
|
|
|
|
type ChangesV1 struct {
|
|
Create []*endpoint.Endpoint `json:"create,omitempty"`
|
|
UpdateOld []*endpoint.Endpoint `json:"updateOld,omitempty"`
|
|
UpdateNew []*endpoint.Endpoint `json:"updateNew,omitempty"`
|
|
Delete []*endpoint.Endpoint `json:"delete,omitempty"`
|
|
}
|
|
|
|
func (changes *Changes) MarshalJSON() ([]byte, error) {
|
|
return json.Marshal(&ChangesV1{
|
|
Create: changes.Create,
|
|
UpdateOld: changes.UpdateOld(),
|
|
UpdateNew: changes.UpdateNew(),
|
|
Delete: changes.Delete,
|
|
})
|
|
}
|
|
|
|
func MkUpdates(olds []*endpoint.Endpoint, news []*endpoint.Endpoint) ([]*Update, error) {
|
|
updates := []*Update{}
|
|
if nOld, nNew := len(olds), len(news); nOld != nNew {
|
|
return nil, fmt.Errorf("number of old updates (%v) does not match number of new updates (%v)", nOld, nNew)
|
|
}
|
|
for i, old := range olds {
|
|
updates = append(updates, &Update{Old: old, New: news[i]})
|
|
}
|
|
return updates, nil
|
|
}
|
|
|
|
func (changes *Changes) UnmarshalJSON(data []byte) error {
|
|
aux := &ChangesV1{}
|
|
if err := json.Unmarshal(data, &aux); err != nil {
|
|
return err
|
|
}
|
|
changes.Create = aux.Create
|
|
changes.Delete = aux.Delete
|
|
update, err := MkUpdates(aux.UpdateOld, aux.UpdateNew)
|
|
changes.Update = update
|
|
return err
|
|
}
|
|
|
|
func (c *Changes) UpdateOld() []*endpoint.Endpoint {
|
|
result := []*endpoint.Endpoint{}
|
|
for _, update := range c.Update {
|
|
result = append(result, update.Old)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (c *Changes) UpdateNew() []*endpoint.Endpoint {
|
|
result := []*endpoint.Endpoint{}
|
|
for _, update := range c.Update {
|
|
result = append(result, update.New)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// planKey is a key for a row in `planTable`.
|
|
type planKey struct {
|
|
dnsName string
|
|
setIdentifier string
|
|
}
|
|
|
|
// planTable is a supplementary struct for Plan
|
|
// each row correspond to a planKey -> (current records + all desired records)
|
|
//
|
|
// planTable (-> = target)
|
|
// --------------------------------------------------------------
|
|
// DNSName | Current record | Desired Records |
|
|
// --------------------------------------------------------------
|
|
// foo.com | [->1.1.1.1 ] | [->1.1.1.1] | = no action
|
|
// --------------------------------------------------------------
|
|
// bar.com | | [->191.1.1.1, ->190.1.1.1] | = create (bar.com [-> 190.1.1.1])
|
|
// --------------------------------------------------------------
|
|
// dog.com | [->1.1.1.2] | | = delete (dog.com [-> 1.1.1.2])
|
|
// --------------------------------------------------------------
|
|
// cat.com | [->::1, ->1.1.1.3] | [->1.1.1.3] | = update old (cat.com [-> ::1, -> 1.1.1.3]) new (cat.com [-> 1.1.1.3])
|
|
// --------------------------------------------------------------
|
|
// big.com | [->1.1.1.4] | [->ing.elb.com] | = update old (big.com [-> 1.1.1.4]) new (big.com [-> ing.elb.com])
|
|
// --------------------------------------------------------------
|
|
// "=", i.e. result of calculation relies on supplied ConflictResolver
|
|
type planTable struct {
|
|
rows map[planKey]*planTableRow
|
|
resolver ConflictResolver
|
|
}
|
|
|
|
func newPlanTable() planTable { // TODO: make resolver configurable
|
|
return planTable{map[planKey]*planTableRow{}, PerResource{}}
|
|
}
|
|
|
|
// planTableRow represents a set of current and desired domain resource records.
|
|
type planTableRow struct {
|
|
// current corresponds to the records currently occupying dns name on the dns provider. More than one record may
|
|
// be represented here: for example A and AAAA. If the current domain record is a CNAME, no other record types
|
|
// are allowed per [RFC 1034 3.6.2]
|
|
//
|
|
// [RFC 1034 3.6.2]: https://datatracker.ietf.org/doc/html/rfc1034#autoid-15
|
|
current []*endpoint.Endpoint
|
|
// candidates corresponds to the list of records which would like to have this dnsName.
|
|
candidates []*endpoint.Endpoint
|
|
// records is a grouping of current and candidates by record type, for example A, AAAA, CNAME.
|
|
records map[string]*domainEndpoints
|
|
}
|
|
|
|
// domainEndpoints is a grouping of current, which are existing records from the registry, and candidates,
|
|
// which are desired records from the source. All records in this grouping have the same record type.
|
|
type domainEndpoints struct {
|
|
// current corresponds to existing record from the registry. Maybe nil if no current record of the type exists.
|
|
current *endpoint.Endpoint
|
|
// candidates corresponds to the list of records which would like to have this dnsName.
|
|
candidates []*endpoint.Endpoint
|
|
}
|
|
|
|
func (t planTableRow) String() string {
|
|
return fmt.Sprintf("planTableRow{current=%v, candidates=%v}", t.current, t.candidates)
|
|
}
|
|
|
|
func (t *planTable) addCurrent(e *endpoint.Endpoint) {
|
|
key := t.newPlanKey(e)
|
|
t.rows[key].current = append(t.rows[key].current, e)
|
|
t.rows[key].records[e.RecordType].current = e
|
|
}
|
|
|
|
func (t *planTable) addCandidate(e *endpoint.Endpoint) {
|
|
key := t.newPlanKey(e)
|
|
t.rows[key].candidates = append(t.rows[key].candidates, e)
|
|
t.rows[key].records[e.RecordType].candidates = append(t.rows[key].records[e.RecordType].candidates, e)
|
|
}
|
|
|
|
func (t *planTable) newPlanKey(e *endpoint.Endpoint) planKey {
|
|
key := planKey{
|
|
dnsName: normalizeDNSName(e.DNSName),
|
|
setIdentifier: e.SetIdentifier,
|
|
}
|
|
|
|
if _, ok := t.rows[key]; !ok {
|
|
t.rows[key] = &planTableRow{
|
|
records: make(map[string]*domainEndpoints),
|
|
}
|
|
}
|
|
|
|
if _, ok := t.rows[key].records[e.RecordType]; !ok {
|
|
t.rows[key].records[e.RecordType] = &domainEndpoints{}
|
|
}
|
|
|
|
return key
|
|
}
|
|
|
|
func (c *Changes) HasChanges() bool {
|
|
if len(c.Create) > 0 || len(c.Delete) > 0 {
|
|
return true
|
|
}
|
|
return !cmp.Equal(c.UpdateNew(), c.UpdateOld())
|
|
}
|
|
|
|
func FilterUpdatesByOwnerId(ownerID string, updates []*Update) []*Update {
|
|
filtered := []*Update{}
|
|
for _, update := range updates {
|
|
// NOTE: OwnerID of `update.Old` and `update.New` will be equivalent
|
|
endpointOwner, ok := update.Old.Labels[endpoint.OwnerLabelKey]
|
|
if !ok {
|
|
log.Debugf(`Skipping update %v because of missing owner label (required: "%s")`, update, ownerID)
|
|
} else if endpointOwner != ownerID {
|
|
log.Debugf(`Skipping update %v because owner id does not match, found: "%s", required: "%s"`, update, endpointOwner, ownerID)
|
|
} else {
|
|
filtered = append(filtered, update)
|
|
}
|
|
}
|
|
|
|
return filtered
|
|
}
|
|
|
|
// Calculate computes the actions needed to move current state towards desired
|
|
// state. It then passes those changes to the current policy for further
|
|
// processing. It returns a copy of Plan with the changes populated.
|
|
func (p *Plan) Calculate() *Plan {
|
|
t := newPlanTable()
|
|
|
|
if p.DomainFilter == nil {
|
|
p.DomainFilter = endpoint.MatchAllDomainFilters(nil)
|
|
}
|
|
|
|
for _, current := range filterRecordsForPlan(p.Current, p.DomainFilter, p.ManagedRecords, p.ExcludeRecords) {
|
|
t.addCurrent(current)
|
|
}
|
|
for _, desired := range filterRecordsForPlan(p.Desired, p.DomainFilter, p.ManagedRecords, p.ExcludeRecords) {
|
|
t.addCandidate(desired)
|
|
}
|
|
|
|
changes := &Changes{}
|
|
|
|
for key, row := range t.rows {
|
|
// dns name not taken
|
|
if len(row.current) == 0 {
|
|
recordsByType := t.resolver.ResolveRecordTypes(key, row)
|
|
for _, records := range recordsByType {
|
|
if len(records.candidates) > 0 {
|
|
changes.Create = append(changes.Create, t.resolver.ResolveCreate(records.candidates))
|
|
}
|
|
}
|
|
}
|
|
|
|
// dns name released or possibly owned by a different external dns
|
|
if len(row.current) > 0 && len(row.candidates) == 0 {
|
|
changes.Delete = append(changes.Delete, row.current...)
|
|
}
|
|
|
|
// dns name is taken
|
|
if len(row.current) > 0 && len(row.candidates) > 0 {
|
|
creates := []*endpoint.Endpoint{}
|
|
|
|
// apply changes for each record type
|
|
recordsByType := t.resolver.ResolveRecordTypes(key, row)
|
|
for _, records := range recordsByType {
|
|
// record type not desired
|
|
if records.current != nil && len(records.candidates) == 0 {
|
|
changes.Delete = append(changes.Delete, records.current)
|
|
}
|
|
|
|
// new record type desired
|
|
if records.current == nil && len(records.candidates) > 0 {
|
|
update := t.resolver.ResolveCreate(records.candidates)
|
|
// creates are evaluated after all domain records have been processed to
|
|
// validate that this external dns has ownership claim on the domain before
|
|
// adding the records to planned changes.
|
|
creates = append(creates, update)
|
|
}
|
|
|
|
// update existing record
|
|
if records.current != nil && len(records.candidates) > 0 {
|
|
update := t.resolver.ResolveUpdate(records.current, records.candidates)
|
|
|
|
if shouldUpdateTTL(update, records.current) || targetChanged(update, records.current) || p.shouldUpdateProviderSpecific(update, records.current) {
|
|
inheritOwner(records.current, update)
|
|
changes.Update = append(changes.Update, &Update{Old: records.current, New: update})
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(creates) > 0 {
|
|
// only add creates if the external dns has ownership claim on the domain
|
|
ownersMatch := true
|
|
for _, current := range row.current {
|
|
if p.OwnerID != "" && !current.IsOwnedBy(p.OwnerID) {
|
|
ownersMatch = false
|
|
}
|
|
}
|
|
|
|
if ownersMatch {
|
|
changes.Create = append(changes.Create, creates...)
|
|
} else if log.GetLevel() == log.DebugLevel {
|
|
for _, current := range row.current {
|
|
log.Debugf(`Skipping endpoint %v because owner id does not match for one or more items to create, found: "%s", required: "%s"`, current, current.Labels[endpoint.OwnerLabelKey], p.OwnerID)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, pol := range p.Policies {
|
|
changes = pol.Apply(changes)
|
|
}
|
|
|
|
// filter out updates this external dns does not have ownership claim over
|
|
if p.OwnerID != "" {
|
|
changes.Delete = endpoint.FilterEndpointsByOwnerID(p.OwnerID, changes.Delete)
|
|
changes.Delete = endpoint.RemoveDuplicates(changes.Delete)
|
|
changes.Update = FilterUpdatesByOwnerId(p.OwnerID, changes.Update)
|
|
}
|
|
|
|
plan := &Plan{
|
|
Current: p.Current,
|
|
Desired: p.Desired,
|
|
Changes: changes,
|
|
// The default for ExternalDNS is to always only consider A/AAAA and CNAMEs.
|
|
// Everything else is an add on or something to be considered.
|
|
ManagedRecords: []string{endpoint.RecordTypeA, endpoint.RecordTypeAAAA, endpoint.RecordTypeCNAME},
|
|
}
|
|
|
|
return plan
|
|
}
|
|
|
|
func inheritOwner(from, to *endpoint.Endpoint) {
|
|
if to.Labels == nil {
|
|
to.Labels = map[string]string{}
|
|
}
|
|
if from.Labels == nil {
|
|
from.Labels = map[string]string{}
|
|
}
|
|
ownerLabel, ok := from.Labels[endpoint.OwnerLabelKey]
|
|
if ok {
|
|
to.Labels[endpoint.OwnerLabelKey] = ownerLabel
|
|
}
|
|
}
|
|
|
|
func targetChanged(desired, current *endpoint.Endpoint) bool {
|
|
return !desired.Targets.Same(current.Targets)
|
|
}
|
|
|
|
func shouldUpdateTTL(desired, current *endpoint.Endpoint) bool {
|
|
if !desired.RecordTTL.IsConfigured() {
|
|
return false
|
|
}
|
|
return desired.RecordTTL != current.RecordTTL
|
|
}
|
|
|
|
func (p *Plan) shouldUpdateProviderSpecific(desired, current *endpoint.Endpoint) bool {
|
|
desiredProperties := map[string]endpoint.ProviderSpecificProperty{}
|
|
|
|
for _, d := range desired.ProviderSpecific {
|
|
desiredProperties[d.Name] = d
|
|
}
|
|
for _, c := range current.ProviderSpecific {
|
|
if d, ok := desiredProperties[c.Name]; ok {
|
|
if c.Value != d.Value {
|
|
return true
|
|
}
|
|
delete(desiredProperties, c.Name)
|
|
} else {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return len(desiredProperties) > 0
|
|
}
|
|
|
|
// filterRecordsForPlan removes records that are not relevant to the planner.
|
|
// Currently, this just removes TXT records to prevent them from being
|
|
// deleted erroneously by the planner (only the TXT registry should do this.)
|
|
//
|
|
// Per RFC 1034, CNAME records conflict with all other records - it is the
|
|
// only record with this property. The behavior of the planner may need to be
|
|
// made more sophisticated to codify this.
|
|
func filterRecordsForPlan(records []*endpoint.Endpoint, domainFilter endpoint.MatchAllDomainFilters, managedRecords, excludeRecords []string) []*endpoint.Endpoint {
|
|
filtered := []*endpoint.Endpoint{}
|
|
|
|
for _, record := range records {
|
|
// Ignore records that do not match the domain filter provided
|
|
if !domainFilter.Match(record.DNSName) {
|
|
log.Debugf("ignoring record %s that does not match domain filter", record.DNSName)
|
|
continue
|
|
}
|
|
if IsManagedRecord(record.RecordType, managedRecords, excludeRecords) {
|
|
filtered = append(filtered, record)
|
|
}
|
|
}
|
|
|
|
return filtered
|
|
}
|
|
|
|
// normalizeDNSName converts a DNS name to a canonical form, so that we can use string equality
|
|
// it: removes space, get ASCII version of dnsName complient with Section 5 of RFC 5891, ensures there is a trailing dot
|
|
func normalizeDNSName(dnsName string) string {
|
|
s, err := idna.Profile.ToASCII(strings.TrimSpace(dnsName))
|
|
if err != nil {
|
|
log.Warnf(`Got error while parsing DNSName %s: %v`, dnsName, err)
|
|
}
|
|
if !strings.HasSuffix(s, ".") {
|
|
s += "."
|
|
}
|
|
return s
|
|
}
|
|
|
|
func IsManagedRecord(record string, managedRecords, excludeRecords []string) bool {
|
|
if slices.Contains(excludeRecords, record) {
|
|
return false
|
|
}
|
|
return slices.Contains(managedRecords, record)
|
|
}
|