--- 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` ```yaml status: url: https://hello.default.example.com ``` **Argo Rollouts** - Canary/blue-green deployments with preview services in `.status.canary.stableRS` ```yaml status: canary: stableRS: my-app-stable-abc123 ``` **Linkerd ServiceProfile** - Service mesh with destination overrides in `.spec.dstOverrides` ```yaml spec: dstOverrides: - authority: webapp.default.svc.cluster.local ``` **Crossplane Composition outputs** - Any Crossplane-managed cloud resource (ElastiCache, S3 websites, CloudFront, etc.) ```yaml status: atProvider: configurationEndpoint: address: my-cache.abc123.cache.amazonaws.com ``` **Cilium BGP PeeringPolicy** - BGP-advertised IPs for LoadBalancer services ```yaml 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 ```yaml # 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. ```yaml 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 ``` ```bash 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 ```bash 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 ```bash 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 ```yaml 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 ``` ```bash 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 ```yaml 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 ``` ```bash 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 ```yaml 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 ``` ```bash 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 ```yaml 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 ``` ```bash 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. ```yaml # 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/" ``` ```bash 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. ```yaml 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 ``` ```bash 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: ```yaml 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: [""] resources: [""] verbs: ["get", "watch", "list"] ```