external-dns/docs/sources/unstructured.md
Ivan Ka c35ed0b82a
feat(source): add unstructured source (#6172)
* 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>
2026-03-12 05:23:33 +05:30

12 KiB

tags
tags
source
area/fqdn
area/source
templating
unstructured

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 .spec or .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 contains with trimSuffix to extract the IP from /32 CIDR 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"]