* feat(source): add unstructured source Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com> * feat(source): add unstructured source Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com> * feat(source): add unstructured source Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com> * feat(source): add unstructured source Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com> * feat(source): add unstructured source Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com> * feat(source): add unstructured source Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com> * feat(source): add unstructured source Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com> * feat(source): add unstructured source Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com> * feat(source): add unstructured source Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com> * feat(source): add unstructured source Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com> * feat(source): add unstructured source Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com> * feat(source): add unstructured source Co-authored-by: vflaux <38909103+vflaux@users.noreply.github.com> * feat(source): add unstructured source Co-authored-by: vflaux <38909103+vflaux@users.noreply.github.com> * feat(source): add unstructured source Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com> * Update docs/sources/unstructured.md Co-authored-by: vflaux <38909103+vflaux@users.noreply.github.com> * feat(source): add unstructured source Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com> * feat(source): add unstructured source Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com> * feat(source): add unstructured source Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com> * feat(source): add unstructured source Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com> * feat(source): add unstructured source Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com> * feat(source): add unstructured source Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com> * feat(source): add unstructured source Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com> * feat(source): add unstructured source Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com> * feat(source): add unstructured source Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com> * feat(source): add unstructured source Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com> * feat(source): add unstructured source * feat(source): add unstructured source Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com> --------- Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com> Co-authored-by: vflaux <38909103+vflaux@users.noreply.github.com>
12 KiB
tags
| tags | |||||
|---|---|---|---|---|---|
|
Unstructured Source
The unstructured source creates DNS records from any Kubernetes resource using Go templates.
It works with custom resources (CRDs) without requiring typed Go clients.
Use Cases
Use this source when:
- Your CRD is not supported by a built-in external-dns source
- The resource exposes DNS-relevant data (hostnames, IPs, endpoints) in
.specor.status - A built-in source exists but only supports an older API version than you're using
- You want to experiment with custom controllers or meshes but keep external-dns
- Create DNS entries based on any attribute of any Kubernetes object
- When controller users Labels or Annotations with Json or flat key-value pairs
- Crossplane managed resources (RDS, ElastiCache, S3, etc.)
- Support Endpoints or any other native resources
- Use ConfigMaps as a lightweight DNS registry without needing custom CRDs
- Allows the community to support new CRDs via configuration rather than code changes
Note
: Prefer built-in sources when available (e.g.,
istio-virtualservice,gateway-httproute) as they provide optimized handling for those resource types.
Advanced Use Cases
The unstructured source can also be used with:
Knative Service - Serverless workloads expose auto-generated URLs in .status.url
status:
url: https://hello.default.example.com
Argo Rollouts - Canary/blue-green deployments with preview services in .status.canary.stableRS
status:
canary:
stableRS: my-app-stable-abc123
Linkerd ServiceProfile - Service mesh with destination overrides in .spec.dstOverrides
spec:
dstOverrides:
- authority: webapp.default.svc.cluster.local
Crossplane Composition outputs - Any Crossplane-managed cloud resource (ElastiCache, S3 websites, CloudFront, etc.)
status:
atProvider:
configurationEndpoint:
address: my-cache.abc123.cache.amazonaws.com
Cilium BGP PeeringPolicy - BGP-advertised IPs for LoadBalancer services
status:
conditions:
- type: Established
status: "True"
ACK FieldExport - AWS Controllers for Kubernetes can export resource status (RDS endpoints, S3 bucket URLs) to ConfigMaps via FieldExport, enabling dynamic DNS records
# FieldExport copies S3 bucket URL to ConfigMap
apiVersion: services.k8s.aws/v1alpha1
kind: FieldExport
spec:
from:
path: ".status.location"
resource:
group: s3.services.k8s.aws
kind: Bucket
name: my-bucket
to:
kind: configmap
name: bucket-dns
Configuration
| Flag | Description |
|---|---|
--unstructured-resource |
Resources to watch in resource.version.group format (repeatable) |
--fqdn-template |
Go template for DNS names |
--target-template |
Go template for DNS targets |
--fqdn-target-template |
Go template returning host:target pairs |
--label-filter |
Filter resources by labels |
--annotation-filter |
Filter resources by annotations |
--combine-fqdn-annotation |
Combine FQDN template and Annotations instead of overwriting |
Template Syntax
Templates have access to typed-style fields and raw object data:
| Field | Description |
|---|---|
.Name |
Object name |
.Namespace |
Object namespace |
.Kind |
Object kind |
.APIVersion |
API version |
.Labels |
Object labels |
.Annotations |
Object annotations |
.Metadata |
Raw metadata section |
.Spec |
Raw spec section |
.Status |
Raw status section |
.Object |
Raw full object |
Examples
ConfigMap DNS Registry
Use ConfigMaps as a lightweight DNS registry without needing custom CRDs. Useful for GitOps workflows where teams manage DNS entries via ConfigMaps in their namespaces.
apiVersion: v1
kind: ConfigMap
metadata:
name: api-dns
namespace: production
labels:
external-dns.alpha.kubernetes.io/dns-controller: "dns-controller"
data:
hostname: api.example.com
target: 10.0.0.100
external-dns \
--source=unstructured \
--unstructured-resource=configmaps.v1 \
--fqdn-template='{{index .Object.data "hostname"}}' \
--target-template='{{index .Object.data "target"}}' \
--label-filter='external-dns.alpha.kubernetes.io/controller=dns-controller'
# Result:
# api.example.com -> 10.0.0.100 (A)
Crossplane RDS Instance
external-dns \
--source=unstructured \
--unstructured-resource=rdsinstances.v1alpha1.rds.aws.crossplane.io \
--fqdn-template='{{.Name}}.db.example.com' \
--target-template='{{.Status.atProvider.endpoint.address}}'
Multiple Resources
external-dns \
--source=unstructured \
--unstructured-resource=virtualmachineinstances.v1.kubevirt.io \
--unstructured-resource=rdsinstances.v1alpha1.rds.aws.crossplane.io \
--fqdn-template='{{.Name}}.{{.Kind}}.example.com' \
--target-template='{{.Status.endpoint}}'
MetalLB IPAddressPool
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: production-pool
namespace: metallb-system
annotations:
external-dns.alpha.kubernetes.io/hostname: "lb.example.com"
spec:
addresses:
- 192.168.10.11/32
external-dns \
--source=unstructured \
--unstructured-resource=ipaddresspools.v1beta1.metallb.io \
--fqdn-template='{{index .Annotations "external-dns.alpha.kubernetes.io/hostname"}}' \
--target-template='{{$addr := index .Spec.addresses 0}}{{if contains $addr "/32"}}{{trimSuffix $addr "/32"}}{{else}}{{$addr}}{{end}}'
# Result:
# lb.example.com -> 192.168.10.11 (A)
Tip
: Use
containswithtrimSuffixto extract the IP from/32CIDR notation.
Apache APISIX Route
apiVersion: apisix.apache.org/v2
kind: ApisixRoute
metadata:
name: httpbin
namespace: ingress-apisix
spec:
http:
- name: httpbin
match:
hosts:
- httpbin.example.com
paths:
- /ip
backends:
- serviceName: httpbin
servicePort: 80
status:
apisix:
gateway: apisix-gateway.ingress-apisix.svc.cluster.local
external-dns \
--source=unstructured \
--unstructured-resource=apisixroutes.v2.apisix.apache.org \
--fqdn-template='{{.Name}}.route.example.com' \
--target-template='{{.Status.apisix.gateway}}'
# Result:
# httpbin.route.example.com -> apisix-gateway.ingress-apisix.svc.cluster.local (CNAME)
cert-manager Certificate
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: my-app-tls
namespace: production
annotations:
external-dns.alpha.kubernetes.io/target: "10.0.0.50"
spec:
secretName: my-app-tls-secret
dnsNames:
- my-app.example.com
- www.my-app.example.com
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
external-dns \
--source=unstructured \
--unstructured-resource=certificates.v1.cert-manager.io \
--fqdn-template='{{index .Spec.dnsNames 0}}' \
--target-template='{{index .Annotations "external-dns.alpha.kubernetes.io/target"}}'
# Result:
# my-app.example.com -> 10.0.0.50 (A)
Rancher Node
apiVersion: management.cattle.io/v3
kind: Node
metadata:
name: my-node-1
namespace: cattle-system
labels:
cattle.io/creator: norman
node-role.kubernetes.io/controlplane: "true"
spec:
clusterName: c-abcde
hostname: my-node-1
status:
nodeName: worker-01
internalNodeStatus:
addresses:
- type: ExternalIP
address: 203.0.113.10
external-dns \
--source=unstructured \
--unstructured-resource=nodes.v3.management.cattle.io \
--fqdn-template='{{.Spec.hostname}}.nodes.example.com' \
--target-template='{{(index .Status.internalNodeStatus.addresses 0).address}}' \
--label-filter='node-role.kubernetes.io/controlplane=true'
# Result:
# my-node-1.nodes.example.com -> 203.0.113.10 (A)
ACK FieldExport with ConfigMap
Use AWS Controllers for Kubernetes (ACK) to dynamically populate ConfigMaps with resource endpoints. FieldExport copies values from ACK-managed resources (RDS, S3, ElastiCache) to ConfigMaps, which external-dns can then use for DNS records.
# 1. ACK creates an S3 bucket
apiVersion: s3.services.k8s.aws/v1alpha1
kind: Bucket
metadata:
name: app-assets
namespace: default
spec:
name: my-app-assets-bucket
---
# 2. FieldExport copies the bucket URL to a ConfigMap
apiVersion: services.k8s.aws/v1alpha1
kind: FieldExport
metadata:
name: export-bucket-url
namespace: default
spec:
from:
path: ".status.location"
resource:
group: s3.services.k8s.aws
kind: Bucket
name: app-assets
to:
kind: configmap
name: app-assets-dns
namespace: default
---
# 3. ConfigMap is populated by FieldExport
apiVersion: v1
kind: ConfigMap
metadata:
name: app-assets-dns
namespace: default
labels:
app.kubernetes.io/managed-by: ack-fieldexport
data:
default.export-bucket-url: "https://my-app-assets-bucket.s3.amazonaws.com/"
external-dns \
--source=unstructured \
--unstructured-resource=configmaps.v1 \
--fqdn-template='{{if eq .Kind "ConfigMap"}}{{.Name}}.cdn.example.com{{end}}' \
--target-template='{{if eq .Kind "ConfigMap"}}{{$url := index .Object.data "default.export-bucket-url"}}{{trimSuffix (trimPrefix $url "https://") "/"}}{{end}}' \
--label-filter='app.kubernetes.io/managed-by=ack-fieldexport'
# Result:
# app-assets-dns.cdn.example.com -> my-app-assets-bucket.s3.amazonaws.com (CNAME)
EndpointSlice for Headless Services
Create per-pod DNS records from EndpointSlice resources for headless services. Each pod gets its own DNS entry pointing to its IP address.
apiVersion: discovery.k8s.io/v1
kind: EndpointSlice
metadata:
name: test-abc12
namespace: default
labels:
endpointslice.kubernetes.io/managed-by: endpointslice-controller.k8s.io
kubernetes.io/service-name: test-headless
service.kubernetes.io/headless: ""
addressType: IPv4
endpoints:
- addresses:
- 10.244.1.2
conditions:
ready: true
nodeName: worker1
targetRef:
kind: Pod
name: app-abc12
namespace: default
- addresses:
- 10.244.2.3
- 10.244.2.4
conditions:
ready: true
nodeName: worker2
targetRef:
kind: Pod
name: app-def34
namespace: default
ports:
- name: http
port: 80
protocol: TCP
external-dns \
--source=unstructured \
--unstructured-resource=endpointslices.v1.discovery.k8s.io \
--fqdn-target-template='{{if and (eq .Kind "EndpointSlice") (hasKey .Labels "service.kubernetes.io/headless")}}{{range $ep := .Object.endpoints}}{{if $ep.conditions.ready}}{{range $ep.addresses}}{{$ep.targetRef.name}}.pod.com:{{.}},{{end}}{{end}}{{end}}{{end}}' \
--fqdn-target-template='{{if and (eq .Kind "EndpointSlice") (hasKey .Labels "service.kubernetes.io/headless")}}{{$svcName := index .Labels "kubernetes.io/service-name"}}{{range $ep :=.Object.endpoints}}{{if $ep.conditions.ready}}{{range $ep.addresses}}{{$svcName}}.example.com:{{.}},{{end}}{{end}}{{end}}{{end}}'
# Result:
# app-abc12.pod.com -> 10.244.1.2 (A)
# app-def34.pod.com -> 10.244.2.3, 10.244.2.4 (A)
# test-abc12.example.com -> 10.244.1.2, 10.244.2.3, 10.244.2.4 (A)
The --fqdn-target-template flag returns host:target pairs, enabling 1:1 mapping between hostnames and targets. Useful when a Kubernetes resource contains arrays where each element should produce its own DNS record (e.g., EndpointSlice endpoints, multi-host configurations).
RBAC
Grant external-dns access to your custom resources:
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: external-dns
rules:
# Add for each resource type
- apiGroups: ["rds.aws.crossplane.io"]
resources: ["rdsinstances"]
verbs: ["get", "watch", "list"]
- apiGroups: ["<your-api-group>"]
resources: ["<your-resources>"]
verbs: ["get", "watch", "list"]