Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/annotations/annotations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-` |

Expand Down
56 changes: 56 additions & 0 deletions docs/tutorials/azure.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does not looks consistent with other providers like aws or cloudflare

Could we have

external-dns.alpha.kubernetes.io/azure-tags: "cost-center=12345,owner=backend-team"

Current approach will require way to many annotaions on objects, and I did not get from first look that two annotations actually represent tags. There are of course limitations on number of annotitionas and character limit on annotations values. I think we better be consistent for now.

Kubernetes has 63 characters limitation on the annotation name. Example external-dns.alpha.kubernetes.io/azure-metadata-cost-center is already 59....

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"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI: This is under discussion in gateway-api if this is what we should support or not.

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-<key>: <value>`

## 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.
Expand Down
35 changes: 35 additions & 0 deletions provider/azure/azure.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -145,6 +147,14 @@ func (p *AzureProvider) Records(ctx context.Context) ([]*endpoint.Endpoint, erro
ttl = endpoint.TTL(*recordSet.Properties.TTL)
}
ep := endpoint.NewEndpointWithTTL(name, recordType, ttl, targets...)
// Extract metadata from Azure RecordSet
if recordSet.Properties != nil && recordSet.Properties.Metadata != nil {
for key, value := range recordSet.Properties.Metadata {
if value != nil {
ep.WithProviderSpecific(providerSpecificMetadataPrefix+key, *value)
}
}
}
log.Debugf(
"Found %s record for '%s' with target '%s'.",
ep.RecordType,
Expand Down Expand Up @@ -346,6 +356,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))
Expand All @@ -358,6 +370,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:
Expand All @@ -371,6 +384,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:
Expand All @@ -380,6 +394,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:
Expand All @@ -395,6 +410,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:
Expand All @@ -408,6 +424,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:
Expand All @@ -421,6 +438,7 @@ func (p *AzureProvider) newRecordSet(endpoint *endpoint.Endpoint) (dns.RecordSet
},
},
},
Metadata: metadata,
},
}, nil
}
Expand Down Expand Up @@ -498,3 +516,20 @@ func extractAzureTargets(recordSet *dns.RecordSet) []string {
}
return []string{}
}

// 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 {
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
}
117 changes: 117 additions & 0 deletions provider/azure/azure_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -612,3 +612,120 @@ func testAzureApplyChangesInternalZoneName(t *testing.T, dryRun bool, client Rec
t.Fatal(err)
}
}

func TestRecordsWithMetadata(t *testing.T) {
// Create a record set with metadata
recordSetWithMetadata := &dns.RecordSet{
Name: to.Ptr("example"),
Type: to.Ptr("Microsoft.Network/dnszones/A"),
Properties: &dns.RecordSetProperties{
TTL: to.Ptr[int64](300),
ARecords: []*dns.ARecord{
{IPv4Address: to.Ptr("1.2.3.4")},
},
Metadata: map[string]*string{
"environment": to.Ptr("production"),
"team": to.Ptr("platform"),
},
},
}

provider, err := newMockedAzureProvider(
endpoint.NewDomainFilter([]string{"example.com"}),
endpoint.NewDomainFilter([]string{}),
provider.NewZoneIDFilter([]string{""}),
false,
"k8s",
"",
"",
[]*dns.Zone{createMockZone("example.com", "/dnszones/example.com")},
[]*dns.RecordSet{recordSetWithMetadata},
3,
)
assert.NoError(t, err)

endpoints, err := provider.Records(context.Background())
assert.NoError(t, err)
assert.Len(t, endpoints, 1)

ep := endpoints[0]
assert.Equal(t, "example.example.com", ep.DNSName)
assert.Equal(t, endpoint.RecordTypeA, ep.RecordType)

// Check metadata was extracted
hasEnv := false
hasTeam := false
for _, ps := range ep.ProviderSpecific {
if ps.Name == "azure-metadata-environment" && ps.Value == "production" {
hasEnv = true
}
if ps.Name == "azure-metadata-team" && ps.Value == "platform" {
hasTeam = true
}
}
assert.True(t, hasEnv, "Expected environment metadata to be present")
assert.True(t, hasTeam, "Expected team metadata to be present")
}

func TestNewRecordSetWithMetadata(t *testing.T) {
provider, err := newMockedAzureProvider(
endpoint.NewDomainFilter([]string{"example.com"}),
endpoint.NewDomainFilter([]string{}),
provider.NewZoneIDFilter([]string{""}),
false,
"k8s",
"",
"",
[]*dns.Zone{createMockZone("example.com", "/dnszones/example.com")},
[]*dns.RecordSet{},
3,
)
assert.NoError(t, err)

t.Run("A record with metadata", func(t *testing.T) {
ep := endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "1.2.3.4")
ep.WithProviderSpecific("azure-metadata-environment", "production")
ep.WithProviderSpecific("azure-metadata-cost-center", "12345")

recordSet, err := provider.newRecordSet(ep)
assert.NoError(t, err)
assert.NotNil(t, recordSet.Properties)
assert.NotNil(t, recordSet.Properties.Metadata)
assert.Len(t, recordSet.Properties.Metadata, 2)
assert.Equal(t, "production", *recordSet.Properties.Metadata["environment"])
assert.Equal(t, "12345", *recordSet.Properties.Metadata["cost-center"])
})

t.Run("CNAME record with metadata", func(t *testing.T) {
ep := endpoint.NewEndpoint("example.com", endpoint.RecordTypeCNAME, "target.com")
ep.WithProviderSpecific("azure-metadata-owner", "team-backend")

recordSet, err := provider.newRecordSet(ep)
assert.NoError(t, err)
assert.NotNil(t, recordSet.Properties)
assert.NotNil(t, recordSet.Properties.Metadata)
assert.Len(t, recordSet.Properties.Metadata, 1)
assert.Equal(t, "team-backend", *recordSet.Properties.Metadata["owner"])
})

t.Run("TXT record with metadata", func(t *testing.T) {
ep := endpoint.NewEndpoint("example.com", endpoint.RecordTypeTXT, "heritage=external-dns")
ep.WithProviderSpecific("azure-metadata-managed-by", "kubernetes")

recordSet, err := provider.newRecordSet(ep)
assert.NoError(t, err)
assert.NotNil(t, recordSet.Properties)
assert.NotNil(t, recordSet.Properties.Metadata)
assert.Len(t, recordSet.Properties.Metadata, 1)
assert.Equal(t, "kubernetes", *recordSet.Properties.Metadata["managed-by"])
})

t.Run("A record without metadata", func(t *testing.T) {
ep := endpoint.NewEndpoint("example.com", endpoint.RecordTypeA, "1.2.3.4")

recordSet, err := provider.newRecordSet(ep)
assert.NoError(t, err)
assert.NotNil(t, recordSet.Properties)
assert.Nil(t, recordSet.Properties.Metadata)
})
}
2 changes: 2 additions & 0 deletions source/annotations/annotations.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ var (
SCWPrefix string
WebhookPrefix string
CloudflarePrefix string
AzurePrefix string

TtlKey string
SetIdentifierKey string
Expand Down Expand Up @@ -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"
Expand Down
5 changes: 5 additions & 0 deletions source/annotations/provider_specific.go
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading