Skip to content

Commit 83edb29

Browse files
committed
feat: Add Azure DNS metadata (tags) support
1 parent 0c39b6e commit 83edb29

File tree

6 files changed

+216
-0
lines changed

6 files changed

+216
-0
lines changed

docs/annotations/annotations.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,7 @@ Some providers define their own annotations. Cloud-specific annotations have key
286286
| Cloud | Annotation prefix |
287287
|------------|------------------------------------------------|
288288
| AWS | `external-dns.alpha.kubernetes.io/aws-` |
289+
| Azure | `external-dns.alpha.kubernetes.io/azure-` |
289290
| CloudFlare | `external-dns.alpha.kubernetes.io/cloudflare-` |
290291
| Scaleway | `external-dns.alpha.kubernetes.io/scw-` |
291292

docs/tutorials/azure.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,62 @@ Ensure that your nginx-ingress deployment has the following arg: added to it:
511511

512512
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)
513513

514+
## DNS Record Metadata (Tags)
515+
516+
External-DNS supports setting Azure resource metadata (tags) on DNS records using annotations on Kubernetes resources.
517+
518+
### Usage with Ingress
519+
520+
```yaml
521+
apiVersion: networking.k8s.io/v1
522+
kind: Ingress
523+
metadata:
524+
name: my-ingress
525+
annotations:
526+
external-dns.alpha.kubernetes.io/azure-metadata-cost-center: "12345"
527+
external-dns.alpha.kubernetes.io/azure-metadata-owner: backend-team
528+
spec:
529+
rules:
530+
- host: app.example.com
531+
http:
532+
paths:
533+
- path: /
534+
pathType: Prefix
535+
backend:
536+
service:
537+
name: my-service
538+
port:
539+
number: 80
540+
```
541+
542+
### Usage with Gateway API (HTTPRoute)
543+
544+
```yaml
545+
apiVersion: gateway.networking.k8s.io/v1
546+
kind: HTTPRoute
547+
metadata:
548+
name: my-route
549+
annotations:
550+
external-dns.alpha.kubernetes.io/azure-metadata-environment: production
551+
external-dns.alpha.kubernetes.io/azure-metadata-app: myapp
552+
spec:
553+
parentRefs:
554+
- name: my-gateway
555+
hostnames:
556+
- "api.example.com"
557+
rules:
558+
- backendRefs:
559+
- name: my-service
560+
port: 8080
561+
```
562+
563+
**Note:** Metadata annotations must be placed directly on each route resource (HTTPRoute, TLSRoute, GRPCRoute, etc.). Gateway annotations are not automatically inherited by routes.
564+
565+
### Annotation Format
566+
567+
Metadata annotations must follow the format:
568+
`external-dns.alpha.kubernetes.io/azure-metadata-<key>: <value>`
569+
514570
## Deploy ExternalDNS
515571

516572
Connect your `kubectl` client to the cluster you want to test ExternalDNS with. Then apply one of the following manifests file to deploy ExternalDNS.

