From 3fd3fe1a4f6b60f1b70dc7e778a858092d819ba4 Mon Sep 17 00:00:00 2001 From: Phil Brookes Date: Wed, 10 Jul 2024 10:43:17 +0200 Subject: [PATCH 1/2] copy azure external-dns provider --- internal/external-dns/azure/azure.go | 462 +++++++++++++++ .../external-dns/azure/azure_private_dns.go | 445 ++++++++++++++ .../azure/azure_privatedns_test.go | 432 ++++++++++++++ internal/external-dns/azure/azure_test.go | 558 ++++++++++++++++++ internal/external-dns/azure/common.go | 47 ++ internal/external-dns/azure/common_test.go | 87 +++ internal/external-dns/azure/config.go | 154 +++++ internal/external-dns/azure/config_test.go | 46 ++ 8 files changed, 2231 insertions(+) create mode 100644 internal/external-dns/azure/azure.go create mode 100644 internal/external-dns/azure/azure_private_dns.go create mode 100644 internal/external-dns/azure/azure_privatedns_test.go create mode 100644 internal/external-dns/azure/azure_test.go create mode 100644 internal/external-dns/azure/common.go create mode 100644 internal/external-dns/azure/common_test.go create mode 100644 internal/external-dns/azure/config.go create mode 100644 internal/external-dns/azure/config_test.go diff --git a/internal/external-dns/azure/azure.go b/internal/external-dns/azure/azure.go new file mode 100644 index 00000000..dc47d8c0 --- /dev/null +++ b/internal/external-dns/azure/azure.go @@ -0,0 +1,462 @@ +/* +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. +*/ + +//nolint:staticcheck // Required due to the current dependency on a deprecated version of azure-sdk-for-go +package azure + +import ( + "context" + "fmt" + "strings" + + log "github.com/sirupsen/logrus" + + azcoreruntime "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + dns "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns" + + "sigs.k8s.io/external-dns/endpoint" + "sigs.k8s.io/external-dns/plan" + "sigs.k8s.io/external-dns/provider" +) + +const ( + azureRecordTTL = 300 +) + +// ZonesClient is an interface of dns.ZoneClient that can be stubbed for testing. +type ZonesClient interface { + NewListByResourceGroupPager(resourceGroupName string, options *dns.ZonesClientListByResourceGroupOptions) *azcoreruntime.Pager[dns.ZonesClientListByResourceGroupResponse] +} + +// RecordSetsClient is an interface of dns.RecordSetsClient that can be stubbed for testing. +type RecordSetsClient interface { + NewListAllByDNSZonePager(resourceGroupName string, zoneName string, options *dns.RecordSetsClientListAllByDNSZoneOptions) *azcoreruntime.Pager[dns.RecordSetsClientListAllByDNSZoneResponse] + Delete(ctx context.Context, resourceGroupName string, zoneName string, relativeRecordSetName string, recordType dns.RecordType, options *dns.RecordSetsClientDeleteOptions) (dns.RecordSetsClientDeleteResponse, error) + CreateOrUpdate(ctx context.Context, resourceGroupName string, zoneName string, relativeRecordSetName string, recordType dns.RecordType, parameters dns.RecordSet, options *dns.RecordSetsClientCreateOrUpdateOptions) (dns.RecordSetsClientCreateOrUpdateResponse, error) +} + +// AzureProvider implements the DNS provider for Microsoft's Azure cloud platform. +type AzureProvider struct { + provider.BaseProvider + domainFilter endpoint.DomainFilter + zoneNameFilter endpoint.DomainFilter + zoneIDFilter provider.ZoneIDFilter + dryRun bool + resourceGroup string + userAssignedIdentityClientID string + zonesClient ZonesClient + recordSetsClient RecordSetsClient +} + +// NewAzureProvider creates a new Azure provider. +// +// Returns the provider or an error if a provider could not be created. +func NewAzureProvider(configFile string, domainFilter endpoint.DomainFilter, zoneNameFilter endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, resourceGroup string, userAssignedIdentityClientID string, dryRun bool) (*AzureProvider, error) { + cfg, err := getConfig(configFile, resourceGroup, userAssignedIdentityClientID) + if err != nil { + return nil, fmt.Errorf("failed to read Azure config file '%s': %v", configFile, err) + } + cred, clientOpts, err := getCredentials(*cfg) + if err != nil { + return nil, fmt.Errorf("failed to get credentials: %w", err) + } + + zonesClient, err := dns.NewZonesClient(cfg.SubscriptionID, cred, clientOpts) + if err != nil { + return nil, err + } + recordSetsClient, err := dns.NewRecordSetsClient(cfg.SubscriptionID, cred, clientOpts) + if err != nil { + return nil, err + } + return &AzureProvider{ + domainFilter: domainFilter, + zoneNameFilter: zoneNameFilter, + zoneIDFilter: zoneIDFilter, + dryRun: dryRun, + resourceGroup: cfg.ResourceGroup, + userAssignedIdentityClientID: cfg.UserAssignedIdentityID, + zonesClient: zonesClient, + recordSetsClient: recordSetsClient, + }, nil +} + +// Records gets the current records. +// +// Returns the current records or an error if the operation failed. +func (p *AzureProvider) Records(ctx context.Context) (endpoints []*endpoint.Endpoint, _ error) { + zones, err := p.zones(ctx) + if err != nil { + return nil, err + } + + for _, zone := range zones { + pager := p.recordSetsClient.NewListAllByDNSZonePager(p.resourceGroup, *zone.Name, &dns.RecordSetsClientListAllByDNSZoneOptions{Top: nil}) + for pager.More() { + nextResult, err := pager.NextPage(ctx) + if err != nil { + return nil, err + } + for _, recordSet := range nextResult.Value { + if recordSet.Name == nil || recordSet.Type == nil { + log.Error("Skipping invalid record set with nil name or type.") + continue + } + recordType := strings.TrimPrefix(*recordSet.Type, "Microsoft.Network/dnszones/") + if !p.SupportedRecordType(recordType) { + continue + } + name := formatAzureDNSName(*recordSet.Name, *zone.Name) + if len(p.zoneNameFilter.Filters) > 0 && !p.domainFilter.Match(name) { + log.Debugf("Skipping return of record %s because it was filtered out by the specified --domain-filter", name) + continue + } + targets := extractAzureTargets(recordSet) + if len(targets) == 0 { + log.Debugf("Failed to extract targets for '%s' with type '%s'.", name, recordType) + continue + } + var ttl endpoint.TTL + if recordSet.Properties.TTL != nil { + ttl = endpoint.TTL(*recordSet.Properties.TTL) + } + ep := endpoint.NewEndpointWithTTL(name, recordType, ttl, targets...) + log.Debugf( + "Found %s record for '%s' with target '%s'.", + ep.RecordType, + ep.DNSName, + ep.Targets, + ) + endpoints = append(endpoints, ep) + } + } + } + return endpoints, nil +} + +// ApplyChanges applies the given changes. +// +// Returns nil if the operation was successful or an error if the operation failed. +func (p *AzureProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { + zones, err := p.zones(ctx) + if err != nil { + return err + } + + deleted, updated := p.mapChanges(zones, changes) + p.deleteRecords(ctx, deleted) + p.updateRecords(ctx, updated) + return nil +} + +func (p *AzureProvider) zones(ctx context.Context) ([]dns.Zone, error) { + log.Debugf("Retrieving Azure DNS zones for resource group: %s.", p.resourceGroup) + var zones []dns.Zone + pager := p.zonesClient.NewListByResourceGroupPager(p.resourceGroup, &dns.ZonesClientListByResourceGroupOptions{Top: nil}) + for pager.More() { + nextResult, err := pager.NextPage(ctx) + if err != nil { + return nil, err + } + for _, zone := range nextResult.Value { + if zone.Name != nil && p.domainFilter.Match(*zone.Name) && p.zoneIDFilter.Match(*zone.ID) { + zones = append(zones, *zone) + } else if zone.Name != nil && len(p.zoneNameFilter.Filters) > 0 && p.zoneNameFilter.Match(*zone.Name) { + // Handle zoneNameFilter + zones = append(zones, *zone) + } + } + } + log.Debugf("Found %d Azure DNS zone(s).", len(zones)) + return zones, nil +} + +func (p *AzureProvider) SupportedRecordType(recordType string) bool { + switch recordType { + case "MX": + return true + default: + return provider.SupportedRecordType(recordType) + } +} + +type azureChangeMap map[string][]*endpoint.Endpoint + +func (p *AzureProvider) mapChanges(zones []dns.Zone, changes *plan.Changes) (azureChangeMap, azureChangeMap) { + ignored := map[string]bool{} + deleted := azureChangeMap{} + updated := azureChangeMap{} + zoneNameIDMapper := provider.ZoneIDName{} + for _, z := range zones { + if z.Name != nil { + zoneNameIDMapper.Add(*z.Name, *z.Name) + } + } + mapChange := func(changeMap azureChangeMap, change *endpoint.Endpoint) { + zone, _ := zoneNameIDMapper.FindZone(change.DNSName) + if zone == "" { + if _, ok := ignored[change.DNSName]; !ok { + ignored[change.DNSName] = true + log.Infof("Ignoring changes to '%s' because a suitable Azure DNS zone was not found.", change.DNSName) + } + return + } + // Ensure the record type is suitable + changeMap[zone] = append(changeMap[zone], change) + } + + for _, change := range changes.Delete { + mapChange(deleted, change) + } + + for _, change := range changes.Create { + mapChange(updated, change) + } + + for _, change := range changes.UpdateNew { + mapChange(updated, change) + } + return deleted, updated +} + +func (p *AzureProvider) deleteRecords(ctx context.Context, deleted azureChangeMap) { + // Delete records first + for zone, endpoints := range deleted { + for _, ep := range endpoints { + name := p.recordSetNameForZone(zone, ep) + if !p.domainFilter.Match(ep.DNSName) { + log.Debugf("Skipping deletion of record %s because it was filtered out by the specified --domain-filter", ep.DNSName) + continue + } + if p.dryRun { + log.Infof("Would delete %s record named '%s' for Azure DNS zone '%s'.", ep.RecordType, name, zone) + } else { + log.Infof("Deleting %s record named '%s' for Azure DNS zone '%s'.", ep.RecordType, name, zone) + if _, err := p.recordSetsClient.Delete(ctx, p.resourceGroup, zone, name, dns.RecordType(ep.RecordType), nil); err != nil { + log.Errorf( + "Failed to delete %s record named '%s' for Azure DNS zone '%s': %v", + ep.RecordType, + name, + zone, + err, + ) + } + } + } + } +} + +func (p *AzureProvider) updateRecords(ctx context.Context, updated azureChangeMap) { + for zone, endpoints := range updated { + for _, ep := range endpoints { + name := p.recordSetNameForZone(zone, ep) + if !p.domainFilter.Match(ep.DNSName) { + log.Debugf("Skipping update of record %s because it was filtered out by the specified --domain-filter", ep.DNSName) + continue + } + if p.dryRun { + log.Infof( + "Would update %s record named '%s' to '%s' for Azure DNS zone '%s'.", + ep.RecordType, + name, + ep.Targets, + zone, + ) + continue + } + + log.Infof( + "Updating %s record named '%s' to '%s' for Azure DNS zone '%s'.", + ep.RecordType, + name, + ep.Targets, + zone, + ) + + recordSet, err := p.newRecordSet(ep) + if err == nil { + _, err = p.recordSetsClient.CreateOrUpdate( + ctx, + p.resourceGroup, + zone, + name, + dns.RecordType(ep.RecordType), + recordSet, + nil, + ) + } + if err != nil { + log.Errorf( + "Failed to update %s record named '%s' to '%s' for DNS zone '%s': %v", + ep.RecordType, + name, + ep.Targets, + zone, + err, + ) + } + } + } +} + +func (p *AzureProvider) recordSetNameForZone(zone string, endpoint *endpoint.Endpoint) string { + // Remove the zone from the record set + name := endpoint.DNSName + name = name[:len(name)-len(zone)] + name = strings.TrimSuffix(name, ".") + + // For root, use @ + if name == "" { + return "@" + } + return name +} + +func (p *AzureProvider) newRecordSet(endpoint *endpoint.Endpoint) (dns.RecordSet, error) { + var ttl int64 = azureRecordTTL + if endpoint.RecordTTL.IsConfigured() { + ttl = int64(endpoint.RecordTTL) + } + switch dns.RecordType(endpoint.RecordType) { + case dns.RecordTypeA: + aRecords := make([]*dns.ARecord, len(endpoint.Targets)) + for i, target := range endpoint.Targets { + aRecords[i] = &dns.ARecord{ + IPv4Address: to.Ptr(target), + } + } + return dns.RecordSet{ + Properties: &dns.RecordSetProperties{ + TTL: to.Ptr(ttl), + ARecords: aRecords, + }, + }, nil + case dns.RecordTypeAAAA: + aaaaRecords := make([]*dns.AaaaRecord, len(endpoint.Targets)) + for i, target := range endpoint.Targets { + aaaaRecords[i] = &dns.AaaaRecord{ + IPv6Address: to.Ptr(target), + } + } + return dns.RecordSet{ + Properties: &dns.RecordSetProperties{ + TTL: to.Ptr(ttl), + AaaaRecords: aaaaRecords, + }, + }, nil + case dns.RecordTypeCNAME: + return dns.RecordSet{ + Properties: &dns.RecordSetProperties{ + TTL: to.Ptr(ttl), + CnameRecord: &dns.CnameRecord{ + Cname: to.Ptr(endpoint.Targets[0]), + }, + }, + }, nil + case dns.RecordTypeMX: + mxRecords := make([]*dns.MxRecord, len(endpoint.Targets)) + for i, target := range endpoint.Targets { + mxRecord, err := parseMxTarget[dns.MxRecord](target) + if err != nil { + return dns.RecordSet{}, err + } + mxRecords[i] = &mxRecord + } + return dns.RecordSet{ + Properties: &dns.RecordSetProperties{ + TTL: to.Ptr(ttl), + MxRecords: mxRecords, + }, + }, nil + case dns.RecordTypeTXT: + return dns.RecordSet{ + Properties: &dns.RecordSetProperties{ + TTL: to.Ptr(ttl), + TxtRecords: []*dns.TxtRecord{ + { + Value: []*string{ + &endpoint.Targets[0], + }, + }, + }, + }, + }, nil + } + return dns.RecordSet{}, fmt.Errorf("unsupported record type '%s'", endpoint.RecordType) +} + +// Helper function (shared with test code) +func formatAzureDNSName(recordName, zoneName string) string { + if recordName == "@" { + return zoneName + } + return fmt.Sprintf("%s.%s", recordName, zoneName) +} + +// Helper function (shared with text code) +func extractAzureTargets(recordSet *dns.RecordSet) []string { + properties := recordSet.Properties + if properties == nil { + return []string{} + } + + // Check for A records + aRecords := properties.ARecords + if len(aRecords) > 0 && (aRecords)[0].IPv4Address != nil { + targets := make([]string, len(aRecords)) + for i, aRecord := range aRecords { + targets[i] = *aRecord.IPv4Address + } + return targets + } + + // Check for AAAA records + aaaaRecords := properties.AaaaRecords + if len(aaaaRecords) > 0 && (aaaaRecords)[0].IPv6Address != nil { + targets := make([]string, len(aaaaRecords)) + for i, aaaaRecord := range aaaaRecords { + targets[i] = *aaaaRecord.IPv6Address + } + return targets + } + + // Check for CNAME records + cnameRecord := properties.CnameRecord + if cnameRecord != nil && cnameRecord.Cname != nil { + return []string{*cnameRecord.Cname} + } + + // Check for MX records + mxRecords := properties.MxRecords + if len(mxRecords) > 0 && (mxRecords)[0].Exchange != nil { + targets := make([]string, len(mxRecords)) + for i, mxRecord := range mxRecords { + targets[i] = fmt.Sprintf("%d %s", *mxRecord.Preference, *mxRecord.Exchange) + } + return targets + } + + // Check for TXT records + txtRecords := properties.TxtRecords + if len(txtRecords) > 0 && (txtRecords)[0].Value != nil { + values := (txtRecords)[0].Value + if len(values) > 0 { + return []string{*(values)[0]} + } + } + return []string{} +} diff --git a/internal/external-dns/azure/azure_private_dns.go b/internal/external-dns/azure/azure_private_dns.go new file mode 100644 index 00000000..50df066f --- /dev/null +++ b/internal/external-dns/azure/azure_private_dns.go @@ -0,0 +1,445 @@ +/* +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. +*/ + +//nolint:staticcheck // Required due to the current dependency on a deprecated version of azure-sdk-for-go +package azure + +import ( + "context" + "fmt" + "strings" + + azcoreruntime "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + privatedns "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns" + log "github.com/sirupsen/logrus" + + "sigs.k8s.io/external-dns/endpoint" + "sigs.k8s.io/external-dns/plan" + "sigs.k8s.io/external-dns/provider" +) + +// PrivateZonesClient is an interface of privatedns.PrivateZoneClient that can be stubbed for testing. +type PrivateZonesClient interface { + NewListByResourceGroupPager(resourceGroupName string, options *privatedns.PrivateZonesClientListByResourceGroupOptions) *azcoreruntime.Pager[privatedns.PrivateZonesClientListByResourceGroupResponse] +} + +// PrivateRecordSetsClient is an interface of privatedns.RecordSetsClient that can be stubbed for testing. +type PrivateRecordSetsClient interface { + NewListPager(resourceGroupName string, privateZoneName string, options *privatedns.RecordSetsClientListOptions) *azcoreruntime.Pager[privatedns.RecordSetsClientListResponse] + Delete(ctx context.Context, resourceGroupName string, privateZoneName string, recordType privatedns.RecordType, relativeRecordSetName string, options *privatedns.RecordSetsClientDeleteOptions) (privatedns.RecordSetsClientDeleteResponse, error) + CreateOrUpdate(ctx context.Context, resourceGroupName string, privateZoneName string, recordType privatedns.RecordType, relativeRecordSetName string, parameters privatedns.RecordSet, options *privatedns.RecordSetsClientCreateOrUpdateOptions) (privatedns.RecordSetsClientCreateOrUpdateResponse, error) +} + +// AzurePrivateDNSProvider implements the DNS provider for Microsoft's Azure Private DNS service +type AzurePrivateDNSProvider struct { + provider.BaseProvider + domainFilter endpoint.DomainFilter + zoneIDFilter provider.ZoneIDFilter + dryRun bool + resourceGroup string + userAssignedIdentityClientID string + zonesClient PrivateZonesClient + recordSetsClient PrivateRecordSetsClient +} + +// NewAzurePrivateDNSProvider creates a new Azure Private DNS provider. +// +// Returns the provider or an error if a provider could not be created. +func NewAzurePrivateDNSProvider(configFile string, domainFilter endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, resourceGroup, userAssignedIdentityClientID string, dryRun bool) (*AzurePrivateDNSProvider, error) { + cfg, err := getConfig(configFile, resourceGroup, userAssignedIdentityClientID) + if err != nil { + return nil, fmt.Errorf("failed to read Azure config file '%s': %v", configFile, err) + } + cred, clientOpts, err := getCredentials(*cfg) + if err != nil { + return nil, fmt.Errorf("failed to get credentials: %w", err) + } + + zonesClient, err := privatedns.NewPrivateZonesClient(cfg.SubscriptionID, cred, clientOpts) + if err != nil { + return nil, err + } + recordSetsClient, err := privatedns.NewRecordSetsClient(cfg.SubscriptionID, cred, clientOpts) + if err != nil { + return nil, err + } + return &AzurePrivateDNSProvider{ + domainFilter: domainFilter, + zoneIDFilter: zoneIDFilter, + dryRun: dryRun, + resourceGroup: cfg.ResourceGroup, + userAssignedIdentityClientID: cfg.UserAssignedIdentityID, + zonesClient: zonesClient, + recordSetsClient: recordSetsClient, + }, nil +} + +// Records gets the current records. +// +// Returns the current records or an error if the operation failed. +func (p *AzurePrivateDNSProvider) Records(ctx context.Context) (endpoints []*endpoint.Endpoint, _ error) { + zones, err := p.zones(ctx) + if err != nil { + return nil, err + } + + log.Debugf("Retrieving Azure Private DNS Records for resource group '%s'", p.resourceGroup) + + for _, zone := range zones { + pager := p.recordSetsClient.NewListPager(p.resourceGroup, *zone.Name, &privatedns.RecordSetsClientListOptions{Top: nil}) + for pager.More() { + nextResult, err := pager.NextPage(ctx) + if err != nil { + return nil, err + } + + for _, recordSet := range nextResult.Value { + var recordType string + if recordSet.Type == nil { + log.Debugf("Skipping invalid record set with missing type.") + continue + } + recordType = strings.TrimPrefix(*recordSet.Type, "Microsoft.Network/privateDnsZones/") + + var name string + if recordSet.Name == nil { + log.Debugf("Skipping invalid record set with missing name.") + continue + } + name = formatAzureDNSName(*recordSet.Name, *zone.Name) + + targets := extractAzurePrivateDNSTargets(recordSet) + if len(targets) == 0 { + log.Debugf("Failed to extract targets for '%s' with type '%s'.", name, recordType) + continue + } + + var ttl endpoint.TTL + if recordSet.Properties.TTL != nil { + ttl = endpoint.TTL(*recordSet.Properties.TTL) + } + + ep := endpoint.NewEndpointWithTTL(name, recordType, ttl, targets...) + log.Debugf( + "Found %s record for '%s' with target '%s'.", + ep.RecordType, + ep.DNSName, + ep.Targets, + ) + endpoints = append(endpoints, ep) + } + } + } + + log.Debugf("Returning %d Azure Private DNS Records for resource group '%s'", len(endpoints), p.resourceGroup) + + return endpoints, nil +} + +// ApplyChanges applies the given changes. +// +// Returns nil if the operation was successful or an error if the operation failed. +func (p *AzurePrivateDNSProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { + log.Debugf("Received %d changes to process", len(changes.Create)+len(changes.Delete)+len(changes.UpdateNew)+len(changes.UpdateOld)) + + zones, err := p.zones(ctx) + if err != nil { + return err + } + + deleted, updated := p.mapChanges(zones, changes) + p.deleteRecords(ctx, deleted) + p.updateRecords(ctx, updated) + return nil +} + +func (p *AzurePrivateDNSProvider) zones(ctx context.Context) ([]privatedns.PrivateZone, error) { + log.Debugf("Retrieving Azure Private DNS zones for Resource Group '%s'", p.resourceGroup) + + var zones []privatedns.PrivateZone + + pager := p.zonesClient.NewListByResourceGroupPager(p.resourceGroup, &privatedns.PrivateZonesClientListByResourceGroupOptions{Top: nil}) + for pager.More() { + nextResult, err := pager.NextPage(ctx) + if err != nil { + return nil, err + } + for _, zone := range nextResult.Value { + log.Debugf("Validating Zone: %v", *zone.Name) + + if zone.Name != nil && p.domainFilter.Match(*zone.Name) && p.zoneIDFilter.Match(*zone.ID) { + zones = append(zones, *zone) + } + } + } + + log.Debugf("Found %d Azure Private DNS zone(s).", len(zones)) + return zones, nil +} + +type azurePrivateDNSChangeMap map[string][]*endpoint.Endpoint + +func (p *AzurePrivateDNSProvider) mapChanges(zones []privatedns.PrivateZone, changes *plan.Changes) (azurePrivateDNSChangeMap, azurePrivateDNSChangeMap) { + ignored := map[string]bool{} + deleted := azurePrivateDNSChangeMap{} + updated := azurePrivateDNSChangeMap{} + zoneNameIDMapper := provider.ZoneIDName{} + for _, z := range zones { + if z.Name != nil { + zoneNameIDMapper.Add(*z.Name, *z.Name) + } + } + mapChange := func(changeMap azurePrivateDNSChangeMap, change *endpoint.Endpoint) { + zone, _ := zoneNameIDMapper.FindZone(change.DNSName) + if zone == "" { + if _, ok := ignored[change.DNSName]; !ok { + ignored[change.DNSName] = true + log.Infof("Ignoring changes to '%s' because a suitable Azure Private DNS zone was not found.", change.DNSName) + } + return + } + // Ensure the record type is suitable + changeMap[zone] = append(changeMap[zone], change) + } + + for _, change := range changes.Delete { + mapChange(deleted, change) + } + + for _, change := range changes.Create { + mapChange(updated, change) + } + + for _, change := range changes.UpdateNew { + mapChange(updated, change) + } + return deleted, updated +} + +func (p *AzurePrivateDNSProvider) deleteRecords(ctx context.Context, deleted azurePrivateDNSChangeMap) { + log.Debugf("Records to be deleted: %d", len(deleted)) + // Delete records first + for zone, endpoints := range deleted { + for _, ep := range endpoints { + name := p.recordSetNameForZone(zone, ep) + if p.dryRun { + log.Infof("Would delete %s record named '%s' for Azure Private DNS zone '%s'.", ep.RecordType, name, zone) + } else { + log.Infof("Deleting %s record named '%s' for Azure Private DNS zone '%s'.", ep.RecordType, name, zone) + if _, err := p.recordSetsClient.Delete(ctx, p.resourceGroup, zone, privatedns.RecordType(ep.RecordType), name, nil); err != nil { + log.Errorf( + "Failed to delete %s record named '%s' for Azure Private DNS zone '%s': %v", + ep.RecordType, + name, + zone, + err, + ) + } + } + } + } +} + +func (p *AzurePrivateDNSProvider) updateRecords(ctx context.Context, updated azurePrivateDNSChangeMap) { + log.Debugf("Records to be updated: %d", len(updated)) + for zone, endpoints := range updated { + for _, ep := range endpoints { + name := p.recordSetNameForZone(zone, ep) + if p.dryRun { + log.Infof( + "Would update %s record named '%s' to '%s' for Azure Private DNS zone '%s'.", + ep.RecordType, + name, + ep.Targets, + zone, + ) + continue + } + + log.Infof( + "Updating %s record named '%s' to '%s' for Azure Private DNS zone '%s'.", + ep.RecordType, + name, + ep.Targets, + zone, + ) + + recordSet, err := p.newRecordSet(ep) + if err == nil { + _, err = p.recordSetsClient.CreateOrUpdate( + ctx, + p.resourceGroup, + zone, + privatedns.RecordType(ep.RecordType), + name, + recordSet, + nil, + ) + } + if err != nil { + log.Errorf( + "Failed to update %s record named '%s' to '%s' for Azure Private DNS zone '%s': %v", + ep.RecordType, + name, + ep.Targets, + zone, + err, + ) + } + } + } +} + +func (p *AzurePrivateDNSProvider) recordSetNameForZone(zone string, endpoint *endpoint.Endpoint) string { + // Remove the zone from the record set + name := endpoint.DNSName + name = name[:len(name)-len(zone)] + name = strings.TrimSuffix(name, ".") + + // For root, use @ + if name == "" { + return "@" + } + return name +} + +func (p *AzurePrivateDNSProvider) newRecordSet(endpoint *endpoint.Endpoint) (privatedns.RecordSet, error) { + var ttl int64 = azureRecordTTL + if endpoint.RecordTTL.IsConfigured() { + ttl = int64(endpoint.RecordTTL) + } + switch privatedns.RecordType(endpoint.RecordType) { + case privatedns.RecordTypeA: + aRecords := make([]*privatedns.ARecord, len(endpoint.Targets)) + for i, target := range endpoint.Targets { + aRecords[i] = &privatedns.ARecord{ + IPv4Address: to.Ptr(target), + } + } + return privatedns.RecordSet{ + Properties: &privatedns.RecordSetProperties{ + TTL: to.Ptr(ttl), + ARecords: aRecords, + }, + }, nil + case privatedns.RecordTypeAAAA: + aaaaRecords := make([]*privatedns.AaaaRecord, len(endpoint.Targets)) + for i, target := range endpoint.Targets { + aaaaRecords[i] = &privatedns.AaaaRecord{ + IPv6Address: to.Ptr(target), + } + } + return privatedns.RecordSet{ + Properties: &privatedns.RecordSetProperties{ + TTL: to.Ptr(ttl), + AaaaRecords: aaaaRecords, + }, + }, nil + case privatedns.RecordTypeCNAME: + return privatedns.RecordSet{ + Properties: &privatedns.RecordSetProperties{ + TTL: to.Ptr(ttl), + CnameRecord: &privatedns.CnameRecord{ + Cname: to.Ptr(endpoint.Targets[0]), + }, + }, + }, nil + case privatedns.RecordTypeMX: + mxRecords := make([]*privatedns.MxRecord, len(endpoint.Targets)) + for i, target := range endpoint.Targets { + mxRecord, err := parseMxTarget[privatedns.MxRecord](target) + if err != nil { + return privatedns.RecordSet{}, err + } + mxRecords[i] = &mxRecord + } + return privatedns.RecordSet{ + Properties: &privatedns.RecordSetProperties{ + TTL: to.Ptr(ttl), + MxRecords: mxRecords, + }, + }, nil + case privatedns.RecordTypeTXT: + return privatedns.RecordSet{ + Properties: &privatedns.RecordSetProperties{ + TTL: to.Ptr(ttl), + TxtRecords: []*privatedns.TxtRecord{ + { + Value: []*string{ + &endpoint.Targets[0], + }, + }, + }, + }, + }, nil + } + return privatedns.RecordSet{}, fmt.Errorf("unsupported record type '%s'", endpoint.RecordType) +} + +// Helper function (shared with test code) +func extractAzurePrivateDNSTargets(recordSet *privatedns.RecordSet) []string { + properties := recordSet.Properties + if properties == nil { + return []string{} + } + + // Check for A records + aRecords := properties.ARecords + if len(aRecords) > 0 && (aRecords)[0].IPv4Address != nil { + targets := make([]string, len(aRecords)) + for i, aRecord := range aRecords { + targets[i] = *aRecord.IPv4Address + } + return targets + } + + // Check for AAAA records + aaaaRecords := properties.AaaaRecords + if len(aaaaRecords) > 0 && (aaaaRecords)[0].IPv6Address != nil { + targets := make([]string, len(aaaaRecords)) + for i, aaaaRecord := range aaaaRecords { + targets[i] = *aaaaRecord.IPv6Address + } + return targets + } + + // Check for CNAME records + cnameRecord := properties.CnameRecord + if cnameRecord != nil && cnameRecord.Cname != nil { + return []string{*cnameRecord.Cname} + } + + // Check for MX records + mxRecords := properties.MxRecords + if len(mxRecords) > 0 && (mxRecords)[0].Exchange != nil { + targets := make([]string, len(mxRecords)) + for i, mxRecord := range mxRecords { + targets[i] = fmt.Sprintf("%d %s", *mxRecord.Preference, *mxRecord.Exchange) + } + return targets + } + + // Check for TXT records + txtRecords := properties.TxtRecords + if len(txtRecords) > 0 && (txtRecords)[0].Value != nil { + values := (txtRecords)[0].Value + if len(values) > 0 { + return []string{*(values)[0]} + } + } + return []string{} +} diff --git a/internal/external-dns/azure/azure_privatedns_test.go b/internal/external-dns/azure/azure_privatedns_test.go new file mode 100644 index 00000000..567badea --- /dev/null +++ b/internal/external-dns/azure/azure_privatedns_test.go @@ -0,0 +1,432 @@ +/* +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 azure + +import ( + "context" + "testing" + + azcoreruntime "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + privatedns "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns" + "sigs.k8s.io/external-dns/endpoint" + "sigs.k8s.io/external-dns/plan" + "sigs.k8s.io/external-dns/provider" +) + +const ( + recordTTL = 300 +) + +// mockPrivateZonesClient implements the methods of the Azure Private DNS Zones Client which are used in the Azure Private DNS Provider +// and returns static results which are defined per test +type mockPrivateZonesClient struct { + pagingHandler azcoreruntime.PagingHandler[privatedns.PrivateZonesClientListByResourceGroupResponse] +} + +func newMockPrivateZonesClient(zones []*privatedns.PrivateZone) mockPrivateZonesClient { + pagingHandler := azcoreruntime.PagingHandler[privatedns.PrivateZonesClientListByResourceGroupResponse]{ + More: func(resp privatedns.PrivateZonesClientListByResourceGroupResponse) bool { + return false + }, + Fetcher: func(context.Context, *privatedns.PrivateZonesClientListByResourceGroupResponse) (privatedns.PrivateZonesClientListByResourceGroupResponse, error) { + return privatedns.PrivateZonesClientListByResourceGroupResponse{ + PrivateZoneListResult: privatedns.PrivateZoneListResult{ + Value: zones, + }, + }, nil + }, + } + return mockPrivateZonesClient{ + pagingHandler: pagingHandler, + } +} + +func (client *mockPrivateZonesClient) NewListByResourceGroupPager(resourceGroupName string, options *privatedns.PrivateZonesClientListByResourceGroupOptions) *azcoreruntime.Pager[privatedns.PrivateZonesClientListByResourceGroupResponse] { + return azcoreruntime.NewPager(client.pagingHandler) +} + +// mockPrivateRecordSetsClient implements the methods of the Azure Private DNS RecordSet Client which are used in the Azure Private DNS Provider +// and returns static results which are defined per test +type mockPrivateRecordSetsClient struct { + pagingHandler azcoreruntime.PagingHandler[privatedns.RecordSetsClientListResponse] + deletedEndpoints []*endpoint.Endpoint + updatedEndpoints []*endpoint.Endpoint +} + +func newMockPrivateRecordSectsClient(recordSets []*privatedns.RecordSet) mockPrivateRecordSetsClient { + pagingHandler := azcoreruntime.PagingHandler[privatedns.RecordSetsClientListResponse]{ + More: func(resp privatedns.RecordSetsClientListResponse) bool { + return false + }, + Fetcher: func(context.Context, *privatedns.RecordSetsClientListResponse) (privatedns.RecordSetsClientListResponse, error) { + return privatedns.RecordSetsClientListResponse{ + RecordSetListResult: privatedns.RecordSetListResult{ + Value: recordSets, + }, + }, nil + }, + } + return mockPrivateRecordSetsClient{ + pagingHandler: pagingHandler, + } +} + +func (client *mockPrivateRecordSetsClient) NewListPager(resourceGroupName string, privateZoneName string, options *privatedns.RecordSetsClientListOptions) *azcoreruntime.Pager[privatedns.RecordSetsClientListResponse] { + return azcoreruntime.NewPager(client.pagingHandler) +} + +func (client *mockPrivateRecordSetsClient) Delete(ctx context.Context, resourceGroupName string, privateZoneName string, recordType privatedns.RecordType, relativeRecordSetName string, options *privatedns.RecordSetsClientDeleteOptions) (privatedns.RecordSetsClientDeleteResponse, error) { + client.deletedEndpoints = append( + client.deletedEndpoints, + endpoint.NewEndpoint( + formatAzureDNSName(relativeRecordSetName, privateZoneName), + string(recordType), + "", + ), + ) + return privatedns.RecordSetsClientDeleteResponse{}, nil +} + +func (client *mockPrivateRecordSetsClient) CreateOrUpdate(ctx context.Context, resourceGroupName string, privateZoneName string, recordType privatedns.RecordType, relativeRecordSetName string, parameters privatedns.RecordSet, options *privatedns.RecordSetsClientCreateOrUpdateOptions) (privatedns.RecordSetsClientCreateOrUpdateResponse, error) { + var ttl endpoint.TTL + if parameters.Properties.TTL != nil { + ttl = endpoint.TTL(*parameters.Properties.TTL) + } + client.updatedEndpoints = append( + client.updatedEndpoints, + endpoint.NewEndpointWithTTL( + formatAzureDNSName(relativeRecordSetName, privateZoneName), + string(recordType), + ttl, + extractAzurePrivateDNSTargets(¶meters)..., + ), + ) + return privatedns.RecordSetsClientCreateOrUpdateResponse{}, nil + //return parameters, nil +} + +func createMockPrivateZone(zone string, id string) *privatedns.PrivateZone { + return &privatedns.PrivateZone{ + ID: to.Ptr(id), + Name: to.Ptr(zone), + } +} + +func privateARecordSetPropertiesGetter(values []string, ttl int64) *privatedns.RecordSetProperties { + aRecords := make([]*privatedns.ARecord, len(values)) + for i, value := range values { + aRecords[i] = &privatedns.ARecord{ + IPv4Address: to.Ptr(value), + } + } + return &privatedns.RecordSetProperties{ + TTL: to.Ptr(ttl), + ARecords: aRecords, + } +} + +func privateAAAARecordSetPropertiesGetter(values []string, ttl int64) *privatedns.RecordSetProperties { + aaaaRecords := make([]*privatedns.AaaaRecord, len(values)) + for i, value := range values { + aaaaRecords[i] = &privatedns.AaaaRecord{ + IPv6Address: to.Ptr(value), + } + } + return &privatedns.RecordSetProperties{ + TTL: to.Ptr(ttl), + AaaaRecords: aaaaRecords, + } +} + +func privateCNameRecordSetPropertiesGetter(values []string, ttl int64) *privatedns.RecordSetProperties { + return &privatedns.RecordSetProperties{ + TTL: to.Ptr(ttl), + CnameRecord: &privatedns.CnameRecord{ + Cname: to.Ptr(values[0]), + }, + } +} + +func privateMXRecordSetPropertiesGetter(values []string, ttl int64) *privatedns.RecordSetProperties { + mxRecords := make([]*privatedns.MxRecord, len(values)) + for i, target := range values { + mxRecord, _ := parseMxTarget[privatedns.MxRecord](target) + mxRecords[i] = &mxRecord + } + return &privatedns.RecordSetProperties{ + TTL: to.Ptr(ttl), + MxRecords: mxRecords, + } +} + +func privateTxtRecordSetPropertiesGetter(values []string, ttl int64) *privatedns.RecordSetProperties { + return &privatedns.RecordSetProperties{ + TTL: to.Ptr(ttl), + TxtRecords: []*privatedns.TxtRecord{ + { + Value: []*string{&values[0]}, + }, + }, + } +} + +func privateOthersRecordSetPropertiesGetter(values []string, ttl int64) *privatedns.RecordSetProperties { + return &privatedns.RecordSetProperties{ + TTL: to.Ptr(ttl), + } +} + +func createPrivateMockRecordSet(name, recordType string, values ...string) *privatedns.RecordSet { + return createPrivateMockRecordSetMultiWithTTL(name, recordType, 0, values...) +} + +func createPrivateMockRecordSetWithTTL(name, recordType, value string, ttl int64) *privatedns.RecordSet { + return createPrivateMockRecordSetMultiWithTTL(name, recordType, ttl, value) +} + +func createPrivateMockRecordSetMultiWithTTL(name, recordType string, ttl int64, values ...string) *privatedns.RecordSet { + var getterFunc func(values []string, ttl int64) *privatedns.RecordSetProperties + + switch recordType { + case endpoint.RecordTypeA: + getterFunc = privateARecordSetPropertiesGetter + case endpoint.RecordTypeAAAA: + getterFunc = privateAAAARecordSetPropertiesGetter + case endpoint.RecordTypeCNAME: + getterFunc = privateCNameRecordSetPropertiesGetter + case endpoint.RecordTypeMX: + getterFunc = privateMXRecordSetPropertiesGetter + case endpoint.RecordTypeTXT: + getterFunc = privateTxtRecordSetPropertiesGetter + default: + getterFunc = privateOthersRecordSetPropertiesGetter + } + return &privatedns.RecordSet{ + Name: to.Ptr(name), + Type: to.Ptr("Microsoft.Network/privateDnsZones/" + recordType), + Properties: getterFunc(values, ttl), + } +} + +// newMockedAzurePrivateDNSProvider creates an AzureProvider comprising the mocked clients for zones and recordsets +func newMockedAzurePrivateDNSProvider(domainFilter endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, dryRun bool, resourceGroup string, zones []*privatedns.PrivateZone, recordSets []*privatedns.RecordSet) (*AzurePrivateDNSProvider, error) { + zonesClient := newMockPrivateZonesClient(zones) + recordSetsClient := newMockPrivateRecordSectsClient(recordSets) + return newAzurePrivateDNSProvider(domainFilter, zoneIDFilter, dryRun, resourceGroup, &zonesClient, &recordSetsClient), nil +} + +func newAzurePrivateDNSProvider(domainFilter endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, dryRun bool, resourceGroup string, privateZonesClient PrivateZonesClient, privateRecordsClient PrivateRecordSetsClient) *AzurePrivateDNSProvider { + return &AzurePrivateDNSProvider{ + domainFilter: domainFilter, + zoneIDFilter: zoneIDFilter, + dryRun: dryRun, + resourceGroup: resourceGroup, + zonesClient: privateZonesClient, + recordSetsClient: privateRecordsClient, + } +} + +func TestAzurePrivateDNSRecord(t *testing.T) { + provider, err := newMockedAzurePrivateDNSProvider(endpoint.NewDomainFilter([]string{"example.com"}), provider.NewZoneIDFilter([]string{""}), true, "k8s", + []*privatedns.PrivateZone{ + createMockPrivateZone("example.com", "/privateDnsZones/example.com"), + }, + []*privatedns.RecordSet{ + createPrivateMockRecordSet("@", "NS", "ns1-03.azure-dns.com."), + createPrivateMockRecordSet("@", "SOA", "Email: azuredns-hostmaster.microsoft.com"), + createPrivateMockRecordSet("@", endpoint.RecordTypeA, "123.123.123.122"), + createPrivateMockRecordSet("@", endpoint.RecordTypeAAAA, "2001::123:123:123:122"), + createPrivateMockRecordSet("@", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default"), + createPrivateMockRecordSetWithTTL("nginx", endpoint.RecordTypeA, "123.123.123.123", 3600), + createPrivateMockRecordSetWithTTL("nginx", endpoint.RecordTypeAAAA, "2001::123:123:123:123", 3600), + createPrivateMockRecordSetWithTTL("nginx", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default", recordTTL), + createPrivateMockRecordSetWithTTL("hack", endpoint.RecordTypeCNAME, "hack.azurewebsites.net", 10), + createPrivateMockRecordSetWithTTL("mail", endpoint.RecordTypeMX, "10 example.com", 4000), + }) + if err != nil { + t.Fatal(err) + } + + actual, err := provider.Records(context.Background()) + if err != nil { + t.Fatal(err) + } + expected := []*endpoint.Endpoint{ + endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "123.123.123.122"), + endpoint.NewEndpoint("example.com", endpoint.RecordTypeAAAA, "2001::123:123:123:122"), + endpoint.NewEndpoint("example.com", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default"), + endpoint.NewEndpointWithTTL("nginx.example.com", endpoint.RecordTypeA, 3600, "123.123.123.123"), + endpoint.NewEndpointWithTTL("nginx.example.com", endpoint.RecordTypeAAAA, 3600, "2001::123:123:123:123"), + endpoint.NewEndpointWithTTL("nginx.example.com", endpoint.RecordTypeTXT, recordTTL, "heritage=external-dns,external-dns/owner=default"), + endpoint.NewEndpointWithTTL("hack.example.com", endpoint.RecordTypeCNAME, 10, "hack.azurewebsites.net"), + endpoint.NewEndpointWithTTL("mail.example.com", endpoint.RecordTypeMX, 4000, "10 example.com"), + } + + validateAzureEndpoints(t, actual, expected) +} + +func TestAzurePrivateDNSMultiRecord(t *testing.T) { + provider, err := newMockedAzurePrivateDNSProvider(endpoint.NewDomainFilter([]string{"example.com"}), provider.NewZoneIDFilter([]string{""}), true, "k8s", + []*privatedns.PrivateZone{ + createMockPrivateZone("example.com", "/privateDnsZones/example.com"), + }, + []*privatedns.RecordSet{ + createPrivateMockRecordSet("@", "NS", "ns1-03.azure-dns.com."), + createPrivateMockRecordSet("@", "SOA", "Email: azuredns-hostmaster.microsoft.com"), + createPrivateMockRecordSet("@", endpoint.RecordTypeA, "123.123.123.122", "234.234.234.233"), + createPrivateMockRecordSet("@", endpoint.RecordTypeAAAA, "2001::123:123:123:122", "2001::234:234:234:233"), + createPrivateMockRecordSet("@", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default"), + createPrivateMockRecordSetMultiWithTTL("nginx", endpoint.RecordTypeA, 3600, "123.123.123.123", "234.234.234.234"), + createPrivateMockRecordSetMultiWithTTL("nginx", endpoint.RecordTypeAAAA, 3600, "2001::123:123:123:123", "2001::234:234:234:234"), + createPrivateMockRecordSetWithTTL("nginx", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default", recordTTL), + createPrivateMockRecordSetWithTTL("hack", endpoint.RecordTypeCNAME, "hack.azurewebsites.net", 10), + createPrivateMockRecordSetMultiWithTTL("mail", endpoint.RecordTypeMX, 4000, "10 example.com", "20 backup.example.com"), + }) + if err != nil { + t.Fatal(err) + } + + actual, err := provider.Records(context.Background()) + if err != nil { + t.Fatal(err) + } + expected := []*endpoint.Endpoint{ + endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "123.123.123.122", "234.234.234.233"), + endpoint.NewEndpoint("example.com", endpoint.RecordTypeAAAA, "2001::123:123:123:122", "2001::234:234:234:233"), + endpoint.NewEndpoint("example.com", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default"), + endpoint.NewEndpointWithTTL("nginx.example.com", endpoint.RecordTypeA, 3600, "123.123.123.123", "234.234.234.234"), + endpoint.NewEndpointWithTTL("nginx.example.com", endpoint.RecordTypeAAAA, 3600, "2001::123:123:123:123", "2001::234:234:234:234"), + endpoint.NewEndpointWithTTL("nginx.example.com", endpoint.RecordTypeTXT, recordTTL, "heritage=external-dns,external-dns/owner=default"), + endpoint.NewEndpointWithTTL("hack.example.com", endpoint.RecordTypeCNAME, 10, "hack.azurewebsites.net"), + endpoint.NewEndpointWithTTL("mail.example.com", endpoint.RecordTypeMX, 4000, "10 example.com", "20 backup.example.com"), + } + + validateAzureEndpoints(t, actual, expected) +} + +func TestAzurePrivateDNSApplyChanges(t *testing.T) { + recordsClient := mockPrivateRecordSetsClient{} + + testAzurePrivateDNSApplyChangesInternal(t, false, &recordsClient) + + validateAzureEndpoints(t, recordsClient.deletedEndpoints, []*endpoint.Endpoint{ + endpoint.NewEndpoint("deleted.example.com", endpoint.RecordTypeA, ""), + endpoint.NewEndpoint("deletedaaaa.example.com", endpoint.RecordTypeAAAA, ""), + endpoint.NewEndpoint("deletedcname.example.com", endpoint.RecordTypeCNAME, ""), + }) + + validateAzureEndpoints(t, recordsClient.updatedEndpoints, []*endpoint.Endpoint{ + endpoint.NewEndpointWithTTL("example.com", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "1.2.3.4"), + endpoint.NewEndpointWithTTL("example.com", endpoint.RecordTypeAAAA, endpoint.TTL(recordTTL), "2001::1:2:3:4"), + endpoint.NewEndpointWithTTL("example.com", endpoint.RecordTypeTXT, endpoint.TTL(recordTTL), "tag"), + endpoint.NewEndpointWithTTL("foo.example.com", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "1.2.3.4", "1.2.3.5"), + endpoint.NewEndpointWithTTL("foo.example.com", endpoint.RecordTypeAAAA, endpoint.TTL(recordTTL), "2001::1:2:3:4", "2001::1:2:3:5"), + endpoint.NewEndpointWithTTL("foo.example.com", endpoint.RecordTypeTXT, endpoint.TTL(recordTTL), "tag"), + endpoint.NewEndpointWithTTL("bar.example.com", endpoint.RecordTypeCNAME, endpoint.TTL(recordTTL), "other.com"), + endpoint.NewEndpointWithTTL("bar.example.com", endpoint.RecordTypeTXT, endpoint.TTL(recordTTL), "tag"), + endpoint.NewEndpointWithTTL("other.com", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "5.6.7.8"), + endpoint.NewEndpointWithTTL("other.com", endpoint.RecordTypeAAAA, endpoint.TTL(recordTTL), "2001::5:6:7:8"), + endpoint.NewEndpointWithTTL("other.com", endpoint.RecordTypeTXT, endpoint.TTL(recordTTL), "tag"), + endpoint.NewEndpointWithTTL("new.example.com", endpoint.RecordTypeA, 3600, "111.222.111.222"), + endpoint.NewEndpointWithTTL("new.example.com", endpoint.RecordTypeAAAA, 3600, "2001::111:222:111:222"), + endpoint.NewEndpointWithTTL("newcname.example.com", endpoint.RecordTypeCNAME, 10, "other.com"), + endpoint.NewEndpointWithTTL("newmail.example.com", endpoint.RecordTypeMX, 7200, "40 bar.other.com"), + endpoint.NewEndpointWithTTL("mail.example.com", endpoint.RecordTypeMX, endpoint.TTL(recordTTL), "10 other.com"), + endpoint.NewEndpointWithTTL("mail.example.com", endpoint.RecordTypeTXT, endpoint.TTL(recordTTL), "tag"), + }) +} + +func TestAzurePrivateDNSApplyChangesDryRun(t *testing.T) { + recordsClient := mockRecordSetsClient{} + + testAzureApplyChangesInternal(t, true, &recordsClient) + + validateAzureEndpoints(t, recordsClient.deletedEndpoints, []*endpoint.Endpoint{}) + + validateAzureEndpoints(t, recordsClient.updatedEndpoints, []*endpoint.Endpoint{}) +} + +func testAzurePrivateDNSApplyChangesInternal(t *testing.T, dryRun bool, client PrivateRecordSetsClient) { + zones := []*privatedns.PrivateZone{ + createMockPrivateZone("example.com", "/privateDnsZones/example.com"), + createMockPrivateZone("other.com", "/privateDnsZones/other.com"), + } + zonesClient := newMockPrivateZonesClient(zones) + + provider := newAzurePrivateDNSProvider( + endpoint.NewDomainFilter([]string{""}), + provider.NewZoneIDFilter([]string{""}), + dryRun, + "group", + &zonesClient, + client, + ) + + createRecords := []*endpoint.Endpoint{ + endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "1.2.3.4"), + endpoint.NewEndpoint("example.com", endpoint.RecordTypeAAAA, "2001::1:2:3:4"), + endpoint.NewEndpoint("example.com", endpoint.RecordTypeTXT, "tag"), + endpoint.NewEndpoint("foo.example.com", endpoint.RecordTypeA, "1.2.3.5", "1.2.3.4"), + endpoint.NewEndpoint("foo.example.com", endpoint.RecordTypeAAAA, "2001::1:2:3:5", "2001::1:2:3:4"), + endpoint.NewEndpoint("foo.example.com", endpoint.RecordTypeTXT, "tag"), + endpoint.NewEndpoint("bar.example.com", endpoint.RecordTypeCNAME, "other.com"), + endpoint.NewEndpoint("bar.example.com", endpoint.RecordTypeTXT, "tag"), + endpoint.NewEndpoint("other.com", endpoint.RecordTypeA, "5.6.7.8"), + endpoint.NewEndpoint("other.com", endpoint.RecordTypeAAAA, "2001::5:6:7:8"), + endpoint.NewEndpoint("other.com", endpoint.RecordTypeTXT, "tag"), + endpoint.NewEndpoint("nope.com", endpoint.RecordTypeA, "4.4.4.4"), + endpoint.NewEndpoint("nope.com", endpoint.RecordTypeAAAA, "2001::4:4:4:4"), + endpoint.NewEndpoint("nope.com", endpoint.RecordTypeTXT, "tag"), + endpoint.NewEndpoint("mail.example.com", endpoint.RecordTypeMX, "10 other.com"), + endpoint.NewEndpoint("mail.example.com", endpoint.RecordTypeTXT, "tag"), + } + + currentRecords := []*endpoint.Endpoint{ + endpoint.NewEndpoint("old.example.com", endpoint.RecordTypeA, "121.212.121.212"), + endpoint.NewEndpoint("oldcname.example.com", endpoint.RecordTypeCNAME, "other.com"), + endpoint.NewEndpoint("old.nope.com", endpoint.RecordTypeA, "121.212.121.212"), + endpoint.NewEndpoint("oldmail.example.com", endpoint.RecordTypeMX, "20 foo.other.com"), + } + updatedRecords := []*endpoint.Endpoint{ + endpoint.NewEndpointWithTTL("new.example.com", endpoint.RecordTypeA, 3600, "111.222.111.222"), + endpoint.NewEndpointWithTTL("new.example.com", endpoint.RecordTypeAAAA, 3600, "2001::111:222:111:222"), + endpoint.NewEndpointWithTTL("newcname.example.com", endpoint.RecordTypeCNAME, 10, "other.com"), + endpoint.NewEndpoint("new.nope.com", endpoint.RecordTypeA, "222.111.222.111"), + endpoint.NewEndpoint("new.nope.com", endpoint.RecordTypeAAAA, "2001::222:111:222:111"), + endpoint.NewEndpointWithTTL("newmail.example.com", endpoint.RecordTypeMX, 7200, "40 bar.other.com"), + } + + deleteRecords := []*endpoint.Endpoint{ + endpoint.NewEndpoint("deleted.example.com", endpoint.RecordTypeA, "111.222.111.222"), + endpoint.NewEndpoint("deletedaaaa.example.com", endpoint.RecordTypeAAAA, "2001::111:222:111:222"), + endpoint.NewEndpoint("deletedcname.example.com", endpoint.RecordTypeCNAME, "other.com"), + endpoint.NewEndpoint("deleted.nope.com", endpoint.RecordTypeA, "222.111.222.111"), + endpoint.NewEndpoint("deleted.nope.com", endpoint.RecordTypeAAAA, "2001::222:111:222:111"), + } + + changes := &plan.Changes{ + Create: createRecords, + UpdateNew: updatedRecords, + UpdateOld: currentRecords, + Delete: deleteRecords, + } + + if err := provider.ApplyChanges(context.Background(), changes); err != nil { + t.Fatal(err) + } +} diff --git a/internal/external-dns/azure/azure_test.go b/internal/external-dns/azure/azure_test.go new file mode 100644 index 00000000..4fd9fa8f --- /dev/null +++ b/internal/external-dns/azure/azure_test.go @@ -0,0 +1,558 @@ +/* +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 azure + +import ( + "context" + "testing" + + azcoreruntime "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + dns "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns" + "github.com/stretchr/testify/assert" + + "sigs.k8s.io/external-dns/endpoint" + "sigs.k8s.io/external-dns/internal/testutils" + "sigs.k8s.io/external-dns/plan" + "sigs.k8s.io/external-dns/provider" +) + +// mockZonesClient implements the methods of the Azure DNS Zones Client which are used in the Azure Provider +// and returns static results which are defined per test +type mockZonesClient struct { + pagingHandler azcoreruntime.PagingHandler[dns.ZonesClientListByResourceGroupResponse] +} + +func newMockZonesClient(zones []*dns.Zone) mockZonesClient { + pagingHandler := azcoreruntime.PagingHandler[dns.ZonesClientListByResourceGroupResponse]{ + More: func(resp dns.ZonesClientListByResourceGroupResponse) bool { + return false + }, + Fetcher: func(context.Context, *dns.ZonesClientListByResourceGroupResponse) (dns.ZonesClientListByResourceGroupResponse, error) { + return dns.ZonesClientListByResourceGroupResponse{ + ZoneListResult: dns.ZoneListResult{ + Value: zones, + }, + }, nil + }, + } + return mockZonesClient{ + pagingHandler: pagingHandler, + } +} + +func (client *mockZonesClient) NewListByResourceGroupPager(resourceGroupName string, options *dns.ZonesClientListByResourceGroupOptions) *azcoreruntime.Pager[dns.ZonesClientListByResourceGroupResponse] { + return azcoreruntime.NewPager(client.pagingHandler) +} + +// mockZonesClient implements the methods of the Azure DNS RecordSet Client which are used in the Azure Provider +// and returns static results which are defined per test +type mockRecordSetsClient struct { + pagingHandler azcoreruntime.PagingHandler[dns.RecordSetsClientListAllByDNSZoneResponse] + deletedEndpoints []*endpoint.Endpoint + updatedEndpoints []*endpoint.Endpoint +} + +func newMockRecordSetsClient(recordSets []*dns.RecordSet) mockRecordSetsClient { + pagingHandler := azcoreruntime.PagingHandler[dns.RecordSetsClientListAllByDNSZoneResponse]{ + More: func(resp dns.RecordSetsClientListAllByDNSZoneResponse) bool { + return false + }, + Fetcher: func(context.Context, *dns.RecordSetsClientListAllByDNSZoneResponse) (dns.RecordSetsClientListAllByDNSZoneResponse, error) { + return dns.RecordSetsClientListAllByDNSZoneResponse{ + RecordSetListResult: dns.RecordSetListResult{ + Value: recordSets, + }, + }, nil + }, + } + return mockRecordSetsClient{ + pagingHandler: pagingHandler, + } +} + +func (client *mockRecordSetsClient) NewListAllByDNSZonePager(resourceGroupName string, zoneName string, options *dns.RecordSetsClientListAllByDNSZoneOptions) *azcoreruntime.Pager[dns.RecordSetsClientListAllByDNSZoneResponse] { + return azcoreruntime.NewPager(client.pagingHandler) +} + +func (client *mockRecordSetsClient) Delete(ctx context.Context, resourceGroupName string, zoneName string, relativeRecordSetName string, recordType dns.RecordType, options *dns.RecordSetsClientDeleteOptions) (dns.RecordSetsClientDeleteResponse, error) { + client.deletedEndpoints = append( + client.deletedEndpoints, + endpoint.NewEndpoint( + formatAzureDNSName(relativeRecordSetName, zoneName), + string(recordType), + "", + ), + ) + return dns.RecordSetsClientDeleteResponse{}, nil +} + +func (client *mockRecordSetsClient) CreateOrUpdate(ctx context.Context, resourceGroupName string, zoneName string, relativeRecordSetName string, recordType dns.RecordType, parameters dns.RecordSet, options *dns.RecordSetsClientCreateOrUpdateOptions) (dns.RecordSetsClientCreateOrUpdateResponse, error) { + var ttl endpoint.TTL + if parameters.Properties.TTL != nil { + ttl = endpoint.TTL(*parameters.Properties.TTL) + } + client.updatedEndpoints = append( + client.updatedEndpoints, + endpoint.NewEndpointWithTTL( + formatAzureDNSName(relativeRecordSetName, zoneName), + string(recordType), + ttl, + extractAzureTargets(¶meters)..., + ), + ) + return dns.RecordSetsClientCreateOrUpdateResponse{}, nil +} + +func createMockZone(zone string, id string) *dns.Zone { + return &dns.Zone{ + ID: to.Ptr(id), + Name: to.Ptr(zone), + } +} + +func aRecordSetPropertiesGetter(values []string, ttl int64) *dns.RecordSetProperties { + aRecords := make([]*dns.ARecord, len(values)) + for i, value := range values { + aRecords[i] = &dns.ARecord{ + IPv4Address: to.Ptr(value), + } + } + return &dns.RecordSetProperties{ + TTL: to.Ptr(ttl), + ARecords: aRecords, + } +} + +func aaaaRecordSetPropertiesGetter(values []string, ttl int64) *dns.RecordSetProperties { + aaaaRecords := make([]*dns.AaaaRecord, len(values)) + for i, value := range values { + aaaaRecords[i] = &dns.AaaaRecord{ + IPv6Address: to.Ptr(value), + } + } + return &dns.RecordSetProperties{ + TTL: to.Ptr(ttl), + AaaaRecords: aaaaRecords, + } +} + +func cNameRecordSetPropertiesGetter(values []string, ttl int64) *dns.RecordSetProperties { + return &dns.RecordSetProperties{ + TTL: to.Ptr(ttl), + CnameRecord: &dns.CnameRecord{ + Cname: to.Ptr(values[0]), + }, + } +} + +func mxRecordSetPropertiesGetter(values []string, ttl int64) *dns.RecordSetProperties { + mxRecords := make([]*dns.MxRecord, len(values)) + for i, target := range values { + mxRecord, _ := parseMxTarget[dns.MxRecord](target) + mxRecords[i] = &mxRecord + } + return &dns.RecordSetProperties{ + TTL: to.Ptr(ttl), + MxRecords: mxRecords, + } +} + +func txtRecordSetPropertiesGetter(values []string, ttl int64) *dns.RecordSetProperties { + return &dns.RecordSetProperties{ + TTL: to.Ptr(ttl), + TxtRecords: []*dns.TxtRecord{ + { + Value: []*string{to.Ptr(values[0])}, + }, + }, + } +} + +func othersRecordSetPropertiesGetter(values []string, ttl int64) *dns.RecordSetProperties { + return &dns.RecordSetProperties{ + TTL: to.Ptr(ttl), + } +} + +func createMockRecordSet(name, recordType string, values ...string) *dns.RecordSet { + return createMockRecordSetMultiWithTTL(name, recordType, 0, values...) +} + +func createMockRecordSetWithTTL(name, recordType, value string, ttl int64) *dns.RecordSet { + return createMockRecordSetMultiWithTTL(name, recordType, ttl, value) +} + +func createMockRecordSetMultiWithTTL(name, recordType string, ttl int64, values ...string) *dns.RecordSet { + var getterFunc func(values []string, ttl int64) *dns.RecordSetProperties + + switch recordType { + case endpoint.RecordTypeA: + getterFunc = aRecordSetPropertiesGetter + case endpoint.RecordTypeAAAA: + getterFunc = aaaaRecordSetPropertiesGetter + case endpoint.RecordTypeCNAME: + getterFunc = cNameRecordSetPropertiesGetter + case endpoint.RecordTypeMX: + getterFunc = mxRecordSetPropertiesGetter + case endpoint.RecordTypeTXT: + getterFunc = txtRecordSetPropertiesGetter + default: + getterFunc = othersRecordSetPropertiesGetter + } + return &dns.RecordSet{ + Name: to.Ptr(name), + Type: to.Ptr("Microsoft.Network/dnszones/" + recordType), + Properties: getterFunc(values, ttl), + } +} + +// newMockedAzureProvider creates an AzureProvider comprising the mocked clients for zones and recordsets +func newMockedAzureProvider(domainFilter endpoint.DomainFilter, zoneNameFilter endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, dryRun bool, resourceGroup string, userAssignedIdentityClientID string, zones []*dns.Zone, recordSets []*dns.RecordSet) (*AzureProvider, error) { + zonesClient := newMockZonesClient(zones) + recordSetsClient := newMockRecordSetsClient(recordSets) + return newAzureProvider(domainFilter, zoneNameFilter, zoneIDFilter, dryRun, resourceGroup, userAssignedIdentityClientID, &zonesClient, &recordSetsClient), nil +} + +func newAzureProvider(domainFilter endpoint.DomainFilter, zoneNameFilter endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, dryRun bool, resourceGroup string, userAssignedIdentityClientID string, zonesClient ZonesClient, recordsClient RecordSetsClient) *AzureProvider { + return &AzureProvider{ + domainFilter: domainFilter, + zoneNameFilter: zoneNameFilter, + zoneIDFilter: zoneIDFilter, + dryRun: dryRun, + resourceGroup: resourceGroup, + userAssignedIdentityClientID: userAssignedIdentityClientID, + zonesClient: zonesClient, + recordSetsClient: recordsClient, + } +} + +func validateAzureEndpoints(t *testing.T, endpoints []*endpoint.Endpoint, expected []*endpoint.Endpoint) { + assert.True(t, testutils.SameEndpoints(endpoints, expected), "expected and actual endpoints don't match. %s:%s", endpoints, expected) +} + +func TestAzureRecord(t *testing.T) { + provider, err := newMockedAzureProvider(endpoint.NewDomainFilter([]string{"example.com"}), endpoint.NewDomainFilter([]string{}), provider.NewZoneIDFilter([]string{""}), true, "k8s", "", + []*dns.Zone{ + createMockZone("example.com", "/dnszones/example.com"), + }, + []*dns.RecordSet{ + createMockRecordSet("@", "NS", "ns1-03.azure-dns.com."), + createMockRecordSet("@", "SOA", "Email: azuredns-hostmaster.microsoft.com"), + createMockRecordSet("@", endpoint.RecordTypeA, "123.123.123.122"), + createMockRecordSet("@", endpoint.RecordTypeAAAA, "2001::123:123:123:122"), + createMockRecordSet("@", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default"), + createMockRecordSetWithTTL("nginx", endpoint.RecordTypeA, "123.123.123.123", 3600), + createMockRecordSetWithTTL("nginx", endpoint.RecordTypeAAAA, "2001::123:123:123:123", 3600), + createMockRecordSetWithTTL("nginx", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default", recordTTL), + createMockRecordSetWithTTL("hack", endpoint.RecordTypeCNAME, "hack.azurewebsites.net", 10), + createMockRecordSetMultiWithTTL("mail", endpoint.RecordTypeMX, 4000, "10 example.com"), + }) + if err != nil { + t.Fatal(err) + } + + ctx := context.Background() + actual, err := provider.Records(ctx) + if err != nil { + t.Fatal(err) + } + expected := []*endpoint.Endpoint{ + endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "123.123.123.122"), + endpoint.NewEndpoint("example.com", endpoint.RecordTypeAAAA, "2001::123:123:123:122"), + endpoint.NewEndpoint("example.com", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default"), + endpoint.NewEndpointWithTTL("nginx.example.com", endpoint.RecordTypeA, 3600, "123.123.123.123"), + endpoint.NewEndpointWithTTL("nginx.example.com", endpoint.RecordTypeAAAA, 3600, "2001::123:123:123:123"), + endpoint.NewEndpointWithTTL("nginx.example.com", endpoint.RecordTypeTXT, recordTTL, "heritage=external-dns,external-dns/owner=default"), + endpoint.NewEndpointWithTTL("hack.example.com", endpoint.RecordTypeCNAME, 10, "hack.azurewebsites.net"), + endpoint.NewEndpointWithTTL("mail.example.com", endpoint.RecordTypeMX, 4000, "10 example.com"), + } + + validateAzureEndpoints(t, actual, expected) +} + +func TestAzureMultiRecord(t *testing.T) { + provider, err := newMockedAzureProvider(endpoint.NewDomainFilter([]string{"example.com"}), endpoint.NewDomainFilter([]string{}), provider.NewZoneIDFilter([]string{""}), true, "k8s", "", + []*dns.Zone{ + createMockZone("example.com", "/dnszones/example.com"), + }, + []*dns.RecordSet{ + createMockRecordSet("@", "NS", "ns1-03.azure-dns.com."), + createMockRecordSet("@", "SOA", "Email: azuredns-hostmaster.microsoft.com"), + createMockRecordSet("@", endpoint.RecordTypeA, "123.123.123.122", "234.234.234.233"), + createMockRecordSet("@", endpoint.RecordTypeAAAA, "2001::123:123:123:122", "2001::234:234:234:233"), + createMockRecordSet("@", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default"), + createMockRecordSetMultiWithTTL("nginx", endpoint.RecordTypeA, 3600, "123.123.123.123", "234.234.234.234"), + createMockRecordSetMultiWithTTL("nginx", endpoint.RecordTypeAAAA, 3600, "2001::123:123:123:123", "2001::234:234:234:234"), + createMockRecordSetWithTTL("nginx", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default", recordTTL), + createMockRecordSetWithTTL("hack", endpoint.RecordTypeCNAME, "hack.azurewebsites.net", 10), + createMockRecordSetMultiWithTTL("mail", endpoint.RecordTypeMX, 4000, "10 example.com", "20 backup.example.com"), + }) + if err != nil { + t.Fatal(err) + } + + ctx := context.Background() + actual, err := provider.Records(ctx) + if err != nil { + t.Fatal(err) + } + expected := []*endpoint.Endpoint{ + endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "123.123.123.122", "234.234.234.233"), + endpoint.NewEndpoint("example.com", endpoint.RecordTypeAAAA, "2001::123:123:123:122", "2001::234:234:234:233"), + endpoint.NewEndpoint("example.com", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default"), + endpoint.NewEndpointWithTTL("nginx.example.com", endpoint.RecordTypeA, 3600, "123.123.123.123", "234.234.234.234"), + endpoint.NewEndpointWithTTL("nginx.example.com", endpoint.RecordTypeAAAA, 3600, "2001::123:123:123:123", "2001::234:234:234:234"), + endpoint.NewEndpointWithTTL("nginx.example.com", endpoint.RecordTypeTXT, recordTTL, "heritage=external-dns,external-dns/owner=default"), + endpoint.NewEndpointWithTTL("hack.example.com", endpoint.RecordTypeCNAME, 10, "hack.azurewebsites.net"), + endpoint.NewEndpointWithTTL("mail.example.com", endpoint.RecordTypeMX, 4000, "10 example.com", "20 backup.example.com"), + } + + validateAzureEndpoints(t, actual, expected) +} + +func TestAzureApplyChanges(t *testing.T) { + recordsClient := mockRecordSetsClient{} + + testAzureApplyChangesInternal(t, false, &recordsClient) + + validateAzureEndpoints(t, recordsClient.deletedEndpoints, []*endpoint.Endpoint{ + endpoint.NewEndpoint("deleted.example.com", endpoint.RecordTypeA, ""), + endpoint.NewEndpoint("deletedaaaa.example.com", endpoint.RecordTypeAAAA, ""), + endpoint.NewEndpoint("deletedcname.example.com", endpoint.RecordTypeCNAME, ""), + }) + + validateAzureEndpoints(t, recordsClient.updatedEndpoints, []*endpoint.Endpoint{ + endpoint.NewEndpointWithTTL("example.com", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "1.2.3.4"), + endpoint.NewEndpointWithTTL("example.com", endpoint.RecordTypeAAAA, endpoint.TTL(recordTTL), "2001::1:2:3:4"), + endpoint.NewEndpointWithTTL("example.com", endpoint.RecordTypeTXT, endpoint.TTL(recordTTL), "tag"), + endpoint.NewEndpointWithTTL("foo.example.com", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "1.2.3.4", "1.2.3.5"), + endpoint.NewEndpointWithTTL("foo.example.com", endpoint.RecordTypeAAAA, endpoint.TTL(recordTTL), "2001::1:2:3:4", "2001::1:2:3:5"), + endpoint.NewEndpointWithTTL("foo.example.com", endpoint.RecordTypeTXT, endpoint.TTL(recordTTL), "tag"), + endpoint.NewEndpointWithTTL("bar.example.com", endpoint.RecordTypeCNAME, endpoint.TTL(recordTTL), "other.com"), + endpoint.NewEndpointWithTTL("bar.example.com", endpoint.RecordTypeTXT, endpoint.TTL(recordTTL), "tag"), + endpoint.NewEndpointWithTTL("other.com", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "5.6.7.8"), + endpoint.NewEndpointWithTTL("other.com", endpoint.RecordTypeAAAA, endpoint.TTL(recordTTL), "2001::5:6:7:8"), + endpoint.NewEndpointWithTTL("other.com", endpoint.RecordTypeTXT, endpoint.TTL(recordTTL), "tag"), + endpoint.NewEndpointWithTTL("new.example.com", endpoint.RecordTypeA, 3600, "111.222.111.222"), + endpoint.NewEndpointWithTTL("new.example.com", endpoint.RecordTypeAAAA, 3600, "2001::111:222:111:222"), + endpoint.NewEndpointWithTTL("newcname.example.com", endpoint.RecordTypeCNAME, 10, "other.com"), + endpoint.NewEndpointWithTTL("newmail.example.com", endpoint.RecordTypeMX, 7200, "40 bar.other.com"), + endpoint.NewEndpointWithTTL("mail.example.com", endpoint.RecordTypeMX, endpoint.TTL(recordTTL), "10 other.com"), + endpoint.NewEndpointWithTTL("mail.example.com", endpoint.RecordTypeTXT, endpoint.TTL(recordTTL), "tag"), + }) +} + +func TestAzureApplyChangesDryRun(t *testing.T) { + recordsClient := mockRecordSetsClient{} + + testAzureApplyChangesInternal(t, true, &recordsClient) + + validateAzureEndpoints(t, recordsClient.deletedEndpoints, []*endpoint.Endpoint{}) + + validateAzureEndpoints(t, recordsClient.updatedEndpoints, []*endpoint.Endpoint{}) +} + +func testAzureApplyChangesInternal(t *testing.T, dryRun bool, client RecordSetsClient) { + zones := []*dns.Zone{ + createMockZone("example.com", "/dnszones/example.com"), + createMockZone("other.com", "/dnszones/other.com"), + } + zonesClient := newMockZonesClient(zones) + + provider := newAzureProvider( + endpoint.NewDomainFilter([]string{""}), + endpoint.NewDomainFilter([]string{""}), + provider.NewZoneIDFilter([]string{""}), + dryRun, + "group", + "", + &zonesClient, + client, + ) + + createRecords := []*endpoint.Endpoint{ + endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "1.2.3.4"), + endpoint.NewEndpoint("example.com", endpoint.RecordTypeAAAA, "2001::1:2:3:4"), + endpoint.NewEndpoint("example.com", endpoint.RecordTypeTXT, "tag"), + endpoint.NewEndpoint("foo.example.com", endpoint.RecordTypeA, "1.2.3.5", "1.2.3.4"), + endpoint.NewEndpoint("foo.example.com", endpoint.RecordTypeAAAA, "2001::1:2:3:5", "2001::1:2:3:4"), + endpoint.NewEndpoint("foo.example.com", endpoint.RecordTypeTXT, "tag"), + endpoint.NewEndpoint("bar.example.com", endpoint.RecordTypeCNAME, "other.com"), + endpoint.NewEndpoint("bar.example.com", endpoint.RecordTypeTXT, "tag"), + endpoint.NewEndpoint("other.com", endpoint.RecordTypeA, "5.6.7.8"), + endpoint.NewEndpoint("other.com", endpoint.RecordTypeAAAA, "2001::5:6:7:8"), + endpoint.NewEndpoint("other.com", endpoint.RecordTypeTXT, "tag"), + endpoint.NewEndpoint("nope.com", endpoint.RecordTypeA, "4.4.4.4"), + endpoint.NewEndpoint("nope.com", endpoint.RecordTypeAAAA, "2001::4:4:4:4"), + endpoint.NewEndpoint("nope.com", endpoint.RecordTypeTXT, "tag"), + endpoint.NewEndpoint("mail.example.com", endpoint.RecordTypeMX, "10 other.com"), + endpoint.NewEndpoint("mail.example.com", endpoint.RecordTypeTXT, "tag"), + } + + currentRecords := []*endpoint.Endpoint{ + endpoint.NewEndpoint("old.example.com", endpoint.RecordTypeA, "121.212.121.212"), + endpoint.NewEndpoint("oldcname.example.com", endpoint.RecordTypeCNAME, "other.com"), + endpoint.NewEndpoint("old.nope.com", endpoint.RecordTypeA, "121.212.121.212"), + endpoint.NewEndpoint("oldmail.example.com", endpoint.RecordTypeMX, "20 foo.other.com"), + } + updatedRecords := []*endpoint.Endpoint{ + endpoint.NewEndpointWithTTL("new.example.com", endpoint.RecordTypeA, 3600, "111.222.111.222"), + endpoint.NewEndpointWithTTL("new.example.com", endpoint.RecordTypeAAAA, 3600, "2001::111:222:111:222"), + endpoint.NewEndpointWithTTL("newcname.example.com", endpoint.RecordTypeCNAME, 10, "other.com"), + endpoint.NewEndpoint("new.nope.com", endpoint.RecordTypeA, "222.111.222.111"), + endpoint.NewEndpoint("new.nope.com", endpoint.RecordTypeAAAA, "2001::222:111:222:111"), + endpoint.NewEndpointWithTTL("newmail.example.com", endpoint.RecordTypeMX, 7200, "40 bar.other.com"), + } + + deleteRecords := []*endpoint.Endpoint{ + endpoint.NewEndpoint("deleted.example.com", endpoint.RecordTypeA, "111.222.111.222"), + endpoint.NewEndpoint("deletedaaaa.example.com", endpoint.RecordTypeAAAA, "2001::111:222:111:222"), + endpoint.NewEndpoint("deletedcname.example.com", endpoint.RecordTypeCNAME, "other.com"), + endpoint.NewEndpoint("deleted.nope.com", endpoint.RecordTypeA, "222.111.222.111"), + endpoint.NewEndpoint("deleted.nope.com", endpoint.RecordTypeAAAA, "2001::222:111:222:111"), + } + + changes := &plan.Changes{ + Create: createRecords, + UpdateNew: updatedRecords, + UpdateOld: currentRecords, + Delete: deleteRecords, + } + + if err := provider.ApplyChanges(context.Background(), changes); err != nil { + t.Fatal(err) + } +} + +func TestAzureNameFilter(t *testing.T) { + provider, err := newMockedAzureProvider(endpoint.NewDomainFilter([]string{"nginx.example.com"}), endpoint.NewDomainFilter([]string{"example.com"}), provider.NewZoneIDFilter([]string{""}), true, "k8s", "", + []*dns.Zone{ + createMockZone("example.com", "/dnszones/example.com"), + }, + + []*dns.RecordSet{ + createMockRecordSet("@", "NS", "ns1-03.azure-dns.com."), + createMockRecordSet("@", "SOA", "Email: azuredns-hostmaster.microsoft.com"), + createMockRecordSet("@", endpoint.RecordTypeA, "123.123.123.122"), + createMockRecordSet("@", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default"), + createMockRecordSetWithTTL("test.nginx", endpoint.RecordTypeA, "123.123.123.123", 3600), + createMockRecordSetWithTTL("nginx", endpoint.RecordTypeA, "123.123.123.123", 3600), + createMockRecordSetWithTTL("nginx", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default", recordTTL), + createMockRecordSetWithTTL("mail.nginx", endpoint.RecordTypeMX, "20 example.com", recordTTL), + createMockRecordSetWithTTL("hack", endpoint.RecordTypeCNAME, "hack.azurewebsites.net", 10), + }) + if err != nil { + t.Fatal(err) + } + + ctx := context.Background() + actual, err := provider.Records(ctx) + if err != nil { + t.Fatal(err) + } + expected := []*endpoint.Endpoint{ + endpoint.NewEndpointWithTTL("test.nginx.example.com", endpoint.RecordTypeA, 3600, "123.123.123.123"), + endpoint.NewEndpointWithTTL("nginx.example.com", endpoint.RecordTypeA, 3600, "123.123.123.123"), + endpoint.NewEndpointWithTTL("nginx.example.com", endpoint.RecordTypeTXT, recordTTL, "heritage=external-dns,external-dns/owner=default"), + endpoint.NewEndpointWithTTL("mail.nginx.example.com", endpoint.RecordTypeMX, recordTTL, "20 example.com"), + } + + validateAzureEndpoints(t, actual, expected) +} + +func TestAzureApplyChangesZoneName(t *testing.T) { + recordsClient := mockRecordSetsClient{} + + testAzureApplyChangesInternalZoneName(t, false, &recordsClient) + + validateAzureEndpoints(t, recordsClient.deletedEndpoints, []*endpoint.Endpoint{ + endpoint.NewEndpoint("deleted.foo.example.com", endpoint.RecordTypeA, ""), + endpoint.NewEndpoint("deletedaaaa.foo.example.com", endpoint.RecordTypeAAAA, ""), + endpoint.NewEndpoint("deletedcname.foo.example.com", endpoint.RecordTypeCNAME, ""), + }) + + validateAzureEndpoints(t, recordsClient.updatedEndpoints, []*endpoint.Endpoint{ + endpoint.NewEndpointWithTTL("foo.example.com", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "1.2.3.4", "1.2.3.5"), + endpoint.NewEndpointWithTTL("foo.example.com", endpoint.RecordTypeAAAA, endpoint.TTL(recordTTL), "2001::1:2:3:4", "2001::1:2:3:5"), + endpoint.NewEndpointWithTTL("foo.example.com", endpoint.RecordTypeTXT, endpoint.TTL(recordTTL), "tag"), + endpoint.NewEndpointWithTTL("new.foo.example.com", endpoint.RecordTypeA, 3600, "111.222.111.222"), + endpoint.NewEndpointWithTTL("new.foo.example.com", endpoint.RecordTypeAAAA, 3600, "2001::111:222:111:222"), + endpoint.NewEndpointWithTTL("newcname.foo.example.com", endpoint.RecordTypeCNAME, 10, "other.com"), + }) +} + +func testAzureApplyChangesInternalZoneName(t *testing.T, dryRun bool, client RecordSetsClient) { + zonesClient := newMockZonesClient([]*dns.Zone{createMockZone("example.com", "/dnszones/example.com")}) + + provider := newAzureProvider( + endpoint.NewDomainFilter([]string{"foo.example.com"}), + endpoint.NewDomainFilter([]string{"example.com"}), + provider.NewZoneIDFilter([]string{""}), + dryRun, + "group", + "", + &zonesClient, + client, + ) + + createRecords := []*endpoint.Endpoint{ + endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "1.2.3.4"), + endpoint.NewEndpoint("example.com", endpoint.RecordTypeAAAA, "2001::1:2:3:4"), + endpoint.NewEndpoint("example.com", endpoint.RecordTypeTXT, "tag"), + endpoint.NewEndpoint("foo.example.com", endpoint.RecordTypeA, "1.2.3.5", "1.2.3.4"), + endpoint.NewEndpoint("foo.example.com", endpoint.RecordTypeAAAA, "2001::1:2:3:5", "2001::1:2:3:4"), + endpoint.NewEndpoint("foo.example.com", endpoint.RecordTypeTXT, "tag"), + endpoint.NewEndpoint("bar.example.com", endpoint.RecordTypeCNAME, "other.com"), + endpoint.NewEndpoint("bar.example.com", endpoint.RecordTypeTXT, "tag"), + endpoint.NewEndpoint("other.com", endpoint.RecordTypeA, "5.6.7.8"), + endpoint.NewEndpoint("other.com", endpoint.RecordTypeTXT, "tag"), + endpoint.NewEndpoint("nope.com", endpoint.RecordTypeA, "4.4.4.4"), + endpoint.NewEndpoint("nope.com", endpoint.RecordTypeTXT, "tag"), + } + + currentRecords := []*endpoint.Endpoint{ + endpoint.NewEndpoint("old.foo.example.com", endpoint.RecordTypeA, "121.212.121.212"), + endpoint.NewEndpoint("oldcname.foo.example.com", endpoint.RecordTypeCNAME, "other.com"), + endpoint.NewEndpoint("old.nope.example.com", endpoint.RecordTypeA, "121.212.121.212"), + } + updatedRecords := []*endpoint.Endpoint{ + endpoint.NewEndpointWithTTL("new.foo.example.com", endpoint.RecordTypeA, 3600, "111.222.111.222"), + endpoint.NewEndpointWithTTL("new.foo.example.com", endpoint.RecordTypeAAAA, 3600, "2001::111:222:111:222"), + endpoint.NewEndpointWithTTL("newcname.foo.example.com", endpoint.RecordTypeCNAME, 10, "other.com"), + endpoint.NewEndpoint("new.nope.example.com", endpoint.RecordTypeA, "222.111.222.111"), + endpoint.NewEndpoint("new.nope.example.com", endpoint.RecordTypeAAAA, "2001::222:111:222:111"), + } + + deleteRecords := []*endpoint.Endpoint{ + endpoint.NewEndpoint("deleted.foo.example.com", endpoint.RecordTypeA, "111.222.111.222"), + endpoint.NewEndpoint("deletedaaaa.foo.example.com", endpoint.RecordTypeAAAA, "2001::111:222:111:222"), + endpoint.NewEndpoint("deletedcname.foo.example.com", endpoint.RecordTypeCNAME, "other.com"), + endpoint.NewEndpoint("deleted.nope.example.com", endpoint.RecordTypeA, "222.111.222.111"), + } + + changes := &plan.Changes{ + Create: createRecords, + UpdateNew: updatedRecords, + UpdateOld: currentRecords, + Delete: deleteRecords, + } + + if err := provider.ApplyChanges(context.Background(), changes); err != nil { + t.Fatal(err) + } +} diff --git a/internal/external-dns/azure/common.go b/internal/external-dns/azure/common.go new file mode 100644 index 00000000..688a0a57 --- /dev/null +++ b/internal/external-dns/azure/common.go @@ -0,0 +1,47 @@ +/* +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. +*/ + +//nolint:staticcheck // Required due to the current dependency on a deprecated version of azure-sdk-for-go +package azure + +import ( + "fmt" + "strconv" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + dns "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns" + privatedns "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns" +) + +// Helper function (shared with test code) +func parseMxTarget[T dns.MxRecord | privatedns.MxRecord](mxTarget string) (T, error) { + targetParts := strings.SplitN(mxTarget, " ", 2) + if len(targetParts) != 2 { + return T{}, fmt.Errorf("mx target needs to be of form '10 example.com'") + } + + preferenceRaw, exchange := targetParts[0], targetParts[1] + preference, err := strconv.ParseInt(preferenceRaw, 10, 32) + if err != nil { + return T{}, fmt.Errorf("invalid preference specified") + } + + return T{ + Preference: to.Ptr(int32(preference)), + Exchange: to.Ptr(exchange), + }, nil +} diff --git a/internal/external-dns/azure/common_test.go b/internal/external-dns/azure/common_test.go new file mode 100644 index 00000000..b85fb5f4 --- /dev/null +++ b/internal/external-dns/azure/common_test.go @@ -0,0 +1,87 @@ +/* +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 azure + +import ( + "fmt" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + dns "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns" + privatedns "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns" + + "github.com/stretchr/testify/assert" +) + +func Test_parseMxTarget(t *testing.T) { + type testCase[T interface { + dns.MxRecord | privatedns.MxRecord + }] struct { + name string + args string + want T + wantErr assert.ErrorAssertionFunc + } + + tests := []testCase[dns.MxRecord]{ + { + name: "valid mx target", + args: "10 example.com", + want: dns.MxRecord{ + Preference: to.Ptr(int32(10)), + Exchange: to.Ptr("example.com"), + }, + wantErr: assert.NoError, + }, + { + name: "valid mx target with a subdomain", + args: "99 foo-bar.example.com", + want: dns.MxRecord{ + Preference: to.Ptr(int32(99)), + Exchange: to.Ptr("foo-bar.example.com"), + }, + wantErr: assert.NoError, + }, + { + name: "invalid mx target with misplaced preference and exchange", + args: "example.com 10", + want: dns.MxRecord{}, + wantErr: assert.Error, + }, + { + name: "invalid mx target without preference", + args: "example.com", + want: dns.MxRecord{}, + wantErr: assert.Error, + }, + { + name: "invalid mx target with non numeric preference", + args: "aa example.com", + want: dns.MxRecord{}, + wantErr: assert.Error, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseMxTarget[dns.MxRecord](tt.args) + if !tt.wantErr(t, err, fmt.Sprintf("parseMxTarget(%v)", tt.args)) { + return + } + assert.Equalf(t, tt.want, got, "parseMxTarget(%v)", tt.args) + }) + } +} diff --git a/internal/external-dns/azure/config.go b/internal/external-dns/azure/config.go new file mode 100644 index 00000000..c148baf8 --- /dev/null +++ b/internal/external-dns/azure/config.go @@ -0,0 +1,154 @@ +/* +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 azure + +import ( + "fmt" + "os" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + log "github.com/sirupsen/logrus" + "gopkg.in/yaml.v2" +) + +// config represents common config items for Azure DNS and Azure Private DNS +type config struct { + Cloud string `json:"cloud" yaml:"cloud"` + TenantID string `json:"tenantId" yaml:"tenantId"` + SubscriptionID string `json:"subscriptionId" yaml:"subscriptionId"` + ResourceGroup string `json:"resourceGroup" yaml:"resourceGroup"` + Location string `json:"location" yaml:"location"` + ClientID string `json:"aadClientId" yaml:"aadClientId"` + ClientSecret string `json:"aadClientSecret" yaml:"aadClientSecret"` + UseManagedIdentityExtension bool `json:"useManagedIdentityExtension" yaml:"useManagedIdentityExtension"` + UseWorkloadIdentityExtension bool `json:"useWorkloadIdentityExtension" yaml:"useWorkloadIdentityExtension"` + UserAssignedIdentityID string `json:"userAssignedIdentityID" yaml:"userAssignedIdentityID"` +} + +func getConfig(configFile, resourceGroup, userAssignedIdentityClientID string) (*config, error) { + contents, err := os.ReadFile(configFile) + if err != nil { + return nil, fmt.Errorf("failed to read Azure config file '%s': %v", configFile, err) + } + cfg := &config{} + err = yaml.Unmarshal(contents, &cfg) + if err != nil { + return nil, fmt.Errorf("failed to read Azure config file '%s': %v", configFile, err) + } + + // If a resource group was given, override what was present in the config file + if resourceGroup != "" { + cfg.ResourceGroup = resourceGroup + } + // If userAssignedIdentityClientID is provided explicitly, override existing one in config file + if userAssignedIdentityClientID != "" { + cfg.UserAssignedIdentityID = userAssignedIdentityClientID + } + return cfg, nil +} + +// getAccessToken retrieves Azure API access token. +func getCredentials(cfg config) (azcore.TokenCredential, *arm.ClientOptions, error) { + cloudCfg, err := getCloudConfiguration(cfg.Cloud) + if err != nil { + return nil, nil, fmt.Errorf("failed to get cloud configuration: %w", err) + } + clientOpts := azcore.ClientOptions{ + Cloud: cloudCfg, + } + armClientOpts := &arm.ClientOptions{ + ClientOptions: clientOpts, + } + + // Try to retrieve token with service principal credentials. + // Try to use service principal first, some AKS clusters are in an intermediate state that `UseManagedIdentityExtension` is `true` + // and service principal exists. In this case, we still want to use service principal to authenticate. + if len(cfg.ClientID) > 0 && + len(cfg.ClientSecret) > 0 && + // due to some historical reason, for pure MSI cluster, + // they will use "msi" as placeholder in azure.json. + // In this case, we shouldn't try to use SPN to authenticate. + !strings.EqualFold(cfg.ClientID, "msi") && + !strings.EqualFold(cfg.ClientSecret, "msi") { + log.Info("Using client_id+client_secret to retrieve access token for Azure API.") + opts := &azidentity.ClientSecretCredentialOptions{ + ClientOptions: clientOpts, + } + cred, err := azidentity.NewClientSecretCredential(cfg.TenantID, cfg.ClientID, cfg.ClientSecret, opts) + if err != nil { + return nil, nil, fmt.Errorf("failed to create service principal token: %w", err) + } + return cred, armClientOpts, nil + } + + // Try to retrieve token with Workload Identity. + if cfg.UseWorkloadIdentityExtension { + log.Info("Using workload identity extension to retrieve access token for Azure API.") + + wiOpt := azidentity.WorkloadIdentityCredentialOptions{ + ClientOptions: clientOpts, + // In a standard scenario, Client ID and Tenant ID are expected to be read from environment variables. + // Though, in certain cases, it might be important to have an option to override those (e.g. when AZURE_TENANT_ID is not set + // through a webhook or azure.workload.identity/client-id service account annotation is absent). When any of those values are + // empty in our config, they will automatically be read from environment variables by azidentity + TenantID: cfg.TenantID, + ClientID: cfg.ClientID, + } + + cred, err := azidentity.NewWorkloadIdentityCredential(&wiOpt) + if err != nil { + return nil, nil, fmt.Errorf("failed to create a workload identity token: %w", err) + } + + return cred, armClientOpts, nil + } + + // Try to retrieve token with MSI. + if cfg.UseManagedIdentityExtension { + log.Info("Using managed identity extension to retrieve access token for Azure API.") + msiOpt := azidentity.ManagedIdentityCredentialOptions{ + ClientOptions: clientOpts, + } + if cfg.UserAssignedIdentityID != "" { + msiOpt.ID = azidentity.ClientID(cfg.UserAssignedIdentityID) + } + cred, err := azidentity.NewManagedIdentityCredential(&msiOpt) + if err != nil { + return nil, nil, fmt.Errorf("failed to create the managed service identity token: %w", err) + } + return cred, armClientOpts, nil + } + + return nil, nil, fmt.Errorf("no credentials provided for Azure API") +} + +func getCloudConfiguration(name string) (cloud.Configuration, error) { + name = strings.ToUpper(name) + switch name { + case "AZURECLOUD", "AZUREPUBLICCLOUD", "": + return cloud.AzurePublic, nil + case "AZUREUSGOVERNMENT", "AZUREUSGOVERNMENTCLOUD": + return cloud.AzureGovernment, nil + case "AZURECHINACLOUD": + return cloud.AzureChina, nil + } + return cloud.Configuration{}, fmt.Errorf("unknown cloud name: %s", name) +} diff --git a/internal/external-dns/azure/config_test.go b/internal/external-dns/azure/config_test.go new file mode 100644 index 00000000..7551fa51 --- /dev/null +++ b/internal/external-dns/azure/config_test.go @@ -0,0 +1,46 @@ +/* +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 azure + +import ( + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" +) + +func TestGetCloudConfiguration(t *testing.T) { + tests := map[string]struct { + cloudName string + expected cloud.Configuration + }{ + "AzureChinaCloud": {"AzureChinaCloud", cloud.AzureChina}, + "AzurePublicCloud": {"", cloud.AzurePublic}, + "AzureUSGovernment": {"AzureUSGovernmentCloud", cloud.AzureGovernment}, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + cloudCfg, err := getCloudConfiguration(test.cloudName) + if err != nil { + t.Errorf("got unexpected err %v", err) + } + if cloudCfg.ActiveDirectoryAuthorityHost != test.expected.ActiveDirectoryAuthorityHost { + t.Errorf("got %v, want %v", cloudCfg, test.expected) + } + }) + } +} From 4610c1b758e47ea28ac49d27ad492000f3ed00d0 Mon Sep 17 00:00:00 2001 From: Phil Brookes Date: Wed, 10 Jul 2024 15:04:41 +0200 Subject: [PATCH 2/2] configure azure DNS provider --- .github/workflows/ci-e2e.yaml | 11 + Makefile | 2 +- README.md | 13 + cmd/main.go | 1 + config/deploy/local/kustomization.yaml | 2 +- .../azure/azure-credentials.env.template | 1 + .../managedzone/azure/kustomization.yaml | 40 ++ .../azure/managed-zone-config.env.template | 2 + .../managedzone/azure/managed_zone.yaml | 10 + .../loadbalanced-healthchecks-dns.yaml | 51 ++ examples/kuadrant/simple-dns.yaml | 14 + .../kuadrant/simple-healthchecks-dns.yaml | 19 + go.mod | 11 +- go.sum | 25 + internal/controller/suite_test.go | 1 + .../external-dns/azure/azure_private_dns.go | 445 ------------------ .../azure/azure_privatedns_test.go | 432 ----------------- .../{ => provider}/azure/azure.go | 128 +++-- .../{ => provider}/azure/azure_test.go | 51 +- .../{ => provider}/azure/common.go | 0 .../{ => provider}/azure/common_test.go | 1 - .../{ => provider}/azure/config.go | 27 +- .../{ => provider}/azure/config_test.go | 0 internal/provider/azure/azure.go | 125 +++++ internal/provider/azure/health.go | 31 ++ internal/provider/factory.go | 2 + make/managedzones.mk | 51 +- test/e2e/healthcheck_test.go | 4 + test/e2e/helpers/common.go | 4 +- test/e2e/multi_instance/multi_record_test.go | 3 + test/e2e/multi_instance/suite_test.go | 1 + test/e2e/multi_record_test.go | 5 +- test/e2e/provider_errors_test.go | 4 + test/e2e/single_record_test.go | 3 + test/e2e/suite_test.go | 3 +- 35 files changed, 523 insertions(+), 1000 deletions(-) create mode 100644 config/local-setup/managedzone/azure/azure-credentials.env.template create mode 100644 config/local-setup/managedzone/azure/kustomization.yaml create mode 100644 config/local-setup/managedzone/azure/managed-zone-config.env.template create mode 100644 config/local-setup/managedzone/azure/managed_zone.yaml create mode 100644 examples/kuadrant/loadbalanced-healthchecks-dns.yaml create mode 100644 examples/kuadrant/simple-dns.yaml create mode 100644 examples/kuadrant/simple-healthchecks-dns.yaml delete mode 100644 internal/external-dns/azure/azure_private_dns.go delete mode 100644 internal/external-dns/azure/azure_privatedns_test.go rename internal/external-dns/{ => provider}/azure/azure.go (78%) rename internal/external-dns/{ => provider}/azure/azure_test.go (94%) rename internal/external-dns/{ => provider}/azure/common.go (100%) rename internal/external-dns/{ => provider}/azure/common_test.go (99%) rename internal/external-dns/{ => provider}/azure/config.go (86%) rename internal/external-dns/{ => provider}/azure/config_test.go (100%) create mode 100644 internal/provider/azure/azure.go create mode 100644 internal/provider/azure/health.go diff --git a/.github/workflows/ci-e2e.yaml b/.github/workflows/ci-e2e.yaml index e3304821..3dd4750d 100644 --- a/.github/workflows/ci-e2e.yaml +++ b/.github/workflows/ci-e2e.yaml @@ -45,10 +45,14 @@ jobs: - name: Create GCP provider configuration run: | make local-setup-gcp-mz-clean local-setup-gcp-mz-generate GCP_ZONE_NAME=e2e-google-hcpapps-net GCP_ZONE_DNS_NAME=e2e.google.hcpapps.net GCP_GOOGLE_CREDENTIALS='${{ secrets.E2E_GCP_GOOGLE_CREDENTIALS }}' GCP_PROJECT_ID=${{ secrets.E2E_GCP_PROJECT_ID }} + - name: Create Azure provider configuration + run: | + make local-setup-azure-mz-clean local-setup-azure-mz-generate KUADRANT_AZURE_DNS_ZONE_ID='${{ secrets.E2E_AZURE_ZONE_ID }}' KUADRANT_AZURE_ZONE_ROOT_DOMAIN=e2e.azure.hcpapps.net KUADRANT_AZURE_CREDENTIALS='${{ secrets.E2E_AZURE_CREDENTIALS }}' - name: Setup environment run: | make local-setup DEPLOY=true TEST_NAMESPACE=${{ env.TEST_NAMESPACE }} kubectl -n ${{ env.TEST_NAMESPACE }} wait --timeout=60s --for=condition=Ready managedzone/dev-mz-aws + kubectl -n ${{ env.TEST_NAMESPACE }} wait --timeout=60s --for=condition=Ready managedzone/dev-mz-azure kubectl -n ${{ env.TEST_NAMESPACE }} wait --timeout=60s --for=condition=Ready managedzone/dev-mz-gcp - name: Run suite AWS run: | @@ -64,6 +68,13 @@ jobs: export TEST_DNS_NAMESPACE=${{ env.TEST_NAMESPACE }} export TEST_DNS_PROVIDER=gcp make test-e2e + - name: Run suite Azure + run: | + export TEST_DNS_MANAGED_ZONE_NAME=dev-mz-azure + export TEST_DNS_ZONE_DOMAIN_NAME=e2e.azure.hcpapps.net + export TEST_DNS_NAMESPACE=${{ env.TEST_NAMESPACE }} + export TEST_DNS_PROVIDER=azure + make test-e2e - name: Dump Controller logs if: ${{ failure() }} run: | diff --git a/Makefile b/Makefile index fcd6e764..4ead905a 100644 --- a/Makefile +++ b/Makefile @@ -211,7 +211,7 @@ build: manifests generate fmt vet ## Build manager binary. .PHONY: run run: manifests generate fmt vet ## Run a controller from your host. - go run ./cmd/main.go --zap-devel --provider inmemory,aws,google + go run ./cmd/main.go --zap-devel --provider inmemory,aws,google,azure # If you wish built the manager image targeting other platforms you can use the --platform flag. # (i.e. docker build --platform linux/arm64 ). However, you must enable docker buildKit for it. diff --git a/README.md b/README.md index b35e6b5b..6b24ab3e 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,19 @@ make local-setup-gcp-mz-clean local-setup-gcp-mz-generate GCP_ZONE_NAME= KUADRANT_AZURE_ZONE_ROOT_DOMAIN='' +``` + +Info on generating service principal credentials [here](https://github.com/kubernetes-sigs/external-dns/blob/master/docs/tutorials/azure.md) + +Getting the zone ID can be achieved using the below command: +```bash +az network dns zone show --name --resource-group --query "{id:id,domain:name}" +``` + ### Running controller locally (default) 1. Create local environment(creates kind cluster) diff --git a/cmd/main.go b/cmd/main.go index 5c38971d..de97e9ef 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -38,6 +38,7 @@ import ( "github.com/kuadrant/dns-operator/internal/controller" "github.com/kuadrant/dns-operator/internal/provider" _ "github.com/kuadrant/dns-operator/internal/provider/aws" + _ "github.com/kuadrant/dns-operator/internal/provider/azure" _ "github.com/kuadrant/dns-operator/internal/provider/google" _ "github.com/kuadrant/dns-operator/internal/provider/inmemory" //+kubebuilder:scaffold:imports diff --git a/config/deploy/local/kustomization.yaml b/config/deploy/local/kustomization.yaml index a8ddb415..a3efa11f 100644 --- a/config/deploy/local/kustomization.yaml +++ b/config/deploy/local/kustomization.yaml @@ -10,7 +10,7 @@ patches: - patch: |- - op: add path: /spec/template/spec/containers/0/args/- - value: --provider=aws,google,inmemory + value: --provider=aws,google,inmemory,azure - op: add path: /spec/template/spec/containers/0/args/- value: --zap-log-level=debug diff --git a/config/local-setup/managedzone/azure/azure-credentials.env.template b/config/local-setup/managedzone/azure/azure-credentials.env.template new file mode 100644 index 00000000..6f8e3b95 --- /dev/null +++ b/config/local-setup/managedzone/azure/azure-credentials.env.template @@ -0,0 +1 @@ +azure.json=${KUADRANT_AZURE_CREDENTIALS} diff --git a/config/local-setup/managedzone/azure/kustomization.yaml b/config/local-setup/managedzone/azure/kustomization.yaml new file mode 100644 index 00000000..f13246df --- /dev/null +++ b/config/local-setup/managedzone/azure/kustomization.yaml @@ -0,0 +1,40 @@ +resources: + - managed_zone.yaml + +generatorOptions: + disableNameSuffixHash: true + +configMapGenerator: + - name: azure-managed-zone-config + envs: + - managed-zone-config.env + +secretGenerator: + - name: azure-credentials + envs: + - azure-credentials.env + type: "kuadrant.io/azure" + +replacements: + - source: + kind: ConfigMap + name: azure-managed-zone-config + version: v1 + fieldPath: data.AZURE_DNS_ZONE_ID + targets: + - select: + kind: ManagedZone + name: dev-mz-azure + fieldPaths: + - spec.id + - source: + kind: ConfigMap + name: azure-managed-zone-config + version: v1 + fieldPath: data.AZURE_ZONE_ROOT_DOMAIN + targets: + - select: + kind: ManagedZone + name: dev-mz-azure + fieldPaths: + - spec.domainName diff --git a/config/local-setup/managedzone/azure/managed-zone-config.env.template b/config/local-setup/managedzone/azure/managed-zone-config.env.template new file mode 100644 index 00000000..8c1be956 --- /dev/null +++ b/config/local-setup/managedzone/azure/managed-zone-config.env.template @@ -0,0 +1,2 @@ +AZURE_DNS_ZONE_ID=${KUADRANT_AZURE_DNS_ZONE_ID} +AZURE_ZONE_ROOT_DOMAIN=${KUADRANT_AZURE_ZONE_ROOT_DOMAIN} diff --git a/config/local-setup/managedzone/azure/managed_zone.yaml b/config/local-setup/managedzone/azure/managed_zone.yaml new file mode 100644 index 00000000..a8ececc1 --- /dev/null +++ b/config/local-setup/managedzone/azure/managed_zone.yaml @@ -0,0 +1,10 @@ +apiVersion: kuadrant.io/v1alpha1 +kind: ManagedZone +metadata: + name: dev-mz-azure +spec: + id: DUMMY_ID + domainName: DUMMY_DOMAIN_NAME + description: "Dev Managed Zone" + dnsProviderSecretRef: + name: azure-credentials diff --git a/examples/kuadrant/loadbalanced-healthchecks-dns.yaml b/examples/kuadrant/loadbalanced-healthchecks-dns.yaml new file mode 100644 index 00000000..e6f11eed --- /dev/null +++ b/examples/kuadrant/loadbalanced-healthchecks-dns.yaml @@ -0,0 +1,51 @@ +apiVersion: kuadrant.io/v1alpha1 +kind: DNSRecord +metadata: + name: loadbalanced-healthchecks-dns +spec: + endpoints: + - dnsName: 14byhk-2k52h1.klb.${KUADRANT_SUB_DOMAIN} + recordTTL: 60 + recordType: A + targets: + - 172.32.200.1 + - dnsName: ${KUADRANT_SUB_DOMAIN} + recordTTL: 300 + recordType: CNAME + targets: + - klb.${KUADRANT_SUB_DOMAIN} + - dnsName: eu.klb.${KUADRANT_SUB_DOMAIN} + providerSpecific: + - name: weight + value: "120" + recordTTL: 60 + recordType: CNAME + setIdentifier: 14byhk-2k52h1.klb.${KUADRANT_SUB_DOMAIN} + targets: + - 14byhk-2k52h1.klb.${KUADRANT_SUB_DOMAIN} + - dnsName: klb.${KUADRANT_SUB_DOMAIN} + providerSpecific: + - name: geo-code + value: EU + recordTTL: 300 + recordType: CNAME + setIdentifier: EU + targets: + - eu.klb.${KUADRANT_SUB_DOMAIN} + - dnsName: klb.${KUADRANT_SUB_DOMAIN} + providerSpecific: + - name: geo-code + value: '*' + recordTTL: 300 + recordType: CNAME + setIdentifier: default + targets: + - eu.klb.${KUADRANT_SUB_DOMAIN} + healthCheck: + endpoint: / + failureThreshold: 3 + port: 80 + protocol: HTTPS + managedZone: + name: dev-mz-azure + rootHost: ${KUADRANT_SUB_DOMAIN} \ No newline at end of file diff --git a/examples/kuadrant/simple-dns.yaml b/examples/kuadrant/simple-dns.yaml new file mode 100644 index 00000000..d4bdb864 --- /dev/null +++ b/examples/kuadrant/simple-dns.yaml @@ -0,0 +1,14 @@ +apiVersion: kuadrant.io/v1alpha1 +kind: DNSRecord +metadata: + name: simple-dns +spec: + endpoints: + - dnsName: ${KUADRANT_SUB_DOMAIN} + recordTTL: 60 + recordType: A + targets: + - 172.32.200.17 + managedZone: + name: dev-mz-azure + rootHost: ${KUADRANT_SUB_DOMAIN} \ No newline at end of file diff --git a/examples/kuadrant/simple-healthchecks-dns.yaml b/examples/kuadrant/simple-healthchecks-dns.yaml new file mode 100644 index 00000000..a73d4839 --- /dev/null +++ b/examples/kuadrant/simple-healthchecks-dns.yaml @@ -0,0 +1,19 @@ +apiVersion: kuadrant.io/v1alpha1 +kind: DNSRecord +metadata: + name: simple-dns +spec: + healthCheck: + endpoint: "/" + port: 80 + protocol: "HTTPS" + failureThreshold: 3 + endpoints: + - dnsName: ${KUADRANT_SUB_DOMAIN} + recordTTL: 60 + recordType: A + targets: + - 172.32.200.17 + managedZone: + name: dev-mz-azure + rootHost: ${KUADRANT_SUB_DOMAIN} \ No newline at end of file diff --git a/go.mod b/go.mod index 59886c21..846323f2 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,10 @@ go 1.21 require ( cloud.google.com/go/compute/metadata v0.2.3 + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0 + github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.0 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.1.0 + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.1.0 github.com/aws/aws-sdk-go v1.44.311 github.com/go-logr/logr v1.3.0 github.com/google/go-cmp v0.6.0 @@ -20,6 +24,7 @@ require ( golang.org/x/net v0.23.0 golang.org/x/oauth2 v0.13.0 google.golang.org/api v0.134.0 + gopkg.in/yaml.v2 v2.4.0 k8s.io/api v0.28.3 k8s.io/apimachinery v0.28.3 k8s.io/client-go v0.28.3 @@ -30,6 +35,8 @@ require ( require ( cloud.google.com/go/compute v1.20.1 // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0 // indirect + github.com/AzureAD/microsoft-authentication-library-for-go v1.0.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect @@ -42,6 +49,7 @@ require ( github.com/go-openapi/swag v0.22.4 // indirect github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang-jwt/jwt/v4 v4.5.0 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.3 // indirect github.com/google/gnostic-models v0.6.8 // indirect @@ -54,11 +62,13 @@ require ( github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.5.0 // indirect @@ -82,7 +92,6 @@ require ( google.golang.org/grpc v1.56.3 // indirect google.golang.org/protobuf v1.33.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/apiextensions-apiserver v0.28.3 // indirect k8s.io/component-base v0.28.3 // indirect diff --git a/go.sum b/go.sum index bda6cd6d..5638ab6c 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,22 @@ cloud.google.com/go/compute v1.20.1 h1:6aKEtlUiwEpJzM001l0yFkpXmUVXaN8W+fbkb2AZN cloud.google.com/go/compute v1.20.1/go.mod h1:4tCnrn48xsqlwSAiLf1HXMQk8CONslYbdiEZc9FEIbM= cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0 h1:8q4SaHjFsClSvuVne0ID/5Ka8u3fcIHyqkLjcFpNRHQ= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.7.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.0 h1:vcYCAze6p19qBW7MhZybIsqD8sMV8js0NyQM8JDnVtg= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.0/go.mod h1:OQeznEEkTZ9OrhHJoDD8ZDq51FHgXjqtP9z6bEwBq9U= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0 h1:sXr+ck84g/ZlZUOZiNELInmMgOsuGwdjjVkEIde0OtY= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0/go.mod h1:okt5dMMTOFjX/aovMlrjvvXoPMBVSPzk9185BT0+eZM= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.1.0 h1:8iR6OLffWWorFdzL2JFCab5xpD8VKEE2DUBBl+HNTDY= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.1.0/go.mod h1:copqlcjMWc/wgQ1N2fzsJFQxDdqKGg1EQt8T5wJMOGE= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal v1.1.2 h1:mLY+pNLjCUeKhgnAJWAKhEUQM+RJQo2H1fuGSw1Ky1E= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal v1.1.2/go.mod h1:FbdwsQ2EzwvXxOPcMFYO8ogEc9uMMIj3YkmCdXdAFmk= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.1.0 h1:rR8ZW79lE/ppfXTfiYSnMFv5EzmVuY4pfZWIkscIJ64= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.1.0/go.mod h1:y2zXtLSMM/X5Mfawq0lOftpWn3f4V6OCsRdINsvWBPI= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.0.0 h1:ECsQtyERDVz3NP3kvDOTLvbQhqWp/x9EsGKtb4ogUr8= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.0.0/go.mod h1:s1tW/At+xHqjNFvWU4G0c0Qv33KOhvbGNj0RCTQDV8s= +github.com/AzureAD/microsoft-authentication-library-for-go v1.0.0 h1:OBhqkivkhkMqLPymWEppkm7vgPQY2XsHoEkaMQ0AdZY= +github.com/AzureAD/microsoft-authentication-library-for-go v1.0.0/go.mod h1:kgDmCTgBzIEPFElEF+FK0SdjAor06dRq2Go927dnQ6o= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/aws/aws-sdk-go v1.44.311 h1:60i8hyVMOXqabKJQPCq4qKRBQ6hRafI/WOcDxGM+J7Q= @@ -26,6 +42,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= +github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= @@ -59,6 +77,8 @@ github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEe github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= +github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= @@ -128,6 +148,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/linki/instrumented_http v0.3.0 h1:dsN92+mXpfZtjJraartcQ99jnuw7fqsnPDjr85ma2dA= github.com/linki/instrumented_http v0.3.0/go.mod h1:pjYbItoegfuVi2GUOMhEqzvm/SJKuEL3H0tc8QRLRFk= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= @@ -147,6 +169,8 @@ github.com/onsi/ginkgo/v2 v2.13.2 h1:Bi2gGVkfn6gQcjNjZJVO8Gf0FHzMPf2phUei9tejVMs github.com/onsi/ginkgo/v2 v2.13.2/go.mod h1:XStQ8QcGwLyF4HdfcZB8SFOS/MWCgDuXMSBe6zrvLgM= github.com/onsi/gomega v1.30.0 h1:hvMK7xYz4D3HapigLTeGdId/NcfQx1VHMJc60ew99+8= github.com/onsi/gomega v1.30.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ= +github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU= +github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -262,6 +286,7 @@ golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go index ed3ad42b..3561f95a 100644 --- a/internal/controller/suite_test.go +++ b/internal/controller/suite_test.go @@ -43,6 +43,7 @@ import ( "github.com/kuadrant/dns-operator/api/v1alpha1" "github.com/kuadrant/dns-operator/internal/provider" _ "github.com/kuadrant/dns-operator/internal/provider/aws" + _ "github.com/kuadrant/dns-operator/internal/provider/azure" _ "github.com/kuadrant/dns-operator/internal/provider/google" _ "github.com/kuadrant/dns-operator/internal/provider/inmemory" //+kubebuilder:scaffold:imports diff --git a/internal/external-dns/azure/azure_private_dns.go b/internal/external-dns/azure/azure_private_dns.go deleted file mode 100644 index 50df066f..00000000 --- a/internal/external-dns/azure/azure_private_dns.go +++ /dev/null @@ -1,445 +0,0 @@ -/* -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. -*/ - -//nolint:staticcheck // Required due to the current dependency on a deprecated version of azure-sdk-for-go -package azure - -import ( - "context" - "fmt" - "strings" - - azcoreruntime "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" - "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" - privatedns "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns" - log "github.com/sirupsen/logrus" - - "sigs.k8s.io/external-dns/endpoint" - "sigs.k8s.io/external-dns/plan" - "sigs.k8s.io/external-dns/provider" -) - -// PrivateZonesClient is an interface of privatedns.PrivateZoneClient that can be stubbed for testing. -type PrivateZonesClient interface { - NewListByResourceGroupPager(resourceGroupName string, options *privatedns.PrivateZonesClientListByResourceGroupOptions) *azcoreruntime.Pager[privatedns.PrivateZonesClientListByResourceGroupResponse] -} - -// PrivateRecordSetsClient is an interface of privatedns.RecordSetsClient that can be stubbed for testing. -type PrivateRecordSetsClient interface { - NewListPager(resourceGroupName string, privateZoneName string, options *privatedns.RecordSetsClientListOptions) *azcoreruntime.Pager[privatedns.RecordSetsClientListResponse] - Delete(ctx context.Context, resourceGroupName string, privateZoneName string, recordType privatedns.RecordType, relativeRecordSetName string, options *privatedns.RecordSetsClientDeleteOptions) (privatedns.RecordSetsClientDeleteResponse, error) - CreateOrUpdate(ctx context.Context, resourceGroupName string, privateZoneName string, recordType privatedns.RecordType, relativeRecordSetName string, parameters privatedns.RecordSet, options *privatedns.RecordSetsClientCreateOrUpdateOptions) (privatedns.RecordSetsClientCreateOrUpdateResponse, error) -} - -// AzurePrivateDNSProvider implements the DNS provider for Microsoft's Azure Private DNS service -type AzurePrivateDNSProvider struct { - provider.BaseProvider - domainFilter endpoint.DomainFilter - zoneIDFilter provider.ZoneIDFilter - dryRun bool - resourceGroup string - userAssignedIdentityClientID string - zonesClient PrivateZonesClient - recordSetsClient PrivateRecordSetsClient -} - -// NewAzurePrivateDNSProvider creates a new Azure Private DNS provider. -// -// Returns the provider or an error if a provider could not be created. -func NewAzurePrivateDNSProvider(configFile string, domainFilter endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, resourceGroup, userAssignedIdentityClientID string, dryRun bool) (*AzurePrivateDNSProvider, error) { - cfg, err := getConfig(configFile, resourceGroup, userAssignedIdentityClientID) - if err != nil { - return nil, fmt.Errorf("failed to read Azure config file '%s': %v", configFile, err) - } - cred, clientOpts, err := getCredentials(*cfg) - if err != nil { - return nil, fmt.Errorf("failed to get credentials: %w", err) - } - - zonesClient, err := privatedns.NewPrivateZonesClient(cfg.SubscriptionID, cred, clientOpts) - if err != nil { - return nil, err - } - recordSetsClient, err := privatedns.NewRecordSetsClient(cfg.SubscriptionID, cred, clientOpts) - if err != nil { - return nil, err - } - return &AzurePrivateDNSProvider{ - domainFilter: domainFilter, - zoneIDFilter: zoneIDFilter, - dryRun: dryRun, - resourceGroup: cfg.ResourceGroup, - userAssignedIdentityClientID: cfg.UserAssignedIdentityID, - zonesClient: zonesClient, - recordSetsClient: recordSetsClient, - }, nil -} - -// Records gets the current records. -// -// Returns the current records or an error if the operation failed. -func (p *AzurePrivateDNSProvider) Records(ctx context.Context) (endpoints []*endpoint.Endpoint, _ error) { - zones, err := p.zones(ctx) - if err != nil { - return nil, err - } - - log.Debugf("Retrieving Azure Private DNS Records for resource group '%s'", p.resourceGroup) - - for _, zone := range zones { - pager := p.recordSetsClient.NewListPager(p.resourceGroup, *zone.Name, &privatedns.RecordSetsClientListOptions{Top: nil}) - for pager.More() { - nextResult, err := pager.NextPage(ctx) - if err != nil { - return nil, err - } - - for _, recordSet := range nextResult.Value { - var recordType string - if recordSet.Type == nil { - log.Debugf("Skipping invalid record set with missing type.") - continue - } - recordType = strings.TrimPrefix(*recordSet.Type, "Microsoft.Network/privateDnsZones/") - - var name string - if recordSet.Name == nil { - log.Debugf("Skipping invalid record set with missing name.") - continue - } - name = formatAzureDNSName(*recordSet.Name, *zone.Name) - - targets := extractAzurePrivateDNSTargets(recordSet) - if len(targets) == 0 { - log.Debugf("Failed to extract targets for '%s' with type '%s'.", name, recordType) - continue - } - - var ttl endpoint.TTL - if recordSet.Properties.TTL != nil { - ttl = endpoint.TTL(*recordSet.Properties.TTL) - } - - ep := endpoint.NewEndpointWithTTL(name, recordType, ttl, targets...) - log.Debugf( - "Found %s record for '%s' with target '%s'.", - ep.RecordType, - ep.DNSName, - ep.Targets, - ) - endpoints = append(endpoints, ep) - } - } - } - - log.Debugf("Returning %d Azure Private DNS Records for resource group '%s'", len(endpoints), p.resourceGroup) - - return endpoints, nil -} - -// ApplyChanges applies the given changes. -// -// Returns nil if the operation was successful or an error if the operation failed. -func (p *AzurePrivateDNSProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { - log.Debugf("Received %d changes to process", len(changes.Create)+len(changes.Delete)+len(changes.UpdateNew)+len(changes.UpdateOld)) - - zones, err := p.zones(ctx) - if err != nil { - return err - } - - deleted, updated := p.mapChanges(zones, changes) - p.deleteRecords(ctx, deleted) - p.updateRecords(ctx, updated) - return nil -} - -func (p *AzurePrivateDNSProvider) zones(ctx context.Context) ([]privatedns.PrivateZone, error) { - log.Debugf("Retrieving Azure Private DNS zones for Resource Group '%s'", p.resourceGroup) - - var zones []privatedns.PrivateZone - - pager := p.zonesClient.NewListByResourceGroupPager(p.resourceGroup, &privatedns.PrivateZonesClientListByResourceGroupOptions{Top: nil}) - for pager.More() { - nextResult, err := pager.NextPage(ctx) - if err != nil { - return nil, err - } - for _, zone := range nextResult.Value { - log.Debugf("Validating Zone: %v", *zone.Name) - - if zone.Name != nil && p.domainFilter.Match(*zone.Name) && p.zoneIDFilter.Match(*zone.ID) { - zones = append(zones, *zone) - } - } - } - - log.Debugf("Found %d Azure Private DNS zone(s).", len(zones)) - return zones, nil -} - -type azurePrivateDNSChangeMap map[string][]*endpoint.Endpoint - -func (p *AzurePrivateDNSProvider) mapChanges(zones []privatedns.PrivateZone, changes *plan.Changes) (azurePrivateDNSChangeMap, azurePrivateDNSChangeMap) { - ignored := map[string]bool{} - deleted := azurePrivateDNSChangeMap{} - updated := azurePrivateDNSChangeMap{} - zoneNameIDMapper := provider.ZoneIDName{} - for _, z := range zones { - if z.Name != nil { - zoneNameIDMapper.Add(*z.Name, *z.Name) - } - } - mapChange := func(changeMap azurePrivateDNSChangeMap, change *endpoint.Endpoint) { - zone, _ := zoneNameIDMapper.FindZone(change.DNSName) - if zone == "" { - if _, ok := ignored[change.DNSName]; !ok { - ignored[change.DNSName] = true - log.Infof("Ignoring changes to '%s' because a suitable Azure Private DNS zone was not found.", change.DNSName) - } - return - } - // Ensure the record type is suitable - changeMap[zone] = append(changeMap[zone], change) - } - - for _, change := range changes.Delete { - mapChange(deleted, change) - } - - for _, change := range changes.Create { - mapChange(updated, change) - } - - for _, change := range changes.UpdateNew { - mapChange(updated, change) - } - return deleted, updated -} - -func (p *AzurePrivateDNSProvider) deleteRecords(ctx context.Context, deleted azurePrivateDNSChangeMap) { - log.Debugf("Records to be deleted: %d", len(deleted)) - // Delete records first - for zone, endpoints := range deleted { - for _, ep := range endpoints { - name := p.recordSetNameForZone(zone, ep) - if p.dryRun { - log.Infof("Would delete %s record named '%s' for Azure Private DNS zone '%s'.", ep.RecordType, name, zone) - } else { - log.Infof("Deleting %s record named '%s' for Azure Private DNS zone '%s'.", ep.RecordType, name, zone) - if _, err := p.recordSetsClient.Delete(ctx, p.resourceGroup, zone, privatedns.RecordType(ep.RecordType), name, nil); err != nil { - log.Errorf( - "Failed to delete %s record named '%s' for Azure Private DNS zone '%s': %v", - ep.RecordType, - name, - zone, - err, - ) - } - } - } - } -} - -func (p *AzurePrivateDNSProvider) updateRecords(ctx context.Context, updated azurePrivateDNSChangeMap) { - log.Debugf("Records to be updated: %d", len(updated)) - for zone, endpoints := range updated { - for _, ep := range endpoints { - name := p.recordSetNameForZone(zone, ep) - if p.dryRun { - log.Infof( - "Would update %s record named '%s' to '%s' for Azure Private DNS zone '%s'.", - ep.RecordType, - name, - ep.Targets, - zone, - ) - continue - } - - log.Infof( - "Updating %s record named '%s' to '%s' for Azure Private DNS zone '%s'.", - ep.RecordType, - name, - ep.Targets, - zone, - ) - - recordSet, err := p.newRecordSet(ep) - if err == nil { - _, err = p.recordSetsClient.CreateOrUpdate( - ctx, - p.resourceGroup, - zone, - privatedns.RecordType(ep.RecordType), - name, - recordSet, - nil, - ) - } - if err != nil { - log.Errorf( - "Failed to update %s record named '%s' to '%s' for Azure Private DNS zone '%s': %v", - ep.RecordType, - name, - ep.Targets, - zone, - err, - ) - } - } - } -} - -func (p *AzurePrivateDNSProvider) recordSetNameForZone(zone string, endpoint *endpoint.Endpoint) string { - // Remove the zone from the record set - name := endpoint.DNSName - name = name[:len(name)-len(zone)] - name = strings.TrimSuffix(name, ".") - - // For root, use @ - if name == "" { - return "@" - } - return name -} - -func (p *AzurePrivateDNSProvider) newRecordSet(endpoint *endpoint.Endpoint) (privatedns.RecordSet, error) { - var ttl int64 = azureRecordTTL - if endpoint.RecordTTL.IsConfigured() { - ttl = int64(endpoint.RecordTTL) - } - switch privatedns.RecordType(endpoint.RecordType) { - case privatedns.RecordTypeA: - aRecords := make([]*privatedns.ARecord, len(endpoint.Targets)) - for i, target := range endpoint.Targets { - aRecords[i] = &privatedns.ARecord{ - IPv4Address: to.Ptr(target), - } - } - return privatedns.RecordSet{ - Properties: &privatedns.RecordSetProperties{ - TTL: to.Ptr(ttl), - ARecords: aRecords, - }, - }, nil - case privatedns.RecordTypeAAAA: - aaaaRecords := make([]*privatedns.AaaaRecord, len(endpoint.Targets)) - for i, target := range endpoint.Targets { - aaaaRecords[i] = &privatedns.AaaaRecord{ - IPv6Address: to.Ptr(target), - } - } - return privatedns.RecordSet{ - Properties: &privatedns.RecordSetProperties{ - TTL: to.Ptr(ttl), - AaaaRecords: aaaaRecords, - }, - }, nil - case privatedns.RecordTypeCNAME: - return privatedns.RecordSet{ - Properties: &privatedns.RecordSetProperties{ - TTL: to.Ptr(ttl), - CnameRecord: &privatedns.CnameRecord{ - Cname: to.Ptr(endpoint.Targets[0]), - }, - }, - }, nil - case privatedns.RecordTypeMX: - mxRecords := make([]*privatedns.MxRecord, len(endpoint.Targets)) - for i, target := range endpoint.Targets { - mxRecord, err := parseMxTarget[privatedns.MxRecord](target) - if err != nil { - return privatedns.RecordSet{}, err - } - mxRecords[i] = &mxRecord - } - return privatedns.RecordSet{ - Properties: &privatedns.RecordSetProperties{ - TTL: to.Ptr(ttl), - MxRecords: mxRecords, - }, - }, nil - case privatedns.RecordTypeTXT: - return privatedns.RecordSet{ - Properties: &privatedns.RecordSetProperties{ - TTL: to.Ptr(ttl), - TxtRecords: []*privatedns.TxtRecord{ - { - Value: []*string{ - &endpoint.Targets[0], - }, - }, - }, - }, - }, nil - } - return privatedns.RecordSet{}, fmt.Errorf("unsupported record type '%s'", endpoint.RecordType) -} - -// Helper function (shared with test code) -func extractAzurePrivateDNSTargets(recordSet *privatedns.RecordSet) []string { - properties := recordSet.Properties - if properties == nil { - return []string{} - } - - // Check for A records - aRecords := properties.ARecords - if len(aRecords) > 0 && (aRecords)[0].IPv4Address != nil { - targets := make([]string, len(aRecords)) - for i, aRecord := range aRecords { - targets[i] = *aRecord.IPv4Address - } - return targets - } - - // Check for AAAA records - aaaaRecords := properties.AaaaRecords - if len(aaaaRecords) > 0 && (aaaaRecords)[0].IPv6Address != nil { - targets := make([]string, len(aaaaRecords)) - for i, aaaaRecord := range aaaaRecords { - targets[i] = *aaaaRecord.IPv6Address - } - return targets - } - - // Check for CNAME records - cnameRecord := properties.CnameRecord - if cnameRecord != nil && cnameRecord.Cname != nil { - return []string{*cnameRecord.Cname} - } - - // Check for MX records - mxRecords := properties.MxRecords - if len(mxRecords) > 0 && (mxRecords)[0].Exchange != nil { - targets := make([]string, len(mxRecords)) - for i, mxRecord := range mxRecords { - targets[i] = fmt.Sprintf("%d %s", *mxRecord.Preference, *mxRecord.Exchange) - } - return targets - } - - // Check for TXT records - txtRecords := properties.TxtRecords - if len(txtRecords) > 0 && (txtRecords)[0].Value != nil { - values := (txtRecords)[0].Value - if len(values) > 0 { - return []string{*(values)[0]} - } - } - return []string{} -} diff --git a/internal/external-dns/azure/azure_privatedns_test.go b/internal/external-dns/azure/azure_privatedns_test.go deleted file mode 100644 index 567badea..00000000 --- a/internal/external-dns/azure/azure_privatedns_test.go +++ /dev/null @@ -1,432 +0,0 @@ -/* -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 azure - -import ( - "context" - "testing" - - azcoreruntime "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" - "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" - privatedns "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns" - "sigs.k8s.io/external-dns/endpoint" - "sigs.k8s.io/external-dns/plan" - "sigs.k8s.io/external-dns/provider" -) - -const ( - recordTTL = 300 -) - -// mockPrivateZonesClient implements the methods of the Azure Private DNS Zones Client which are used in the Azure Private DNS Provider -// and returns static results which are defined per test -type mockPrivateZonesClient struct { - pagingHandler azcoreruntime.PagingHandler[privatedns.PrivateZonesClientListByResourceGroupResponse] -} - -func newMockPrivateZonesClient(zones []*privatedns.PrivateZone) mockPrivateZonesClient { - pagingHandler := azcoreruntime.PagingHandler[privatedns.PrivateZonesClientListByResourceGroupResponse]{ - More: func(resp privatedns.PrivateZonesClientListByResourceGroupResponse) bool { - return false - }, - Fetcher: func(context.Context, *privatedns.PrivateZonesClientListByResourceGroupResponse) (privatedns.PrivateZonesClientListByResourceGroupResponse, error) { - return privatedns.PrivateZonesClientListByResourceGroupResponse{ - PrivateZoneListResult: privatedns.PrivateZoneListResult{ - Value: zones, - }, - }, nil - }, - } - return mockPrivateZonesClient{ - pagingHandler: pagingHandler, - } -} - -func (client *mockPrivateZonesClient) NewListByResourceGroupPager(resourceGroupName string, options *privatedns.PrivateZonesClientListByResourceGroupOptions) *azcoreruntime.Pager[privatedns.PrivateZonesClientListByResourceGroupResponse] { - return azcoreruntime.NewPager(client.pagingHandler) -} - -// mockPrivateRecordSetsClient implements the methods of the Azure Private DNS RecordSet Client which are used in the Azure Private DNS Provider -// and returns static results which are defined per test -type mockPrivateRecordSetsClient struct { - pagingHandler azcoreruntime.PagingHandler[privatedns.RecordSetsClientListResponse] - deletedEndpoints []*endpoint.Endpoint - updatedEndpoints []*endpoint.Endpoint -} - -func newMockPrivateRecordSectsClient(recordSets []*privatedns.RecordSet) mockPrivateRecordSetsClient { - pagingHandler := azcoreruntime.PagingHandler[privatedns.RecordSetsClientListResponse]{ - More: func(resp privatedns.RecordSetsClientListResponse) bool { - return false - }, - Fetcher: func(context.Context, *privatedns.RecordSetsClientListResponse) (privatedns.RecordSetsClientListResponse, error) { - return privatedns.RecordSetsClientListResponse{ - RecordSetListResult: privatedns.RecordSetListResult{ - Value: recordSets, - }, - }, nil - }, - } - return mockPrivateRecordSetsClient{ - pagingHandler: pagingHandler, - } -} - -func (client *mockPrivateRecordSetsClient) NewListPager(resourceGroupName string, privateZoneName string, options *privatedns.RecordSetsClientListOptions) *azcoreruntime.Pager[privatedns.RecordSetsClientListResponse] { - return azcoreruntime.NewPager(client.pagingHandler) -} - -func (client *mockPrivateRecordSetsClient) Delete(ctx context.Context, resourceGroupName string, privateZoneName string, recordType privatedns.RecordType, relativeRecordSetName string, options *privatedns.RecordSetsClientDeleteOptions) (privatedns.RecordSetsClientDeleteResponse, error) { - client.deletedEndpoints = append( - client.deletedEndpoints, - endpoint.NewEndpoint( - formatAzureDNSName(relativeRecordSetName, privateZoneName), - string(recordType), - "", - ), - ) - return privatedns.RecordSetsClientDeleteResponse{}, nil -} - -func (client *mockPrivateRecordSetsClient) CreateOrUpdate(ctx context.Context, resourceGroupName string, privateZoneName string, recordType privatedns.RecordType, relativeRecordSetName string, parameters privatedns.RecordSet, options *privatedns.RecordSetsClientCreateOrUpdateOptions) (privatedns.RecordSetsClientCreateOrUpdateResponse, error) { - var ttl endpoint.TTL - if parameters.Properties.TTL != nil { - ttl = endpoint.TTL(*parameters.Properties.TTL) - } - client.updatedEndpoints = append( - client.updatedEndpoints, - endpoint.NewEndpointWithTTL( - formatAzureDNSName(relativeRecordSetName, privateZoneName), - string(recordType), - ttl, - extractAzurePrivateDNSTargets(¶meters)..., - ), - ) - return privatedns.RecordSetsClientCreateOrUpdateResponse{}, nil - //return parameters, nil -} - -func createMockPrivateZone(zone string, id string) *privatedns.PrivateZone { - return &privatedns.PrivateZone{ - ID: to.Ptr(id), - Name: to.Ptr(zone), - } -} - -func privateARecordSetPropertiesGetter(values []string, ttl int64) *privatedns.RecordSetProperties { - aRecords := make([]*privatedns.ARecord, len(values)) - for i, value := range values { - aRecords[i] = &privatedns.ARecord{ - IPv4Address: to.Ptr(value), - } - } - return &privatedns.RecordSetProperties{ - TTL: to.Ptr(ttl), - ARecords: aRecords, - } -} - -func privateAAAARecordSetPropertiesGetter(values []string, ttl int64) *privatedns.RecordSetProperties { - aaaaRecords := make([]*privatedns.AaaaRecord, len(values)) - for i, value := range values { - aaaaRecords[i] = &privatedns.AaaaRecord{ - IPv6Address: to.Ptr(value), - } - } - return &privatedns.RecordSetProperties{ - TTL: to.Ptr(ttl), - AaaaRecords: aaaaRecords, - } -} - -func privateCNameRecordSetPropertiesGetter(values []string, ttl int64) *privatedns.RecordSetProperties { - return &privatedns.RecordSetProperties{ - TTL: to.Ptr(ttl), - CnameRecord: &privatedns.CnameRecord{ - Cname: to.Ptr(values[0]), - }, - } -} - -func privateMXRecordSetPropertiesGetter(values []string, ttl int64) *privatedns.RecordSetProperties { - mxRecords := make([]*privatedns.MxRecord, len(values)) - for i, target := range values { - mxRecord, _ := parseMxTarget[privatedns.MxRecord](target) - mxRecords[i] = &mxRecord - } - return &privatedns.RecordSetProperties{ - TTL: to.Ptr(ttl), - MxRecords: mxRecords, - } -} - -func privateTxtRecordSetPropertiesGetter(values []string, ttl int64) *privatedns.RecordSetProperties { - return &privatedns.RecordSetProperties{ - TTL: to.Ptr(ttl), - TxtRecords: []*privatedns.TxtRecord{ - { - Value: []*string{&values[0]}, - }, - }, - } -} - -func privateOthersRecordSetPropertiesGetter(values []string, ttl int64) *privatedns.RecordSetProperties { - return &privatedns.RecordSetProperties{ - TTL: to.Ptr(ttl), - } -} - -func createPrivateMockRecordSet(name, recordType string, values ...string) *privatedns.RecordSet { - return createPrivateMockRecordSetMultiWithTTL(name, recordType, 0, values...) -} - -func createPrivateMockRecordSetWithTTL(name, recordType, value string, ttl int64) *privatedns.RecordSet { - return createPrivateMockRecordSetMultiWithTTL(name, recordType, ttl, value) -} - -func createPrivateMockRecordSetMultiWithTTL(name, recordType string, ttl int64, values ...string) *privatedns.RecordSet { - var getterFunc func(values []string, ttl int64) *privatedns.RecordSetProperties - - switch recordType { - case endpoint.RecordTypeA: - getterFunc = privateARecordSetPropertiesGetter - case endpoint.RecordTypeAAAA: - getterFunc = privateAAAARecordSetPropertiesGetter - case endpoint.RecordTypeCNAME: - getterFunc = privateCNameRecordSetPropertiesGetter - case endpoint.RecordTypeMX: - getterFunc = privateMXRecordSetPropertiesGetter - case endpoint.RecordTypeTXT: - getterFunc = privateTxtRecordSetPropertiesGetter - default: - getterFunc = privateOthersRecordSetPropertiesGetter - } - return &privatedns.RecordSet{ - Name: to.Ptr(name), - Type: to.Ptr("Microsoft.Network/privateDnsZones/" + recordType), - Properties: getterFunc(values, ttl), - } -} - -// newMockedAzurePrivateDNSProvider creates an AzureProvider comprising the mocked clients for zones and recordsets -func newMockedAzurePrivateDNSProvider(domainFilter endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, dryRun bool, resourceGroup string, zones []*privatedns.PrivateZone, recordSets []*privatedns.RecordSet) (*AzurePrivateDNSProvider, error) { - zonesClient := newMockPrivateZonesClient(zones) - recordSetsClient := newMockPrivateRecordSectsClient(recordSets) - return newAzurePrivateDNSProvider(domainFilter, zoneIDFilter, dryRun, resourceGroup, &zonesClient, &recordSetsClient), nil -} - -func newAzurePrivateDNSProvider(domainFilter endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, dryRun bool, resourceGroup string, privateZonesClient PrivateZonesClient, privateRecordsClient PrivateRecordSetsClient) *AzurePrivateDNSProvider { - return &AzurePrivateDNSProvider{ - domainFilter: domainFilter, - zoneIDFilter: zoneIDFilter, - dryRun: dryRun, - resourceGroup: resourceGroup, - zonesClient: privateZonesClient, - recordSetsClient: privateRecordsClient, - } -} - -func TestAzurePrivateDNSRecord(t *testing.T) { - provider, err := newMockedAzurePrivateDNSProvider(endpoint.NewDomainFilter([]string{"example.com"}), provider.NewZoneIDFilter([]string{""}), true, "k8s", - []*privatedns.PrivateZone{ - createMockPrivateZone("example.com", "/privateDnsZones/example.com"), - }, - []*privatedns.RecordSet{ - createPrivateMockRecordSet("@", "NS", "ns1-03.azure-dns.com."), - createPrivateMockRecordSet("@", "SOA", "Email: azuredns-hostmaster.microsoft.com"), - createPrivateMockRecordSet("@", endpoint.RecordTypeA, "123.123.123.122"), - createPrivateMockRecordSet("@", endpoint.RecordTypeAAAA, "2001::123:123:123:122"), - createPrivateMockRecordSet("@", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default"), - createPrivateMockRecordSetWithTTL("nginx", endpoint.RecordTypeA, "123.123.123.123", 3600), - createPrivateMockRecordSetWithTTL("nginx", endpoint.RecordTypeAAAA, "2001::123:123:123:123", 3600), - createPrivateMockRecordSetWithTTL("nginx", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default", recordTTL), - createPrivateMockRecordSetWithTTL("hack", endpoint.RecordTypeCNAME, "hack.azurewebsites.net", 10), - createPrivateMockRecordSetWithTTL("mail", endpoint.RecordTypeMX, "10 example.com", 4000), - }) - if err != nil { - t.Fatal(err) - } - - actual, err := provider.Records(context.Background()) - if err != nil { - t.Fatal(err) - } - expected := []*endpoint.Endpoint{ - endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "123.123.123.122"), - endpoint.NewEndpoint("example.com", endpoint.RecordTypeAAAA, "2001::123:123:123:122"), - endpoint.NewEndpoint("example.com", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default"), - endpoint.NewEndpointWithTTL("nginx.example.com", endpoint.RecordTypeA, 3600, "123.123.123.123"), - endpoint.NewEndpointWithTTL("nginx.example.com", endpoint.RecordTypeAAAA, 3600, "2001::123:123:123:123"), - endpoint.NewEndpointWithTTL("nginx.example.com", endpoint.RecordTypeTXT, recordTTL, "heritage=external-dns,external-dns/owner=default"), - endpoint.NewEndpointWithTTL("hack.example.com", endpoint.RecordTypeCNAME, 10, "hack.azurewebsites.net"), - endpoint.NewEndpointWithTTL("mail.example.com", endpoint.RecordTypeMX, 4000, "10 example.com"), - } - - validateAzureEndpoints(t, actual, expected) -} - -func TestAzurePrivateDNSMultiRecord(t *testing.T) { - provider, err := newMockedAzurePrivateDNSProvider(endpoint.NewDomainFilter([]string{"example.com"}), provider.NewZoneIDFilter([]string{""}), true, "k8s", - []*privatedns.PrivateZone{ - createMockPrivateZone("example.com", "/privateDnsZones/example.com"), - }, - []*privatedns.RecordSet{ - createPrivateMockRecordSet("@", "NS", "ns1-03.azure-dns.com."), - createPrivateMockRecordSet("@", "SOA", "Email: azuredns-hostmaster.microsoft.com"), - createPrivateMockRecordSet("@", endpoint.RecordTypeA, "123.123.123.122", "234.234.234.233"), - createPrivateMockRecordSet("@", endpoint.RecordTypeAAAA, "2001::123:123:123:122", "2001::234:234:234:233"), - createPrivateMockRecordSet("@", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default"), - createPrivateMockRecordSetMultiWithTTL("nginx", endpoint.RecordTypeA, 3600, "123.123.123.123", "234.234.234.234"), - createPrivateMockRecordSetMultiWithTTL("nginx", endpoint.RecordTypeAAAA, 3600, "2001::123:123:123:123", "2001::234:234:234:234"), - createPrivateMockRecordSetWithTTL("nginx", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default", recordTTL), - createPrivateMockRecordSetWithTTL("hack", endpoint.RecordTypeCNAME, "hack.azurewebsites.net", 10), - createPrivateMockRecordSetMultiWithTTL("mail", endpoint.RecordTypeMX, 4000, "10 example.com", "20 backup.example.com"), - }) - if err != nil { - t.Fatal(err) - } - - actual, err := provider.Records(context.Background()) - if err != nil { - t.Fatal(err) - } - expected := []*endpoint.Endpoint{ - endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "123.123.123.122", "234.234.234.233"), - endpoint.NewEndpoint("example.com", endpoint.RecordTypeAAAA, "2001::123:123:123:122", "2001::234:234:234:233"), - endpoint.NewEndpoint("example.com", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default"), - endpoint.NewEndpointWithTTL("nginx.example.com", endpoint.RecordTypeA, 3600, "123.123.123.123", "234.234.234.234"), - endpoint.NewEndpointWithTTL("nginx.example.com", endpoint.RecordTypeAAAA, 3600, "2001::123:123:123:123", "2001::234:234:234:234"), - endpoint.NewEndpointWithTTL("nginx.example.com", endpoint.RecordTypeTXT, recordTTL, "heritage=external-dns,external-dns/owner=default"), - endpoint.NewEndpointWithTTL("hack.example.com", endpoint.RecordTypeCNAME, 10, "hack.azurewebsites.net"), - endpoint.NewEndpointWithTTL("mail.example.com", endpoint.RecordTypeMX, 4000, "10 example.com", "20 backup.example.com"), - } - - validateAzureEndpoints(t, actual, expected) -} - -func TestAzurePrivateDNSApplyChanges(t *testing.T) { - recordsClient := mockPrivateRecordSetsClient{} - - testAzurePrivateDNSApplyChangesInternal(t, false, &recordsClient) - - validateAzureEndpoints(t, recordsClient.deletedEndpoints, []*endpoint.Endpoint{ - endpoint.NewEndpoint("deleted.example.com", endpoint.RecordTypeA, ""), - endpoint.NewEndpoint("deletedaaaa.example.com", endpoint.RecordTypeAAAA, ""), - endpoint.NewEndpoint("deletedcname.example.com", endpoint.RecordTypeCNAME, ""), - }) - - validateAzureEndpoints(t, recordsClient.updatedEndpoints, []*endpoint.Endpoint{ - endpoint.NewEndpointWithTTL("example.com", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "1.2.3.4"), - endpoint.NewEndpointWithTTL("example.com", endpoint.RecordTypeAAAA, endpoint.TTL(recordTTL), "2001::1:2:3:4"), - endpoint.NewEndpointWithTTL("example.com", endpoint.RecordTypeTXT, endpoint.TTL(recordTTL), "tag"), - endpoint.NewEndpointWithTTL("foo.example.com", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "1.2.3.4", "1.2.3.5"), - endpoint.NewEndpointWithTTL("foo.example.com", endpoint.RecordTypeAAAA, endpoint.TTL(recordTTL), "2001::1:2:3:4", "2001::1:2:3:5"), - endpoint.NewEndpointWithTTL("foo.example.com", endpoint.RecordTypeTXT, endpoint.TTL(recordTTL), "tag"), - endpoint.NewEndpointWithTTL("bar.example.com", endpoint.RecordTypeCNAME, endpoint.TTL(recordTTL), "other.com"), - endpoint.NewEndpointWithTTL("bar.example.com", endpoint.RecordTypeTXT, endpoint.TTL(recordTTL), "tag"), - endpoint.NewEndpointWithTTL("other.com", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "5.6.7.8"), - endpoint.NewEndpointWithTTL("other.com", endpoint.RecordTypeAAAA, endpoint.TTL(recordTTL), "2001::5:6:7:8"), - endpoint.NewEndpointWithTTL("other.com", endpoint.RecordTypeTXT, endpoint.TTL(recordTTL), "tag"), - endpoint.NewEndpointWithTTL("new.example.com", endpoint.RecordTypeA, 3600, "111.222.111.222"), - endpoint.NewEndpointWithTTL("new.example.com", endpoint.RecordTypeAAAA, 3600, "2001::111:222:111:222"), - endpoint.NewEndpointWithTTL("newcname.example.com", endpoint.RecordTypeCNAME, 10, "other.com"), - endpoint.NewEndpointWithTTL("newmail.example.com", endpoint.RecordTypeMX, 7200, "40 bar.other.com"), - endpoint.NewEndpointWithTTL("mail.example.com", endpoint.RecordTypeMX, endpoint.TTL(recordTTL), "10 other.com"), - endpoint.NewEndpointWithTTL("mail.example.com", endpoint.RecordTypeTXT, endpoint.TTL(recordTTL), "tag"), - }) -} - -func TestAzurePrivateDNSApplyChangesDryRun(t *testing.T) { - recordsClient := mockRecordSetsClient{} - - testAzureApplyChangesInternal(t, true, &recordsClient) - - validateAzureEndpoints(t, recordsClient.deletedEndpoints, []*endpoint.Endpoint{}) - - validateAzureEndpoints(t, recordsClient.updatedEndpoints, []*endpoint.Endpoint{}) -} - -func testAzurePrivateDNSApplyChangesInternal(t *testing.T, dryRun bool, client PrivateRecordSetsClient) { - zones := []*privatedns.PrivateZone{ - createMockPrivateZone("example.com", "/privateDnsZones/example.com"), - createMockPrivateZone("other.com", "/privateDnsZones/other.com"), - } - zonesClient := newMockPrivateZonesClient(zones) - - provider := newAzurePrivateDNSProvider( - endpoint.NewDomainFilter([]string{""}), - provider.NewZoneIDFilter([]string{""}), - dryRun, - "group", - &zonesClient, - client, - ) - - createRecords := []*endpoint.Endpoint{ - endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "1.2.3.4"), - endpoint.NewEndpoint("example.com", endpoint.RecordTypeAAAA, "2001::1:2:3:4"), - endpoint.NewEndpoint("example.com", endpoint.RecordTypeTXT, "tag"), - endpoint.NewEndpoint("foo.example.com", endpoint.RecordTypeA, "1.2.3.5", "1.2.3.4"), - endpoint.NewEndpoint("foo.example.com", endpoint.RecordTypeAAAA, "2001::1:2:3:5", "2001::1:2:3:4"), - endpoint.NewEndpoint("foo.example.com", endpoint.RecordTypeTXT, "tag"), - endpoint.NewEndpoint("bar.example.com", endpoint.RecordTypeCNAME, "other.com"), - endpoint.NewEndpoint("bar.example.com", endpoint.RecordTypeTXT, "tag"), - endpoint.NewEndpoint("other.com", endpoint.RecordTypeA, "5.6.7.8"), - endpoint.NewEndpoint("other.com", endpoint.RecordTypeAAAA, "2001::5:6:7:8"), - endpoint.NewEndpoint("other.com", endpoint.RecordTypeTXT, "tag"), - endpoint.NewEndpoint("nope.com", endpoint.RecordTypeA, "4.4.4.4"), - endpoint.NewEndpoint("nope.com", endpoint.RecordTypeAAAA, "2001::4:4:4:4"), - endpoint.NewEndpoint("nope.com", endpoint.RecordTypeTXT, "tag"), - endpoint.NewEndpoint("mail.example.com", endpoint.RecordTypeMX, "10 other.com"), - endpoint.NewEndpoint("mail.example.com", endpoint.RecordTypeTXT, "tag"), - } - - currentRecords := []*endpoint.Endpoint{ - endpoint.NewEndpoint("old.example.com", endpoint.RecordTypeA, "121.212.121.212"), - endpoint.NewEndpoint("oldcname.example.com", endpoint.RecordTypeCNAME, "other.com"), - endpoint.NewEndpoint("old.nope.com", endpoint.RecordTypeA, "121.212.121.212"), - endpoint.NewEndpoint("oldmail.example.com", endpoint.RecordTypeMX, "20 foo.other.com"), - } - updatedRecords := []*endpoint.Endpoint{ - endpoint.NewEndpointWithTTL("new.example.com", endpoint.RecordTypeA, 3600, "111.222.111.222"), - endpoint.NewEndpointWithTTL("new.example.com", endpoint.RecordTypeAAAA, 3600, "2001::111:222:111:222"), - endpoint.NewEndpointWithTTL("newcname.example.com", endpoint.RecordTypeCNAME, 10, "other.com"), - endpoint.NewEndpoint("new.nope.com", endpoint.RecordTypeA, "222.111.222.111"), - endpoint.NewEndpoint("new.nope.com", endpoint.RecordTypeAAAA, "2001::222:111:222:111"), - endpoint.NewEndpointWithTTL("newmail.example.com", endpoint.RecordTypeMX, 7200, "40 bar.other.com"), - } - - deleteRecords := []*endpoint.Endpoint{ - endpoint.NewEndpoint("deleted.example.com", endpoint.RecordTypeA, "111.222.111.222"), - endpoint.NewEndpoint("deletedaaaa.example.com", endpoint.RecordTypeAAAA, "2001::111:222:111:222"), - endpoint.NewEndpoint("deletedcname.example.com", endpoint.RecordTypeCNAME, "other.com"), - endpoint.NewEndpoint("deleted.nope.com", endpoint.RecordTypeA, "222.111.222.111"), - endpoint.NewEndpoint("deleted.nope.com", endpoint.RecordTypeAAAA, "2001::222:111:222:111"), - } - - changes := &plan.Changes{ - Create: createRecords, - UpdateNew: updatedRecords, - UpdateOld: currentRecords, - Delete: deleteRecords, - } - - if err := provider.ApplyChanges(context.Background(), changes); err != nil { - t.Fatal(err) - } -} diff --git a/internal/external-dns/azure/azure.go b/internal/external-dns/provider/azure/azure.go similarity index 78% rename from internal/external-dns/azure/azure.go rename to internal/external-dns/provider/azure/azure.go index dc47d8c0..0a1433bb 100644 --- a/internal/external-dns/azure/azure.go +++ b/internal/external-dns/provider/azure/azure.go @@ -19,14 +19,14 @@ package azure import ( "context" + "errors" "fmt" "strings" - log "github.com/sirupsen/logrus" - azcoreruntime "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" dns "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns" + "github.com/go-logr/logr" "sigs.k8s.io/external-dns/endpoint" "sigs.k8s.io/external-dns/plan" @@ -60,46 +60,61 @@ type AzureProvider struct { userAssignedIdentityClientID string zonesClient ZonesClient recordSetsClient RecordSetsClient + logger logr.Logger } -// NewAzureProvider creates a new Azure provider. -// -// Returns the provider or an error if a provider could not be created. -func NewAzureProvider(configFile string, domainFilter endpoint.DomainFilter, zoneNameFilter endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, resourceGroup string, userAssignedIdentityClientID string, dryRun bool) (*AzureProvider, error) { - cfg, err := getConfig(configFile, resourceGroup, userAssignedIdentityClientID) +func NewAzureProviderFromConfig(ctx context.Context, azureConfig Config) (*AzureProvider, error) { + cred, clientOpts, err := getCredentials(ctx, azureConfig) if err != nil { - return nil, fmt.Errorf("failed to read Azure config file '%s': %v", configFile, err) + return nil, err } - cred, clientOpts, err := getCredentials(*cfg) + + zonesClient, err := dns.NewZonesClient(azureConfig.SubscriptionID, cred, clientOpts) if err != nil { - return nil, fmt.Errorf("failed to get credentials: %w", err) + return nil, err } - zonesClient, err := dns.NewZonesClient(cfg.SubscriptionID, cred, clientOpts) + recordSetsClient, err := dns.NewRecordSetsClient(azureConfig.SubscriptionID, cred, clientOpts) if err != nil { return nil, err } - recordSetsClient, err := dns.NewRecordSetsClient(cfg.SubscriptionID, cred, clientOpts) + + p := &AzureProvider{ + domainFilter: azureConfig.DomainFilter, + zoneNameFilter: azureConfig.ZoneNameFilter, + zoneIDFilter: azureConfig.IDFilter, + dryRun: azureConfig.DryRun, + zonesClient: zonesClient, + recordSetsClient: recordSetsClient, + resourceGroup: azureConfig.ResourceGroup, + logger: logr.FromContextOrDiscard(ctx), + } + + return p, nil +} + +// NewAzureProvider creates a new Azure provider. +// +// Returns the provider or an error if a provider could not be created. +func NewAzureProvider(ctx context.Context, configFile string, domainFilter endpoint.DomainFilter, zoneNameFilter endpoint.DomainFilter, zoneIDFilter provider.ZoneIDFilter, resourceGroup string, userAssignedIdentityClientID string, dryRun bool) (*AzureProvider, error) { + cfg, err := getConfig(configFile, resourceGroup, userAssignedIdentityClientID) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to read Azure config file '%s': %v", configFile, err) } - return &AzureProvider{ - domainFilter: domainFilter, - zoneNameFilter: zoneNameFilter, - zoneIDFilter: zoneIDFilter, - dryRun: dryRun, - resourceGroup: cfg.ResourceGroup, - userAssignedIdentityClientID: cfg.UserAssignedIdentityID, - zonesClient: zonesClient, - recordSetsClient: recordSetsClient, - }, nil + + cfg.DomainFilter = domainFilter + cfg.ZoneNameFilter = zoneNameFilter + cfg.IDFilter = zoneIDFilter + cfg.DryRun = dryRun + + return NewAzureProviderFromConfig(ctx, *cfg) } // Records gets the current records. // // Returns the current records or an error if the operation failed. func (p *AzureProvider) Records(ctx context.Context) (endpoints []*endpoint.Endpoint, _ error) { - zones, err := p.zones(ctx) + zones, err := p.Zones(ctx) if err != nil { return nil, err } @@ -113,7 +128,7 @@ func (p *AzureProvider) Records(ctx context.Context) (endpoints []*endpoint.Endp } for _, recordSet := range nextResult.Value { if recordSet.Name == nil || recordSet.Type == nil { - log.Error("Skipping invalid record set with nil name or type.") + p.logger.Error(errors.New("record set has nil name or type"), "Skipping invalid record set with nil name or type") continue } recordType := strings.TrimPrefix(*recordSet.Type, "Microsoft.Network/dnszones/") @@ -122,12 +137,12 @@ func (p *AzureProvider) Records(ctx context.Context) (endpoints []*endpoint.Endp } name := formatAzureDNSName(*recordSet.Name, *zone.Name) if len(p.zoneNameFilter.Filters) > 0 && !p.domainFilter.Match(name) { - log.Debugf("Skipping return of record %s because it was filtered out by the specified --domain-filter", name) + p.logger.V(1).Info("skipping return of record because it was filtered out by the specified --domain-filter", "record name", name) continue } targets := extractAzureTargets(recordSet) if len(targets) == 0 { - log.Debugf("Failed to extract targets for '%s' with type '%s'.", name, recordType) + p.logger.V(1).Info("failed to extract targets from record set", "record name", name, "record type", recordType) continue } var ttl endpoint.TTL @@ -135,12 +150,7 @@ func (p *AzureProvider) Records(ctx context.Context) (endpoints []*endpoint.Endp ttl = endpoint.TTL(*recordSet.Properties.TTL) } ep := endpoint.NewEndpointWithTTL(name, recordType, ttl, targets...) - log.Debugf( - "Found %s record for '%s' with target '%s'.", - ep.RecordType, - ep.DNSName, - ep.Targets, - ) + p.logger.V(1).Info("found record set", "record type", ep.RecordType, "DNS Name", ep.DNSName, "targets", ep.Targets) endpoints = append(endpoints, ep) } } @@ -152,7 +162,7 @@ func (p *AzureProvider) Records(ctx context.Context) (endpoints []*endpoint.Endp // // Returns nil if the operation was successful or an error if the operation failed. func (p *AzureProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) error { - zones, err := p.zones(ctx) + zones, err := p.Zones(ctx) if err != nil { return err } @@ -163,8 +173,8 @@ func (p *AzureProvider) ApplyChanges(ctx context.Context, changes *plan.Changes) return nil } -func (p *AzureProvider) zones(ctx context.Context) ([]dns.Zone, error) { - log.Debugf("Retrieving Azure DNS zones for resource group: %s.", p.resourceGroup) +func (p *AzureProvider) Zones(ctx context.Context) ([]dns.Zone, error) { + p.logger.V(1).Info("retrieving azure DNS Zones for resource group", "resource group", p.resourceGroup) var zones []dns.Zone pager := p.zonesClient.NewListByResourceGroupPager(p.resourceGroup, &dns.ZonesClientListByResourceGroupOptions{Top: nil}) for pager.More() { @@ -181,7 +191,7 @@ func (p *AzureProvider) zones(ctx context.Context) ([]dns.Zone, error) { } } } - log.Debugf("Found %d Azure DNS zone(s).", len(zones)) + p.logger.V(1).Info("found azure DNS Zones", "zones count", len(zones)) return zones, nil } @@ -211,7 +221,7 @@ func (p *AzureProvider) mapChanges(zones []dns.Zone, changes *plan.Changes) (azu if zone == "" { if _, ok := ignored[change.DNSName]; !ok { ignored[change.DNSName] = true - log.Infof("Ignoring changes to '%s' because a suitable Azure DNS zone was not found.", change.DNSName) + p.logger.Info("ignoring changes because a suitable Azure DNS zone was not found", "change DNS Name", change.DNSName) } return } @@ -239,21 +249,15 @@ func (p *AzureProvider) deleteRecords(ctx context.Context, deleted azureChangeMa for _, ep := range endpoints { name := p.recordSetNameForZone(zone, ep) if !p.domainFilter.Match(ep.DNSName) { - log.Debugf("Skipping deletion of record %s because it was filtered out by the specified --domain-filter", ep.DNSName) + p.logger.V(1).Info("skipping deletion of record as it was filtered out by the specified --domain-filter", "record name", ep.DNSName) continue } if p.dryRun { - log.Infof("Would delete %s record named '%s' for Azure DNS zone '%s'.", ep.RecordType, name, zone) + p.logger.Info("would delete record", "record type", ep.RecordType, "record name", name, "zone", zone) } else { - log.Infof("Deleting %s record named '%s' for Azure DNS zone '%s'.", ep.RecordType, name, zone) + p.logger.Info("deleting record", "record type", ep.RecordType, "record name", name, "zone", zone) if _, err := p.recordSetsClient.Delete(ctx, p.resourceGroup, zone, name, dns.RecordType(ep.RecordType), nil); err != nil { - log.Errorf( - "Failed to delete %s record named '%s' for Azure DNS zone '%s': %v", - ep.RecordType, - name, - zone, - err, - ) + p.logger.Error(err, "failed to delete record", "record type", ep.RecordType, "record name", name, "zone", zone) } } } @@ -265,27 +269,14 @@ func (p *AzureProvider) updateRecords(ctx context.Context, updated azureChangeMa for _, ep := range endpoints { name := p.recordSetNameForZone(zone, ep) if !p.domainFilter.Match(ep.DNSName) { - log.Debugf("Skipping update of record %s because it was filtered out by the specified --domain-filter", ep.DNSName) + p.logger.V(1).Info("skipping update of record because it was filtered by the specified --domain-filter", "record name", ep.DNSName) continue } if p.dryRun { - log.Infof( - "Would update %s record named '%s' to '%s' for Azure DNS zone '%s'.", - ep.RecordType, - name, - ep.Targets, - zone, - ) + p.logger.Info("would update record", "record type", ep.RecordType, "record name", name, "targets", ep.Targets, "zone", zone) continue } - - log.Infof( - "Updating %s record named '%s' to '%s' for Azure DNS zone '%s'.", - ep.RecordType, - name, - ep.Targets, - zone, - ) + p.logger.Info("updating record", "record type", ep.RecordType, "record name", name, "targets", ep.Targets, "zone", zone) recordSet, err := p.newRecordSet(ep) if err == nil { @@ -300,14 +291,7 @@ func (p *AzureProvider) updateRecords(ctx context.Context, updated azureChangeMa ) } if err != nil { - log.Errorf( - "Failed to update %s record named '%s' to '%s' for DNS zone '%s': %v", - ep.RecordType, - name, - ep.Targets, - zone, - err, - ) + p.logger.Error(err, "failed to update record", "record type", ep.RecordType, "record name", name, "targets", ep.Targets, "zone", zone) } } } diff --git a/internal/external-dns/azure/azure_test.go b/internal/external-dns/provider/azure/azure_test.go similarity index 94% rename from internal/external-dns/azure/azure_test.go rename to internal/external-dns/provider/azure/azure_test.go index 4fd9fa8f..6265c053 100644 --- a/internal/external-dns/azure/azure_test.go +++ b/internal/external-dns/provider/azure/azure_test.go @@ -26,9 +26,10 @@ import ( "github.com/stretchr/testify/assert" "sigs.k8s.io/external-dns/endpoint" - "sigs.k8s.io/external-dns/internal/testutils" "sigs.k8s.io/external-dns/plan" "sigs.k8s.io/external-dns/provider" + + "github.com/kuadrant/dns-operator/internal/external-dns/testutils" ) // mockZonesClient implements the methods of the Azure DNS Zones Client which are used in the Azure Provider @@ -258,7 +259,7 @@ func TestAzureRecord(t *testing.T) { createMockRecordSet("@", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default"), createMockRecordSetWithTTL("nginx", endpoint.RecordTypeA, "123.123.123.123", 3600), createMockRecordSetWithTTL("nginx", endpoint.RecordTypeAAAA, "2001::123:123:123:123", 3600), - createMockRecordSetWithTTL("nginx", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default", recordTTL), + createMockRecordSetWithTTL("nginx", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default", azureRecordTTL), createMockRecordSetWithTTL("hack", endpoint.RecordTypeCNAME, "hack.azurewebsites.net", 10), createMockRecordSetMultiWithTTL("mail", endpoint.RecordTypeMX, 4000, "10 example.com"), }) @@ -277,7 +278,7 @@ func TestAzureRecord(t *testing.T) { endpoint.NewEndpoint("example.com", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default"), endpoint.NewEndpointWithTTL("nginx.example.com", endpoint.RecordTypeA, 3600, "123.123.123.123"), endpoint.NewEndpointWithTTL("nginx.example.com", endpoint.RecordTypeAAAA, 3600, "2001::123:123:123:123"), - endpoint.NewEndpointWithTTL("nginx.example.com", endpoint.RecordTypeTXT, recordTTL, "heritage=external-dns,external-dns/owner=default"), + endpoint.NewEndpointWithTTL("nginx.example.com", endpoint.RecordTypeTXT, azureRecordTTL, "heritage=external-dns,external-dns/owner=default"), endpoint.NewEndpointWithTTL("hack.example.com", endpoint.RecordTypeCNAME, 10, "hack.azurewebsites.net"), endpoint.NewEndpointWithTTL("mail.example.com", endpoint.RecordTypeMX, 4000, "10 example.com"), } @@ -298,7 +299,7 @@ func TestAzureMultiRecord(t *testing.T) { createMockRecordSet("@", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default"), createMockRecordSetMultiWithTTL("nginx", endpoint.RecordTypeA, 3600, "123.123.123.123", "234.234.234.234"), createMockRecordSetMultiWithTTL("nginx", endpoint.RecordTypeAAAA, 3600, "2001::123:123:123:123", "2001::234:234:234:234"), - createMockRecordSetWithTTL("nginx", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default", recordTTL), + createMockRecordSetWithTTL("nginx", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default", azureRecordTTL), createMockRecordSetWithTTL("hack", endpoint.RecordTypeCNAME, "hack.azurewebsites.net", 10), createMockRecordSetMultiWithTTL("mail", endpoint.RecordTypeMX, 4000, "10 example.com", "20 backup.example.com"), }) @@ -317,7 +318,7 @@ func TestAzureMultiRecord(t *testing.T) { endpoint.NewEndpoint("example.com", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default"), endpoint.NewEndpointWithTTL("nginx.example.com", endpoint.RecordTypeA, 3600, "123.123.123.123", "234.234.234.234"), endpoint.NewEndpointWithTTL("nginx.example.com", endpoint.RecordTypeAAAA, 3600, "2001::123:123:123:123", "2001::234:234:234:234"), - endpoint.NewEndpointWithTTL("nginx.example.com", endpoint.RecordTypeTXT, recordTTL, "heritage=external-dns,external-dns/owner=default"), + endpoint.NewEndpointWithTTL("nginx.example.com", endpoint.RecordTypeTXT, azureRecordTTL, "heritage=external-dns,external-dns/owner=default"), endpoint.NewEndpointWithTTL("hack.example.com", endpoint.RecordTypeCNAME, 10, "hack.azurewebsites.net"), endpoint.NewEndpointWithTTL("mail.example.com", endpoint.RecordTypeMX, 4000, "10 example.com", "20 backup.example.com"), } @@ -337,23 +338,23 @@ func TestAzureApplyChanges(t *testing.T) { }) validateAzureEndpoints(t, recordsClient.updatedEndpoints, []*endpoint.Endpoint{ - endpoint.NewEndpointWithTTL("example.com", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "1.2.3.4"), - endpoint.NewEndpointWithTTL("example.com", endpoint.RecordTypeAAAA, endpoint.TTL(recordTTL), "2001::1:2:3:4"), - endpoint.NewEndpointWithTTL("example.com", endpoint.RecordTypeTXT, endpoint.TTL(recordTTL), "tag"), - endpoint.NewEndpointWithTTL("foo.example.com", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "1.2.3.4", "1.2.3.5"), - endpoint.NewEndpointWithTTL("foo.example.com", endpoint.RecordTypeAAAA, endpoint.TTL(recordTTL), "2001::1:2:3:4", "2001::1:2:3:5"), - endpoint.NewEndpointWithTTL("foo.example.com", endpoint.RecordTypeTXT, endpoint.TTL(recordTTL), "tag"), - endpoint.NewEndpointWithTTL("bar.example.com", endpoint.RecordTypeCNAME, endpoint.TTL(recordTTL), "other.com"), - endpoint.NewEndpointWithTTL("bar.example.com", endpoint.RecordTypeTXT, endpoint.TTL(recordTTL), "tag"), - endpoint.NewEndpointWithTTL("other.com", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "5.6.7.8"), - endpoint.NewEndpointWithTTL("other.com", endpoint.RecordTypeAAAA, endpoint.TTL(recordTTL), "2001::5:6:7:8"), - endpoint.NewEndpointWithTTL("other.com", endpoint.RecordTypeTXT, endpoint.TTL(recordTTL), "tag"), + endpoint.NewEndpointWithTTL("example.com", endpoint.RecordTypeA, endpoint.TTL(azureRecordTTL), "1.2.3.4"), + endpoint.NewEndpointWithTTL("example.com", endpoint.RecordTypeAAAA, endpoint.TTL(azureRecordTTL), "2001::1:2:3:4"), + endpoint.NewEndpointWithTTL("example.com", endpoint.RecordTypeTXT, endpoint.TTL(azureRecordTTL), "tag"), + endpoint.NewEndpointWithTTL("foo.example.com", endpoint.RecordTypeA, endpoint.TTL(azureRecordTTL), "1.2.3.4", "1.2.3.5"), + endpoint.NewEndpointWithTTL("foo.example.com", endpoint.RecordTypeAAAA, endpoint.TTL(azureRecordTTL), "2001::1:2:3:4", "2001::1:2:3:5"), + endpoint.NewEndpointWithTTL("foo.example.com", endpoint.RecordTypeTXT, endpoint.TTL(azureRecordTTL), "tag"), + endpoint.NewEndpointWithTTL("bar.example.com", endpoint.RecordTypeCNAME, endpoint.TTL(azureRecordTTL), "other.com"), + endpoint.NewEndpointWithTTL("bar.example.com", endpoint.RecordTypeTXT, endpoint.TTL(azureRecordTTL), "tag"), + endpoint.NewEndpointWithTTL("other.com", endpoint.RecordTypeA, endpoint.TTL(azureRecordTTL), "5.6.7.8"), + endpoint.NewEndpointWithTTL("other.com", endpoint.RecordTypeAAAA, endpoint.TTL(azureRecordTTL), "2001::5:6:7:8"), + endpoint.NewEndpointWithTTL("other.com", endpoint.RecordTypeTXT, endpoint.TTL(azureRecordTTL), "tag"), endpoint.NewEndpointWithTTL("new.example.com", endpoint.RecordTypeA, 3600, "111.222.111.222"), endpoint.NewEndpointWithTTL("new.example.com", endpoint.RecordTypeAAAA, 3600, "2001::111:222:111:222"), endpoint.NewEndpointWithTTL("newcname.example.com", endpoint.RecordTypeCNAME, 10, "other.com"), endpoint.NewEndpointWithTTL("newmail.example.com", endpoint.RecordTypeMX, 7200, "40 bar.other.com"), - endpoint.NewEndpointWithTTL("mail.example.com", endpoint.RecordTypeMX, endpoint.TTL(recordTTL), "10 other.com"), - endpoint.NewEndpointWithTTL("mail.example.com", endpoint.RecordTypeTXT, endpoint.TTL(recordTTL), "tag"), + endpoint.NewEndpointWithTTL("mail.example.com", endpoint.RecordTypeMX, endpoint.TTL(azureRecordTTL), "10 other.com"), + endpoint.NewEndpointWithTTL("mail.example.com", endpoint.RecordTypeTXT, endpoint.TTL(azureRecordTTL), "tag"), }) } @@ -452,8 +453,8 @@ func TestAzureNameFilter(t *testing.T) { createMockRecordSet("@", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default"), createMockRecordSetWithTTL("test.nginx", endpoint.RecordTypeA, "123.123.123.123", 3600), createMockRecordSetWithTTL("nginx", endpoint.RecordTypeA, "123.123.123.123", 3600), - createMockRecordSetWithTTL("nginx", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default", recordTTL), - createMockRecordSetWithTTL("mail.nginx", endpoint.RecordTypeMX, "20 example.com", recordTTL), + createMockRecordSetWithTTL("nginx", endpoint.RecordTypeTXT, "heritage=external-dns,external-dns/owner=default", azureRecordTTL), + createMockRecordSetWithTTL("mail.nginx", endpoint.RecordTypeMX, "20 example.com", azureRecordTTL), createMockRecordSetWithTTL("hack", endpoint.RecordTypeCNAME, "hack.azurewebsites.net", 10), }) if err != nil { @@ -468,8 +469,8 @@ func TestAzureNameFilter(t *testing.T) { expected := []*endpoint.Endpoint{ endpoint.NewEndpointWithTTL("test.nginx.example.com", endpoint.RecordTypeA, 3600, "123.123.123.123"), endpoint.NewEndpointWithTTL("nginx.example.com", endpoint.RecordTypeA, 3600, "123.123.123.123"), - endpoint.NewEndpointWithTTL("nginx.example.com", endpoint.RecordTypeTXT, recordTTL, "heritage=external-dns,external-dns/owner=default"), - endpoint.NewEndpointWithTTL("mail.nginx.example.com", endpoint.RecordTypeMX, recordTTL, "20 example.com"), + endpoint.NewEndpointWithTTL("nginx.example.com", endpoint.RecordTypeTXT, azureRecordTTL, "heritage=external-dns,external-dns/owner=default"), + endpoint.NewEndpointWithTTL("mail.nginx.example.com", endpoint.RecordTypeMX, azureRecordTTL, "20 example.com"), } validateAzureEndpoints(t, actual, expected) @@ -487,9 +488,9 @@ func TestAzureApplyChangesZoneName(t *testing.T) { }) validateAzureEndpoints(t, recordsClient.updatedEndpoints, []*endpoint.Endpoint{ - endpoint.NewEndpointWithTTL("foo.example.com", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "1.2.3.4", "1.2.3.5"), - endpoint.NewEndpointWithTTL("foo.example.com", endpoint.RecordTypeAAAA, endpoint.TTL(recordTTL), "2001::1:2:3:4", "2001::1:2:3:5"), - endpoint.NewEndpointWithTTL("foo.example.com", endpoint.RecordTypeTXT, endpoint.TTL(recordTTL), "tag"), + endpoint.NewEndpointWithTTL("foo.example.com", endpoint.RecordTypeA, endpoint.TTL(azureRecordTTL), "1.2.3.4", "1.2.3.5"), + endpoint.NewEndpointWithTTL("foo.example.com", endpoint.RecordTypeAAAA, endpoint.TTL(azureRecordTTL), "2001::1:2:3:4", "2001::1:2:3:5"), + endpoint.NewEndpointWithTTL("foo.example.com", endpoint.RecordTypeTXT, endpoint.TTL(azureRecordTTL), "tag"), endpoint.NewEndpointWithTTL("new.foo.example.com", endpoint.RecordTypeA, 3600, "111.222.111.222"), endpoint.NewEndpointWithTTL("new.foo.example.com", endpoint.RecordTypeAAAA, 3600, "2001::111:222:111:222"), endpoint.NewEndpointWithTTL("newcname.foo.example.com", endpoint.RecordTypeCNAME, 10, "other.com"), diff --git a/internal/external-dns/azure/common.go b/internal/external-dns/provider/azure/common.go similarity index 100% rename from internal/external-dns/azure/common.go rename to internal/external-dns/provider/azure/common.go diff --git a/internal/external-dns/azure/common_test.go b/internal/external-dns/provider/azure/common_test.go similarity index 99% rename from internal/external-dns/azure/common_test.go rename to internal/external-dns/provider/azure/common_test.go index b85fb5f4..9e09ce41 100644 --- a/internal/external-dns/azure/common_test.go +++ b/internal/external-dns/provider/azure/common_test.go @@ -23,7 +23,6 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" dns "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns" privatedns "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns" - "github.com/stretchr/testify/assert" ) diff --git a/internal/external-dns/azure/config.go b/internal/external-dns/provider/azure/config.go similarity index 86% rename from internal/external-dns/azure/config.go rename to internal/external-dns/provider/azure/config.go index c148baf8..52c97b66 100644 --- a/internal/external-dns/azure/config.go +++ b/internal/external-dns/provider/azure/config.go @@ -17,6 +17,7 @@ limitations under the License. package azure import ( + "context" "fmt" "os" "strings" @@ -25,12 +26,15 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" - log "github.com/sirupsen/logrus" + "github.com/go-logr/logr" "gopkg.in/yaml.v2" + + "sigs.k8s.io/external-dns/endpoint" + "sigs.k8s.io/external-dns/provider" ) -// config represents common config items for Azure DNS and Azure Private DNS -type config struct { +// Config represents common config items for Azure DNS and Azure Private DNS +type Config struct { Cloud string `json:"cloud" yaml:"cloud"` TenantID string `json:"tenantId" yaml:"tenantId"` SubscriptionID string `json:"subscriptionId" yaml:"subscriptionId"` @@ -41,14 +45,18 @@ type config struct { UseManagedIdentityExtension bool `json:"useManagedIdentityExtension" yaml:"useManagedIdentityExtension"` UseWorkloadIdentityExtension bool `json:"useWorkloadIdentityExtension" yaml:"useWorkloadIdentityExtension"` UserAssignedIdentityID string `json:"userAssignedIdentityID" yaml:"userAssignedIdentityID"` + DomainFilter endpoint.DomainFilter + ZoneNameFilter endpoint.DomainFilter + IDFilter provider.ZoneIDFilter + DryRun bool } -func getConfig(configFile, resourceGroup, userAssignedIdentityClientID string) (*config, error) { +func getConfig(configFile, resourceGroup, userAssignedIdentityClientID string) (*Config, error) { contents, err := os.ReadFile(configFile) if err != nil { return nil, fmt.Errorf("failed to read Azure config file '%s': %v", configFile, err) } - cfg := &config{} + cfg := &Config{} err = yaml.Unmarshal(contents, &cfg) if err != nil { return nil, fmt.Errorf("failed to read Azure config file '%s': %v", configFile, err) @@ -66,7 +74,8 @@ func getConfig(configFile, resourceGroup, userAssignedIdentityClientID string) ( } // getAccessToken retrieves Azure API access token. -func getCredentials(cfg config) (azcore.TokenCredential, *arm.ClientOptions, error) { +func getCredentials(ctx context.Context, cfg Config) (azcore.TokenCredential, *arm.ClientOptions, error) { + logger := logr.FromContextOrDiscard(ctx).WithName("getCredentials") cloudCfg, err := getCloudConfiguration(cfg.Cloud) if err != nil { return nil, nil, fmt.Errorf("failed to get cloud configuration: %w", err) @@ -88,7 +97,7 @@ func getCredentials(cfg config) (azcore.TokenCredential, *arm.ClientOptions, err // In this case, we shouldn't try to use SPN to authenticate. !strings.EqualFold(cfg.ClientID, "msi") && !strings.EqualFold(cfg.ClientSecret, "msi") { - log.Info("Using client_id+client_secret to retrieve access token for Azure API.") + logger.Info("using client_id and client_secret to retrieve access token for Azure API") opts := &azidentity.ClientSecretCredentialOptions{ ClientOptions: clientOpts, } @@ -101,7 +110,7 @@ func getCredentials(cfg config) (azcore.TokenCredential, *arm.ClientOptions, err // Try to retrieve token with Workload Identity. if cfg.UseWorkloadIdentityExtension { - log.Info("Using workload identity extension to retrieve access token for Azure API.") + logger.Info("using workload identity extension to retrieve access token for Azure API") wiOpt := azidentity.WorkloadIdentityCredentialOptions{ ClientOptions: clientOpts, @@ -123,7 +132,7 @@ func getCredentials(cfg config) (azcore.TokenCredential, *arm.ClientOptions, err // Try to retrieve token with MSI. if cfg.UseManagedIdentityExtension { - log.Info("Using managed identity extension to retrieve access token for Azure API.") + logger.Info("using managed identity extension to retrieve access token for Azure API") msiOpt := azidentity.ManagedIdentityCredentialOptions{ ClientOptions: clientOpts, } diff --git a/internal/external-dns/azure/config_test.go b/internal/external-dns/provider/azure/config_test.go similarity index 100% rename from internal/external-dns/azure/config_test.go rename to internal/external-dns/provider/azure/config_test.go diff --git a/internal/provider/azure/azure.go b/internal/provider/azure/azure.go new file mode 100644 index 00000000..052e638b --- /dev/null +++ b/internal/provider/azure/azure.go @@ -0,0 +1,125 @@ +package azure + +import ( + "context" + "fmt" + + "github.com/go-logr/logr" + "gopkg.in/yaml.v2" + + v1 "k8s.io/api/core/v1" + crlog "sigs.k8s.io/controller-runtime/pkg/log" + + "github.com/kuadrant/dns-operator/api/v1alpha1" + externaldnsproviderazure "github.com/kuadrant/dns-operator/internal/external-dns/provider/azure" + "github.com/kuadrant/dns-operator/internal/provider" +) + +type AzureProvider struct { + *externaldnsproviderazure.AzureProvider + azureConfig externaldnsproviderazure.Config + logger logr.Logger +} + +var _ provider.Provider = &AzureProvider{} + +func NewAzureProviderFromSecret(ctx context.Context, s *v1.Secret, c provider.Config) (provider.Provider, error) { + if string(s.Data["azure.json"]) == "" { + return nil, fmt.Errorf("the Azure provider credentials is empty") + } + + configString := string(s.Data["azure.json"]) + var azureConfig externaldnsproviderazure.Config + err := yaml.Unmarshal([]byte(configString), &azureConfig) + if err != nil { + return nil, err + } + + logger := crlog.FromContext(ctx). + WithName("azure-dns"). + WithValues("tenantId", azureConfig.TenantID, "resourceGroup", azureConfig.ResourceGroup) + ctx = crlog.IntoContext(ctx, logger) + + azureConfig.DomainFilter = c.DomainFilter + azureConfig.ZoneNameFilter = c.DomainFilter + azureConfig.IDFilter = c.ZoneIDFilter + azureConfig.DryRun = false + + azureProvider, err := externaldnsproviderazure.NewAzureProviderFromConfig(ctx, azureConfig) + + if err != nil { + return nil, fmt.Errorf("unable to create azure provider: %s", err) + } + + p := &AzureProvider{ + AzureProvider: azureProvider, + azureConfig: azureConfig, + logger: logger, + } + + return p, nil + +} + +// Register this Provider with the provider factory +func init() { + provider.RegisterProvider("azure", NewAzureProviderFromSecret, false) +} + +func (p *AzureProvider) HealthCheckReconciler() provider.HealthCheckReconciler { + return NewAzureHealthCheckReconciler() +} + +func (p *AzureProvider) ProviderSpecific() provider.ProviderSpecificLabels { + return provider.ProviderSpecificLabels{} +} + +func (p *AzureProvider) EnsureManagedZone(ctx context.Context, managedZone *v1alpha1.ManagedZone) (provider.ManagedZoneOutput, error) { + var zoneID string + + if managedZone.Spec.ID != "" { + zoneID = managedZone.Spec.ID + } else { + zoneID = managedZone.Status.ID + } + + if zoneID != "" { + //Get existing managed zone + return p.getManagedZone(ctx, zoneID) + } + //Create new managed zone + return p.createManagedZone(ctx, managedZone) +} + +// DeleteManagedZone not implemented as managed zones are going away +func (p *AzureProvider) DeleteManagedZone(_ *v1alpha1.ManagedZone) error { + return nil // p.zonesClient.Delete(p.project, managedZone.Status.ID).Do() +} + +func (p *AzureProvider) getManagedZone(ctx context.Context, zoneID string) (provider.ManagedZoneOutput, error) { + logger := crlog.FromContext(ctx).WithName("getManagedZone") + zones, err := p.Zones(ctx) + if err != nil { + return provider.ManagedZoneOutput{}, err + } + + for _, zone := range zones { + logger.Info("comparing zone IDs", "found zone ID", zone.ID, "wanted zone ID", zoneID) + if *zone.ID == zoneID { + logger.Info("found zone ID", "found zone ID", zoneID, "wanted zone ID", zoneID) + return provider.ManagedZoneOutput{ + ID: *zone.ID, + DNSName: *zone.Name, + NameServers: zone.Properties.NameServers, + RecordCount: *zone.Properties.NumberOfRecordSets, + }, nil + } + } + + return provider.ManagedZoneOutput{}, fmt.Errorf("zone %s not found", zoneID) +} + +// createManagedZone not implemented as managed zones are going away +func (p *AzureProvider) createManagedZone(_ context.Context, _ *v1alpha1.ManagedZone) (provider.ManagedZoneOutput, error) { + return provider.ManagedZoneOutput{}, nil +} diff --git a/internal/provider/azure/health.go b/internal/provider/azure/health.go new file mode 100644 index 00000000..4b418656 --- /dev/null +++ b/internal/provider/azure/health.go @@ -0,0 +1,31 @@ +package azure + +import ( + "context" + + externaldns "sigs.k8s.io/external-dns/endpoint" + + "github.com/kuadrant/dns-operator/api/v1alpha1" + "github.com/kuadrant/dns-operator/internal/provider" +) + +type AzureHealthCheckReconciler struct { +} + +var _ provider.HealthCheckReconciler = &AzureHealthCheckReconciler{} + +func NewAzureHealthCheckReconciler() *AzureHealthCheckReconciler { + return &AzureHealthCheckReconciler{} +} + +func (r *AzureHealthCheckReconciler) HealthCheckExists(_ context.Context, _ *v1alpha1.HealthCheckStatusProbe) (bool, error) { + return true, nil +} + +func (r *AzureHealthCheckReconciler) Reconcile(_ context.Context, _ provider.HealthCheckSpec, _ *externaldns.Endpoint, _ *v1alpha1.HealthCheckStatusProbe, _ string) provider.HealthCheckResult { + return provider.HealthCheckResult{} +} + +func (r *AzureHealthCheckReconciler) Delete(_ context.Context, _ *externaldns.Endpoint, _ *v1alpha1.HealthCheckStatusProbe) (provider.HealthCheckResult, error) { + return provider.HealthCheckResult{}, nil +} diff --git a/internal/provider/factory.go b/internal/provider/factory.go index 19ec81f0..c0270fcc 100644 --- a/internal/provider/factory.go +++ b/internal/provider/factory.go @@ -105,6 +105,8 @@ func NameForProviderSecret(secret *v1.Secret) (string, error) { switch secret.Type { case "kuadrant.io/aws": return "aws", nil + case "kuadrant.io/azure": + return "azure", nil case "kuadrant.io/gcp": return "google", nil case "kuadrant.io/inmemory": diff --git a/make/managedzones.mk b/make/managedzones.mk index 91d9d49f..04fd2387 100644 --- a/make/managedzones.mk +++ b/make/managedzones.mk @@ -11,10 +11,18 @@ endef ndef = $(if $(value $(1)),,$(error $(1) not set)) -LOCAL_SETUP_AWS_MZ_CONFIG=config/local-setup/managedzone/aws/managed-zone-config.env -LOCAL_SETUP_AWS_MZ_CREDS=config/local-setup/managedzone/aws/aws-credentials.env -LOCAL_SETUP_GCP_MZ_CONFIG=config/local-setup/managedzone/gcp/managed-zone-config.env -LOCAL_SETUP_GCP_MZ_CREDS=config/local-setup/managedzone/gcp/gcp-credentials.env + +LOCAL_SETUP_AWS_MZ_DIR=config/local-setup/managedzone/aws +LOCAL_SETUP_AWS_MZ_CONFIG=${LOCAL_SETUP_AWS_MZ_DIR}/managed-zone-config.env +LOCAL_SETUP_AWS_MZ_CREDS=${LOCAL_SETUP_AWS_MZ_DIR}/aws-credentials.env + +LOCAL_SETUP_GCP_MZ_DIR=config/local-setup/managedzone/gcp +LOCAL_SETUP_GCP_MZ_CONFIG=${LOCAL_SETUP_GCP_MZ_DIR}/managed-zone-config.env +LOCAL_SETUP_GCP_MZ_CREDS=${LOCAL_SETUP_GCP_MZ_DIR}/gcp-credentials.env + +LOCAL_SETUP_AZURE_MZ_DIR=config/local-setup/managedzone/azure +LOCAL_SETUP_AZURE_MZ_CONFIG=${LOCAL_SETUP_AZURE_MZ_DIR}/managed-zone-config.env +LOCAL_SETUP_AZURE_MZ_CREDS=${LOCAL_SETUP_AZURE_MZ_DIR}/azure-credentials.env .PHONY: local-setup-aws-mz-generate local-setup-aws-mz-generate: local-setup-aws-mz-config local-setup-aws-mz-credentials ## Generate AWS ManagedZone configuration and credentials for local-setup @@ -60,14 +68,39 @@ $(LOCAL_SETUP_GCP_MZ_CREDS): $(call ndef,GCP_PROJECT_ID) $(call patch-config,${LOCAL_SETUP_GCP_MZ_CREDS}.template,${LOCAL_SETUP_GCP_MZ_CREDS}) + +.PHONY: local-setup-azure-mz-generate +local-setup-azure-mz-generate: local-setup-azure-mz-config local-setup-azure-mz-credentials ## Generate Azure ManagedZone configuration and credentials for local-setup + +.PHONY: local-setup-azure-mz-clean +local-setup-azure-mz-clean: ## Remove Azure ManagedZone configuration and credentials + rm -f ${LOCAL_SETUP_AZURE_MZ_CONFIG} + rm -f ${LOCAL_SETUP_AZURE_MZ_CREDS} + +.PHONY: local-setup-azure-mz-config +local-setup-azure-mz-config: $(LOCAL_SETUP_AZURE_MZ_CONFIG) +$(LOCAL_SETUP_AZURE_MZ_CONFIG): + $(call ndef,KUADRANT_AZURE_DNS_ZONE_ID) + $(call patch-config,${LOCAL_SETUP_AZURE_MZ_CONFIG}.template,${LOCAL_SETUP_AZURE_MZ_CONFIG}) + +.PHONY: local-setup-azure-mz-credentials +local-setup-azure-mz-credentials: $(LOCAL_SETUP_AZURE_MZ_CREDS) +$(LOCAL_SETUP_AZURE_MZ_CREDS): + $(call ndef,KUADRANT_AZURE_CREDENTIALS) + $(call patch-config,${LOCAL_SETUP_AZURE_MZ_CREDS}.template,${LOCAL_SETUP_AZURE_MZ_CREDS}) + .PHONY: local-setup-managedzones local-setup-managedzones: TARGET_NAMESPACE=dnstest -local-setup-managedzones: kustomize ## Create AWS and GCP managedzones in the 'TARGET_NAMESPACE' namespace - @if [[ -f "config/local-setup/managedzone/gcp/managed-zone-config.env" && -f "config/local-setup/managedzone/gcp/gcp-credentials.env" ]]; then\ +local-setup-managedzones: kustomize ## Create AWS, Azure and GCP managedzones in the 'TARGET_NAMESPACE' namespace + @if [[ -f ${LOCAL_SETUP_GCP_MZ_CONFIG} && -f ${LOCAL_SETUP_GCP_MZ_CREDS} ]]; then\ echo "local-setup: creating managedzone for gcp config and credentials in ${TARGET_NAMESPACE}";\ - ${KUSTOMIZE} build config/local-setup/managedzone/gcp | $(KUBECTL) -n ${TARGET_NAMESPACE} apply -f -;\ + ${KUSTOMIZE} build ${LOCAL_SETUP_GCP_MZ_DIR} | $(KUBECTL) -n ${TARGET_NAMESPACE} apply -f -;\ fi - @if [[ -f "config/local-setup/managedzone/aws/managed-zone-config.env" && -f "config/local-setup/managedzone/aws/aws-credentials.env" ]]; then\ + @if [[ -f ${LOCAL_SETUP_AWS_MZ_CONFIG} && -f ${LOCAL_SETUP_AWS_MZ_CREDS} ]]; then\ echo "local-setup: creating managedzone for aws config and credentials in ${TARGET_NAMESPACE}";\ - ${KUSTOMIZE} build config/local-setup/managedzone/aws | $(KUBECTL) -n ${TARGET_NAMESPACE} apply -f -;\ + ${KUSTOMIZE} build ${LOCAL_SETUP_AWS_MZ_DIR} | $(KUBECTL) -n ${TARGET_NAMESPACE} apply -f -;\ + fi + @if [[ -f ${LOCAL_SETUP_AZURE_MZ_CONFIG} && -f ${LOCAL_SETUP_AZURE_MZ_CREDS} ]]; then\ + echo "local-setup: creating managedzone for azure config and credentials in ${TARGET_NAMESPACE}";\ + ${KUSTOMIZE} build ${LOCAL_SETUP_AZURE_MZ_DIR} | $(KUBECTL) -n ${TARGET_NAMESPACE} apply -f -;\ fi diff --git a/test/e2e/healthcheck_test.go b/test/e2e/healthcheck_test.go index 742ad8d5..98b21451 100644 --- a/test/e2e/healthcheck_test.go +++ b/test/e2e/healthcheck_test.go @@ -49,6 +49,10 @@ var _ = Describe("Health Check Test", Serial, Labels{"health_checks"}, func() { Context("DNS Provider health checks", func() { It("creates health checks for a health check spec", func(ctx SpecContext) { + // azure only handles simple DNS Records, and AWS health checks require CNAMEs + if testDNSProvider == "azure" { + Skip("not yet supported for azure") + } healthChecksSupported := false if slices.Contains(supportedHealthCheckProviders, strings.ToLower(testDNSProvider)) { healthChecksSupported = true diff --git a/test/e2e/helpers/common.go b/test/e2e/helpers/common.go index d8630fb4..9dd2d13b 100644 --- a/test/e2e/helpers/common.go +++ b/test/e2e/helpers/common.go @@ -23,7 +23,7 @@ import ( ) var ( - SupportedProviders = []string{"aws", "gcp"} + SupportedProviders = []string{"aws", "gcp", "azure"} ) const ( @@ -78,7 +78,7 @@ func EndpointsForHost(ctx context.Context, provider provider.Provider, host stri func ProviderForManagedZone(ctx context.Context, mz *v1alpha1.ManagedZone, c client.Client) (provider.Provider, error) { //ToDo mnairn: We have a mismatch in naming GCP vs Google, we need to make this consistent one way or the other - providerFactory, err := provider.NewFactory(c, []string{"aws", "google"}) + providerFactory, err := provider.NewFactory(c, []string{"aws", "google", "azure"}) if err != nil { return nil, err } diff --git a/test/e2e/multi_instance/multi_record_test.go b/test/e2e/multi_instance/multi_record_test.go index 45e8e21d..34b0f7c7 100644 --- a/test/e2e/multi_instance/multi_record_test.go +++ b/test/e2e/multi_instance/multi_record_test.go @@ -251,6 +251,9 @@ var _ = Describe("Multi Record Test", func() { Context("loadbalanced", func() { It("creates and deletes distributed dns records", func(ctx SpecContext) { + if testDNSProvider == "azure" { + Skip("not yet supported for azure") + } testGeoRecords := map[string][]testDNSRecord{} By(fmt.Sprintf("creating %d loadbalanced dnsrecords accross %d clusters", len(testNamespaces)*len(testClusters), len(testClusters))) diff --git a/test/e2e/multi_instance/suite_test.go b/test/e2e/multi_instance/suite_test.go index 6e95370a..183d6881 100644 --- a/test/e2e/multi_instance/suite_test.go +++ b/test/e2e/multi_instance/suite_test.go @@ -26,6 +26,7 @@ import ( "github.com/kuadrant/dns-operator/api/v1alpha1" "github.com/kuadrant/dns-operator/internal/provider" _ "github.com/kuadrant/dns-operator/internal/provider/aws" + _ "github.com/kuadrant/dns-operator/internal/provider/azure" _ "github.com/kuadrant/dns-operator/internal/provider/google" . "github.com/kuadrant/dns-operator/test/e2e/helpers" ) diff --git a/test/e2e/multi_record_test.go b/test/e2e/multi_record_test.go index 618c380a..046a0f77 100644 --- a/test/e2e/multi_record_test.go +++ b/test/e2e/multi_record_test.go @@ -201,7 +201,7 @@ var _ = Describe("Multi Record Test", func() { err := k8sClient.Get(ctx, client.ObjectKeyFromObject(dnsRecord2), dnsRecord2) g.Expect(err).To(HaveOccurred()) g.Expect(err).To(MatchError(ContainSubstring("not found"))) - }, 10*time.Second, 1*time.Second, ctx).Should(Succeed()) + }, 25*time.Second, 1*time.Second, ctx).Should(Succeed()) By("ensuring zone records are updated as expected") Eventually(func(g Gomega, ctx context.Context) { @@ -256,6 +256,9 @@ var _ = Describe("Multi Record Test", func() { Context("loadbalanced", func() { It("makes available a hostname that can be resolved", func(ctx SpecContext) { + if testDNSProvider == "azure" { + Skip("not yet supported for azure") + } By("creating two dns records") klbHostName := "klb." + testHostname diff --git a/test/e2e/provider_errors_test.go b/test/e2e/provider_errors_test.go index 0553da17..9a7efb73 100644 --- a/test/e2e/provider_errors_test.go +++ b/test/e2e/provider_errors_test.go @@ -53,6 +53,8 @@ var _ = Describe("DNSRecord Provider Errors", Labels{"provider_errors"}, func() //GCP expectedProviderErr = "location': 'notageocode', invalid" validGeoCode = "us-east1" + } else if testDNSProvider == "azure" { + Skip("not yet supported for azure") } else { //AWS expectedProviderErr = "Value 'notageocode' with length = '11' is not facet-valid with respect to length '2' for type 'ContinentCode'" @@ -142,6 +144,8 @@ var _ = Describe("DNSRecord Provider Errors", Labels{"provider_errors"}, func() if testDNSProvider == "gcp" { //GCP expectedProviderErr = "weight': '-1.0' Reason: backendError, Message: Invalid Value" + } else if testDNSProvider == "azure" { + Skip("not yet supported for azure") } else { //AWS expectedProviderErr = "weight' failed to satisfy constraint: Member must have value greater than or equal to 0" diff --git a/test/e2e/single_record_test.go b/test/e2e/single_record_test.go index af67439b..5616a039 100644 --- a/test/e2e/single_record_test.go +++ b/test/e2e/single_record_test.go @@ -234,6 +234,9 @@ var _ = Describe("Single Record Test", func() { Context("loadbalanced", func() { It("makes available a hostname that can be resolved", func(ctx SpecContext) { + if testDNSProvider == "azure" { + Skip("not yet supported for azure") + } testTargetIP := "127.0.0.1" klbHostName := "klb." + testHostname diff --git a/test/e2e/suite_test.go b/test/e2e/suite_test.go index b73b4c43..08f706a4 100644 --- a/test/e2e/suite_test.go +++ b/test/e2e/suite_test.go @@ -20,6 +20,7 @@ import ( "github.com/kuadrant/dns-operator/api/v1alpha1" _ "github.com/kuadrant/dns-operator/internal/provider/aws" + _ "github.com/kuadrant/dns-operator/internal/provider/azure" _ "github.com/kuadrant/dns-operator/internal/provider/google" . "github.com/kuadrant/dns-operator/test/e2e/helpers" ) @@ -41,7 +42,7 @@ var ( testManagedZoneName string testNamespace string testDNSProvider string - supportedProviders = []string{"aws", "gcp"} + supportedProviders = []string{"aws", "gcp", "azure"} supportedHealthCheckProviders = []string{"aws"} testManagedZone *v1alpha1.ManagedZone )