diff --git a/docs/annotations/annotations.md b/docs/annotations/annotations.md index 96ad1e71a4..08dfac8115 100644 --- a/docs/annotations/annotations.md +++ b/docs/annotations/annotations.md @@ -286,6 +286,7 @@ Some providers define their own annotations. Cloud-specific annotations have key | Cloud | Annotation prefix | |------------|------------------------------------------------| | AWS | `external-dns.alpha.kubernetes.io/aws-` | +| Azure | `external-dns.alpha.kubernetes.io/azure-` | | CloudFlare | `external-dns.alpha.kubernetes.io/cloudflare-` | | Scaleway | `external-dns.alpha.kubernetes.io/scw-` | diff --git a/docs/tutorials/azure.md b/docs/tutorials/azure.md index edf7adafbc..ab94b683f3 100644 --- a/docs/tutorials/azure.md +++ b/docs/tutorials/azure.md @@ -511,6 +511,62 @@ Ensure that your nginx-ingress deployment has the following arg: added to it: For more details see here: [nginx-ingress external-dns](https://github.com/kubernetes-sigs/external-dns/blob/HEAD/docs/faq.md#why-is-externaldns-only-adding-a-single-ip-address-in-route-53-on-aws-when-using-the-nginx-ingress-controller-how-do-i-get-it-to-use-the-fqdn-of-the-elb-assigned-to-my-nginx-ingress-controller-service-instead) +## DNS Record Metadata (Tags) + +External-DNS supports setting Azure resource metadata (tags) on DNS records using annotations on Kubernetes resources. + +### Usage with Ingress + +```yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: my-ingress + annotations: + external-dns.alpha.kubernetes.io/azure-metadata-cost-center: "12345" + external-dns.alpha.kubernetes.io/azure-metadata-owner: backend-team +spec: + rules: + - host: app.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: my-service + port: + number: 80 +``` + +### Usage with Gateway API (HTTPRoute) + +```yaml +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: my-route + annotations: + external-dns.alpha.kubernetes.io/azure-metadata-environment: production + external-dns.alpha.kubernetes.io/azure-metadata-app: myapp +spec: + parentRefs: + - name: my-gateway + hostnames: + - "api.example.com" + rules: + - backendRefs: + - name: my-service + port: 8080 +``` + +**Note:** Metadata annotations must be placed directly on each route resource (HTTPRoute, TLSRoute, GRPCRoute, etc.). Gateway annotations are not automatically inherited by routes. + +### Annotation Format + +Metadata annotations must follow the format: +`external-dns.alpha.kubernetes.io/azure-metadata-: ` + ## Deploy ExternalDNS Connect your `kubectl` client to the cluster you want to test ExternalDNS with. Then apply one of the following manifests file to deploy ExternalDNS. diff --git a/provider/azure/azure.go b/provider/azure/azure.go index d53efa32ff..ef7197f6cf 100644 --- a/provider/azure/azure.go +++ b/provider/azure/azure.go @@ -36,6 +36,8 @@ import ( const ( defaultTTL = 300 + // Azure-specific provider properties + providerSpecificMetadataPrefix = "azure/metadata-" ) // ZonesClient is an interface of dns.ZoneClient that can be stubbed for testing. @@ -145,6 +147,7 @@ func (p *AzureProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, erro ttl = endpoint.TTL(*recordSet.Properties.TTL) } ep := endpoint.NewEndpointWithTTL(name, recordType, ttl, targets...) + extractMetadataFromRecordSet(ep, recordSet) log.Debugf( "Found %s record for '%s' with target '%s'.", ep.RecordType, @@ -346,6 +349,8 @@ func (p *AzureProvider) newRecordSet(endpoint *endpoint.Endpoint) (dns.RecordSet if endpoint.RecordTTL.IsConfigured() { ttl = int64(endpoint.RecordTTL) } + // Extract metadata from ProviderSpecific properties + metadata := extractMetadataFromEndpoint(endpoint) switch dns.RecordType(endpoint.RecordType) { case dns.RecordTypeA: aRecords := make([]*dns.ARecord, len(endpoint.Targets)) @@ -358,6 +363,7 @@ func (p *AzureProvider) newRecordSet(endpoint *endpoint.Endpoint) (dns.RecordSet Properties: &dns.RecordSetProperties{ TTL: to.Ptr(ttl), ARecords: aRecords, + Metadata: metadata, }, }, nil case dns.RecordTypeAAAA: @@ -371,6 +377,7 @@ func (p *AzureProvider) newRecordSet(endpoint *endpoint.Endpoint) (dns.RecordSet Properties: &dns.RecordSetProperties{ TTL: to.Ptr(ttl), AaaaRecords: aaaaRecords, + Metadata: metadata, }, }, nil case dns.RecordTypeCNAME: @@ -380,6 +387,7 @@ func (p *AzureProvider) newRecordSet(endpoint *endpoint.Endpoint) (dns.RecordSet CnameRecord: &dns.CnameRecord{ Cname: to.Ptr(endpoint.Targets[0]), }, + Metadata: metadata, }, }, nil case dns.RecordTypeMX: @@ -395,6 +403,7 @@ func (p *AzureProvider) newRecordSet(endpoint *endpoint.Endpoint) (dns.RecordSet Properties: &dns.RecordSetProperties{ TTL: to.Ptr(ttl), MxRecords: mxRecords, + Metadata: metadata, }, }, nil case dns.RecordTypeNS: @@ -408,6 +417,7 @@ func (p *AzureProvider) newRecordSet(endpoint *endpoint.Endpoint) (dns.RecordSet Properties: &dns.RecordSetProperties{ TTL: to.Ptr(ttl), NsRecords: nsRecords, + Metadata: metadata, }, }, nil case dns.RecordTypeTXT: @@ -421,6 +431,7 @@ func (p *AzureProvider) newRecordSet(endpoint *endpoint.Endpoint) (dns.RecordSet }, }, }, + Metadata: metadata, }, }, nil } @@ -498,3 +509,35 @@ func extractAzureTargets(recordSet *dns.RecordSet) []string { } return []string{} } + +// extractMetadataFromRecordSet extracts Azure RecordSet metadata into endpoint ProviderSpecific properties. +func extractMetadataFromRecordSet(ep *endpoint.Endpoint, recordSet *dns.RecordSet) { + if recordSet.Properties == nil || recordSet.Properties.Metadata == nil { + return + } + for key, value := range recordSet.Properties.Metadata { + if value != nil { + ep.WithProviderSpecific(providerSpecificMetadataPrefix+key, *value) + } + } +} + +// extractMetadataFromEndpoint extracts Azure metadata from endpoint ProviderSpecific properties. +// Properties with prefix "azure/metadata-" are converted to Azure RecordSet metadata. +func extractMetadataFromEndpoint(ep *endpoint.Endpoint) map[string]*string { + if len(ep.ProviderSpecific) == 0 { + return nil + } + metadata := make(map[string]*string) + for _, ps := range ep.ProviderSpecific { + if key, ok := strings.CutPrefix(ps.Name, providerSpecificMetadataPrefix); ok { + if key != "" && ps.Value != "" { + metadata[key] = to.Ptr(ps.Value) + } + } + } + if len(metadata) == 0 { + return nil + } + return metadata +} diff --git a/provider/azure/azure_test.go b/provider/azure/azure_test.go index a55070e264..c135bf4b44 100644 --- a/provider/azure/azure_test.go +++ b/provider/azure/azure_test.go @@ -386,6 +386,7 @@ func TestAzureApplyChanges(t *testing.T) { 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("metadata.example.com", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "1.2.3.4"), }) } @@ -439,6 +440,9 @@ func testAzureApplyChangesInternal(t *testing.T, dryRun bool, client RecordSetsC 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"), + endpoint.NewEndpointWithTTL("metadata.example.com", endpoint.RecordTypeA, endpoint.TTL(recordTTL), "1.2.3.4"). + WithProviderSpecific("azure/metadata-foo", "bar"). + WithProviderSpecific("azure/metadata-baz", "qux"), } currentRecords := []*endpoint.Endpoint{ diff --git a/source/annotations/annotations.go b/source/annotations/annotations.go index 73f916f232..096ddc8cc9 100644 --- a/source/annotations/annotations.go +++ b/source/annotations/annotations.go @@ -42,6 +42,7 @@ var ( SCWPrefix string WebhookPrefix string CloudflarePrefix string + AzurePrefix string TtlKey string SetIdentifierKey string @@ -85,6 +86,7 @@ func SetAnnotationPrefix(prefix string) { SCWPrefix = AnnotationKeyPrefix + "scw-" WebhookPrefix = AnnotationKeyPrefix + "webhook-" CloudflarePrefix = AnnotationKeyPrefix + "cloudflare-" + AzurePrefix = AnnotationKeyPrefix + "azure-" // Core annotations TtlKey = AnnotationKeyPrefix + "ttl" diff --git a/source/annotations/provider_specific.go b/source/annotations/provider_specific.go index fa70a8654a..41021aad95 100644 --- a/source/annotations/provider_specific.go +++ b/source/annotations/provider_specific.go @@ -54,6 +54,11 @@ func ProviderSpecificAnnotations(annotations map[string]string) (endpoint.Provid Name: fmt.Sprintf("coredns/%s", attr), Value: v, }) + } else if attr, ok := strings.CutPrefix(k, AzurePrefix); ok { + providerSpecificAnnotations = append(providerSpecificAnnotations, endpoint.ProviderSpecificProperty{ + Name: fmt.Sprintf("azure/%s", attr), + Value: v, + }) } else if strings.HasPrefix(k, CloudflarePrefix) { switch { case strings.Contains(k, CloudflareCustomHostnameKey): diff --git a/source/annotations/provider_specific_test.go b/source/annotations/provider_specific_test.go index 684ea431ff..992ece5640 100644 --- a/source/annotations/provider_specific_test.go +++ b/source/annotations/provider_specific_test.go @@ -14,6 +14,7 @@ limitations under the License. package annotations import ( + "os" "strconv" "testing" @@ -21,6 +22,12 @@ import ( "sigs.k8s.io/external-dns/endpoint" ) +func TestMain(m *testing.M) { + // Initialize annotation prefixes before running tests + SetAnnotationPrefix(DefaultAnnotationPrefix) + os.Exit(m.Run()) +} + func TestProviderSpecificAnnotations(t *testing.T) { tests := []struct { name string @@ -74,6 +81,16 @@ func TestProviderSpecificAnnotations(t *testing.T) { }, setIdentifier: "", }, + { + name: "Azure metadata annotation", + annotations: map[string]string{ + "external-dns.alpha.kubernetes.io/azure-metadata-environment": "production", + }, + expected: endpoint.ProviderSpecific{ + {Name: "azure/metadata-environment", Value: "production"}, + }, + setIdentifier: "", + }, { name: "Set identifier annotation", annotations: map[string]string{ @@ -358,6 +375,19 @@ func TestGetProviderSpecificIdentifierAnnotations(t *testing.T) { }, expectedIdentifier: "id1", }, + { + title: "azure- provider specific annotations are set correctly", + annotations: map[string]string{ + "external-dns.alpha.kubernetes.io/azure-metadata-foo": "bar", + SetIdentifierKey: "id1", + "external-dns.alpha.kubernetes.io/azure-metadata-baz": "qux", + }, + expectedResult: map[string]string{ + "azure/metadata-foo": "bar", + "azure/metadata-baz": "qux", + }, + expectedIdentifier: "id1", + }, } { t.Run(tc.title, func(t *testing.T) { providerSpecificAnnotations, identifier := ProviderSpecificAnnotations(tc.annotations)