provider/azure/azure.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ import (
3636

3737
const (
3838
defaultTTL = 300
39+
// Azure-specific provider properties
40+
providerSpecificMetadataPrefix = "azure-metadata-"
3941
)
4042

4143
// ZonesClient is an interface of dns.ZoneClient that can be stubbed for testing.
@@ -145,6 +147,14 @@ func (p *AzureProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, erro
145147
ttl = endpoint.TTL(*recordSet.Properties.TTL)
146148
}
147149
ep := endpoint.NewEndpointWithTTL(name, recordType, ttl, targets...)
150+
// Extract metadata from Azure RecordSet
151+
if recordSet.Properties != nil && recordSet.Properties.Metadata != nil {
152+
for key, value := range recordSet.Properties.Metadata {
153+
if value != nil {
154+
ep.WithProviderSpecific(providerSpecificMetadataPrefix+key, *value)
155+
}
156+
}
157+
}
148158
log.Debugf(
149159
"Found %s record for '%s' with target '%s'.",
150160
ep.RecordType,
@@ -346,6 +356,8 @@ func (p *AzureProvider) newRecordSet(endpoint *endpoint.Endpoint) (dns.RecordSet
346356
if endpoint.RecordTTL.IsConfigured() {
347357
ttl = int64(endpoint.RecordTTL)
348358
}
359+
// Extract metadata from ProviderSpecific properties
360+
metadata := extractMetadataFromEndpoint(endpoint)
349361
switch dns.RecordType(endpoint.RecordType) {
350362
case dns.RecordTypeA:
351363
aRecords := make([]*dns.ARecord, len(endpoint.Targets))
@@ -358,6 +370,7 @@ func (p *AzureProvider) newRecordSet(endpoint *endpoint.Endpoint) (dns.RecordSet
358370
Properties: &dns.RecordSetProperties{
359371
TTL: to.Ptr(ttl),
360372
ARecords: aRecords,
373+
Metadata: metadata,
361374
},
362375
}, nil
363376
case dns.RecordTypeAAAA:
@@ -371,6 +384,7 @@ func (p *AzureProvider) newRecordSet(endpoint *endpoint.Endpoint) (dns.RecordSet
371384
Properties: &dns.RecordSetProperties{
372385
TTL: to.Ptr(ttl),
373386
AaaaRecords: aaaaRecords,
387+
Metadata: metadata,
374388
},
375389
}, nil
376390
case dns.RecordTypeCNAME:
@@ -380,6 +394,7 @@ func (p *AzureProvider) newRecordSet(endpoint *endpoint.Endpoint) (dns.RecordSet
380394
CnameRecord: &dns.CnameRecord{
381395
Cname: to.Ptr(endpoint.Targets[0]),
382396
},
397+
Metadata: metadata,
383398
},
384399
}, nil
385400
case dns.RecordTypeMX:
@@ -395,6 +410,7 @@ func (p *AzureProvider) newRecordSet(endpoint *endpoint.Endpoint) (dns.RecordSet
395410
Properties: &dns.RecordSetProperties{
396411
TTL: to.Ptr(ttl),
397412
MxRecords: mxRecords,
413+
Metadata: metadata,
398414
},
399415
}, nil
400416
case dns.RecordTypeNS:
@@ -408,6 +424,7 @@ func (p *AzureProvider) newRecordSet(endpoint *endpoint.Endpoint) (dns.RecordSet
408424
Properties: &dns.RecordSetProperties{
409425
TTL: to.Ptr(ttl),
410426
NsRecords: nsRecords,
427+
Metadata: metadata,
411428
},
412429
}, nil
413430
case dns.RecordTypeTXT:
@@ -421,6 +438,7 @@ func (p *AzureProvider) newRecordSet(endpoint *endpoint.Endpoint) (dns.RecordSet
421438
},
422439
},
423440
},
441+
Metadata: metadata,
424442
},
425443
}, nil
426444
}
@@ -498,3 +516,20 @@ func extractAzureTargets(recordSet *dns.RecordSet) []string {
498516
}
499517
return []string{}
500518
}
519+
520+
// extractMetadataFromEndpoint extracts Azure metadata from endpoint ProviderSpecific properties.
521+
// Properties with prefix "azure-metadata-" are converted to Azure RecordSet metadata.
522+
func extractMetadataFromEndpoint(ep *endpoint.Endpoint) map[string]*string {
523+
metadata := make(map[string]*string)
524+
for _, ps := range ep.ProviderSpecific {
525+
if key, ok := strings.CutPrefix(ps.Name, providerSpecificMetadataPrefix); ok {
526+
if key != "" && ps.Value != "" {
527+
metadata[key] = to.Ptr(ps.Value)
528+
}
529+
}
530+
}
531+
if len(metadata) == 0 {
532+
return nil
533+
}
534+
return metadata
535+
}

provider/azure/azure_test.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -612,3 +612,120 @@ func testAzureApplyChangesInternalZoneName(t *testing.T, dryRun bool, client Rec
612612
t.Fatal(err)
613613
}
614614
}
615+
616+
func TestRecordsWithMetadata(t *testing.T) {
617+
// Create a record set with metadata
618+
recordSetWithMetadata := &dns.RecordSet{
619+
Name: to.Ptr("example"),
620+
Type: to.Ptr("Microsoft.Network/dnszones/A"),
621+
Properties: &dns.RecordSetProperties{
622+
TTL: to.Ptr[int64](300),
623+
ARecords: []*dns.ARecord{
624+
{IPv4Address: to.Ptr("1.2.3.4")},
625+
},
626+
Metadata: map[string]*string{
627+
"environment": to.Ptr("production"),
628+
"team": to.Ptr("platform"),
629+
},
630+
},
631+
}
632+
633+
provider, err := newMockedAzureProvider(
634+
endpoint.NewDomainFilter([]string{"example.com"}),
635+
endpoint.NewDomainFilter([]string{}),
636+
provider.NewZoneIDFilter([]string{""}),
637+
false,
638+
"k8s",
639+
"",
640+
"",
641+
[]*dns.Zone{createMockZone("example.com", "/dnszones/example.com")},
642+
[]*dns.RecordSet{recordSetWithMetadata},
643+
3,
644+
)
645+
assert.NoError(t, err)
646+
647+
endpoints, err := provider.Records(context.Background())
648+
assert.NoError(t, err)
649+
assert.Len(t, endpoints, 1)
650+
651+
ep := endpoints[0]
652+
assert.Equal(t, "example.example.com", ep.DNSName)
653+
assert.Equal(t, endpoint.RecordTypeA, ep.RecordType)
654+
655+
// Check metadata was extracted
656+
hasEnv := false
657+
hasTeam := false
658+
for _, ps := range ep.ProviderSpecific {
659+
if ps.Name == "azure-metadata-environment" && ps.Value == "production" {
660+
hasEnv = true
661+
}
662+
if ps.Name == "azure-metadata-team" && ps.Value == "platform" {
663+
hasTeam = true
664+
}
665+
}
666+
assert.True(t, hasEnv, "Expected environment metadata to be present")
667+
assert.True(t, hasTeam, "Expected team metadata to be present")
668+
}
669+
670+
func TestNewRecordSetWithMetadata(t *testing.T) {
671+
provider, err := newMockedAzureProvider(
672+
endpoint.NewDomainFilter([]string{"example.com"}),
673+
endpoint.NewDomainFilter([]string{}),
674+
provider.NewZoneIDFilter([]string{""}),
675+
false,
676+
"k8s",
677+
"",
678+
"",
679+
[]*dns.Zone{createMockZone("example.com", "/dnszones/example.com")},
680+
[]*dns.RecordSet{},
681+
3,
682+
)
683+
assert.NoError(t, err)
684+
685+
t.Run("A record with metadata", func(t *testing.T) {
686+
ep := endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "1.2.3.4")
687+
ep.WithProviderSpecific("azure-metadata-environment", "production")
688+
ep.WithProviderSpecific("azure-metadata-cost-center", "12345")
689+
690+
recordSet, err := provider.newRecordSet(ep)
691+
assert.NoError(t, err)
692+
assert.NotNil(t, recordSet.Properties)
693+
assert.NotNil(t, recordSet.Properties.Metadata)
694+
assert.Len(t, recordSet.Properties.Metadata, 2)
695+
assert.Equal(t, "production", *recordSet.Properties.Metadata["environment"])
696+
assert.Equal(t, "12345", *recordSet.Properties.Metadata["cost-center"])
697+
})
698+
699+
t.Run("CNAME record with metadata", func(t *testing.T) {
700+
ep := endpoint.NewEndpoint("example.com", endpoint.RecordTypeCNAME, "target.com")
701+
ep.WithProviderSpecific("azure-metadata-owner", "team-backend")
702+
703+
recordSet, err := provider.newRecordSet(ep)
704+
assert.NoError(t, err)
705+
assert.NotNil(t, recordSet.Properties)
706+
assert.NotNil(t, recordSet.Properties.Metadata)
707+
assert.Len(t, recordSet.Properties.Metadata, 1)
708+
assert.Equal(t, "team-backend", *recordSet.Properties.Metadata["owner"])
709+
})
710+
711+
t.Run("TXT record with metadata", func(t *testing.T) {
712+
ep := endpoint.NewEndpoint("example.com", endpoint.RecordTypeTXT, "heritage=external-dns")
713+
ep.WithProviderSpecific("azure-metadata-managed-by", "kubernetes")
714+
715+
recordSet, err := provider.newRecordSet(ep)
716+
assert.NoError(t, err)
717+
assert.NotNil(t, recordSet.Properties)
718+
assert.NotNil(t, recordSet.Properties.Metadata)
719+
assert.Len(t, recordSet.Properties.Metadata, 1)
720+
assert.Equal(t, "kubernetes", *recordSet.Properties.Metadata["managed-by"])
721+
})
722+
723+
t.Run("A record without metadata", func(t *testing.T) {
724+
ep := endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "1.2.3.4")
725+
726+
recordSet, err := provider.newRecordSet(ep)
727+
assert.NoError(t, err)
728+
assert.NotNil(t, recordSet.Properties)
729+
assert.Nil(t, recordSet.Properties.Metadata)
730+
})
731+
}

source/annotations/annotations.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ var (
4242
SCWPrefix string
4343
WebhookPrefix string
4444
CloudflarePrefix string
45+
AzurePrefix string
4546

4647
TtlKey string
4748
SetIdentifierKey string
@@ -85,6 +86,7 @@ func SetAnnotationPrefix(prefix string) {
8586
SCWPrefix = AnnotationKeyPrefix + "scw-"
8687
WebhookPrefix = AnnotationKeyPrefix + "webhook-"
8788
CloudflarePrefix = AnnotationKeyPrefix + "cloudflare-"
89+
AzurePrefix = AnnotationKeyPrefix + "azure-"
8890

8991
// Core annotations
9092
TtlKey = AnnotationKeyPrefix + "ttl"

source/annotations/provider_specific.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,11 @@ func ProviderSpecificAnnotations(annotations map[string]string) (endpoint.Provid
5454
Name: fmt.Sprintf("coredns/%s", attr),
5555
Value: v,
5656
})
57+
} else if attr, ok := strings.CutPrefix(k, AzurePrefix); ok {
58+
providerSpecificAnnotations = append(providerSpecificAnnotations, endpoint.ProviderSpecificProperty{
59+
Name: fmt.Sprintf("azure-%s", attr),
60+
Value: v,
61+
})
5762
} else if strings.HasPrefix(k, CloudflarePrefix) {
5863
switch {
5964
case strings.Contains(k, CloudflareCustomHostnameKey):

0 commit comments

Comments
 (0)