external-dns/source/unstructured.go
Ivan Ka d217706973
refactor(fqdn): encapsulate FQDN template logic into TemplateEngine (#6292)
* refactor(source): extract FQDN template logic into fqdn.TemplateEngine

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* refactor(source): extract FQDN template logic into fqdn.TemplateEngine

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* refactor(source): extract FQDN template logic into fqdn.TemplateEngine

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* refactor(source): extract FQDN template logic into fqdn.TemplateEngine

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* refactor(source): extract FQDN template logic into fqdn.TemplateEngine

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* refactor(source): extract FQDN template logic into fqdn.TemplateEngine

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* refactor(source): extract FQDN template logic into fqdn.TemplateEngine

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* refactor(source): extract FQDN template logic into fqdn.TemplateEngine

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* refactor(source): extract FQDN template logic into fqdn.TemplateEngine

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* refactor(source): extract FQDN template logic into fqdn.TemplateEngine

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* refactor(source): extract FQDN template logic into fqdn.TemplateEngine

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* refactor(source): extract FQDN template logic into fqdn.TemplateEngine

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* refactor(fqdn): encapsulate FQDN template logic into TemplateEngine

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* refactor(fqdn): encapsulate FQDN template logic into TemplateEngine

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* efactor(fqdn): encapsulate FQDN template logic into TemplateEngine

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* refactor(fqdn): encapsulate FQDN template logic into TemplateEngine

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* refactor(fqdn): encapsulate FQDN template logic into TemplateEngine

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* refactor(fqdn): encapsulate FQDN template logic into TemplateEngine

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* refactor(fqdn): encapsulate FQDN template logic into TemplateEngine

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* refactor(fqdn): encapsulate FQDN template logic into TemplateEngine

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* refactor(fqdn): encapsulate FQDN template logic into TemplateEngine

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

* refactor(fqdn): encapsulate FQDN template logic into TemplateEngine

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>

---------

Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>
2026-03-23 19:40:19 +05:30

388 lines
12 KiB
Go

/*
Copyright 2026 The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package source
import (
"context"
"fmt"
"maps"
"slices"
"strings"
log "github.com/sirupsen/logrus"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/discovery"
"k8s.io/client-go/discovery/cached/memory"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/dynamic/dynamicinformer"
kubeinformers "k8s.io/client-go/informers"
"k8s.io/client-go/kubernetes"
"sigs.k8s.io/external-dns/endpoint"
"sigs.k8s.io/external-dns/pkg/events"
"sigs.k8s.io/external-dns/source/annotations"
"sigs.k8s.io/external-dns/source/informers"
"sigs.k8s.io/external-dns/source/template"
"sigs.k8s.io/external-dns/source/types"
)
// unstructuredSource is a Source that creates DNS records from unstructured resources.
//
// +externaldns:source:name=unstructured
// +externaldns:source:category=Custom Resources
// +externaldns:source:description=Creates DNS entries from unstructured Kubernetes resources
// +externaldns:source:resources=Unstructured
// +externaldns:source:filters=annotation,label
// +externaldns:source:namespace=all,single
// +externaldns:source:fqdn-template=true
// +externaldns:source:provider-specific=false
// +externaldns:source:events=false
type unstructuredSource struct {
templateEngine template.Engine
informers []kubeinformers.GenericInformer
}
// NewUnstructuredFQDNSource creates a new unstructuredSource.
func NewUnstructuredFQDNSource(
ctx context.Context,
dynamicClient dynamic.Interface,
kubeClient kubernetes.Interface,
cfg *Config,
) (Source, error) {
gvrs, err := discoverResources(kubeClient, cfg.UnstructuredResources)
if err != nil {
return nil, err
}
// Create a single informer factory for all resources
informerFactory := dynamicinformer.NewFilteredDynamicSharedInformerFactory(
dynamicClient,
0,
cfg.Namespace,
nil,
)
// Create informers for each resource
resourceInformers := make([]kubeinformers.GenericInformer, 0, len(gvrs))
for _, gvr := range gvrs {
informer := informerFactory.ForResource(gvr)
// Add indexers for efficient lookups by namespace and labels (must be before AddEventHandler)
informers.MustAddIndexers(informer.Informer(), informers.IndexerWithOptions[*unstructured.Unstructured](
informers.IndexSelectorWithAnnotationFilter(cfg.AnnotationFilter),
informers.IndexSelectorWithLabelSelector(cfg.LabelFilter),
))
informers.MustSetTransform(informer.Informer(), informers.TransformerWithOptions[*unstructured.Unstructured](
informers.TransformRemoveManagedFields(),
informers.TransformRemoveLastAppliedConfig(),
))
informers.MustAddEventHandler(informer.Informer(), informers.DefaultEventHandler())
resourceInformers = append(resourceInformers, informer)
}
informerFactory.Start(ctx.Done())
if err := informers.WaitForDynamicCacheSync(ctx, informerFactory); err != nil {
return nil, err
}
return &unstructuredSource{
templateEngine: cfg.TemplateEngine,
informers: resourceInformers,
}, nil
}
// Endpoints returns the list of endpoints from unstructured resources.
func (us *unstructuredSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, error) {
var endpoints []*endpoint.Endpoint
for _, informer := range us.informers {
resourceEndpoints, err := us.endpointsFromInformer(informer)
if err != nil {
return nil, err
}
endpoints = append(endpoints, resourceEndpoints...)
}
return endpoints, nil
}
// endpointsFromInformer returns endpoints for a single resource type.
func (us *unstructuredSource) endpointsFromInformer(informer kubeinformers.GenericInformer) ([]*endpoint.Endpoint, error) {
var endpoints []*endpoint.Endpoint
// Get objects that match the indexer filter (annotation and label selectors)
indexKeys := informer.Informer().GetIndexer().ListIndexFuncValues(informers.IndexWithSelectors)
if len(indexKeys) == 0 {
return nil, nil
}
for _, key := range indexKeys {
obj, err := informers.GetByKey[*unstructured.Unstructured](informer.Informer().GetIndexer(), key)
if err != nil {
continue
}
el := newUnstructuredWrapper(obj)
if annotations.IsControllerMismatch(el, types.Unstructured) {
continue
}
hosts := annotations.HostnamesFromAnnotations(el.GetAnnotations())
addrs := annotations.TargetsFromTargetAnnotation(el.GetAnnotations())
annotationEdps := EndpointsForHostsAndTargets(hosts, addrs)
fqdnTargetEdps, err := us.templateEngine.CombineWithEndpoints(
annotationEdps,
func() ([]*endpoint.Endpoint, error) {
return us.endpointsFromFQDNTargetTemplate(el)
},
)
if err != nil {
return nil, err
}
edps, err := us.templateEngine.CombineWithEndpoints(
fqdnTargetEdps,
func() ([]*endpoint.Endpoint, error) {
return us.endpointsFromTemplate(el)
},
)
if err != nil {
return nil, err
}
ttl := annotations.TTLFromAnnotations(el.GetAnnotations(),
fmt.Sprintf("%s/%s", strings.ToLower(el.GetKind()), el.GetName()))
for _, ep := range edps {
ep.
WithRefObject(events.NewObjectReference(el, types.Unstructured)).
WithLabel(endpoint.ResourceLabelKey,
fmt.Sprintf("%s/%s/%s", strings.ToLower(el.GetKind()), el.GetNamespace(), el.GetName())).
WithMinTTL(int64(ttl))
endpoints = append(endpoints, ep)
}
}
return MergeEndpoints(endpoints), nil
}
// endpointsFromTemplate creates endpoints using DNS names from the FQDN template.
func (us *unstructuredSource) endpointsFromTemplate(el *unstructuredWrapper) ([]*endpoint.Endpoint, error) {
hostnames, err := us.templateEngine.ExecFQDN(el)
if err != nil {
return nil, err
}
if len(hostnames) == 0 {
return nil, nil
}
targets, err := us.templateEngine.ExecTarget(el)
if err != nil {
return nil, err
}
return EndpointsForHostsAndTargets(hostnames, targets), nil
}
// endpointsFromFQDNTargetTemplate creates endpoints from a template that returns host:target pairs.
// Each pair creates a single endpoint with 1:1 mapping between host and target.
func (us *unstructuredSource) endpointsFromFQDNTargetTemplate(el *unstructuredWrapper) ([]*endpoint.Endpoint, error) {
pairs, err := us.templateEngine.ExecFQDNTarget(el)
if err != nil {
return nil, err
}
if len(pairs) == 0 {
return nil, nil
}
endpoints := make([]*endpoint.Endpoint, 0, len(pairs))
for _, pair := range pairs {
// Split at first colon (hostnames can't contain colons, IPv6 targets can)
parts := strings.SplitN(pair, ":", 2)
if len(parts) != 2 {
log.Debugf("Skipping invalid host:target pair %q from %s %s/%s: missing ':' separator",
pair, strings.ToLower(el.GetKind()), el.GetNamespace(), el.GetName())
continue
}
host := strings.TrimSpace(parts[0])
target := strings.TrimSpace(parts[1])
if host == "" || target == "" {
log.Debugf("Skipping incomplete host:target pair %q from %s %s/%s: field may not yet be populated",
pair, strings.ToLower(el.GetKind()), el.GetNamespace(), el.GetName())
continue
}
endpoints = append(endpoints, endpoint.NewEndpoint(host, endpoint.SuitableType(target), target))
}
return MergeEndpoints(endpoints), nil
}
// AddEventHandler adds an event handler that is called when resources change.
func (us *unstructuredSource) AddEventHandler(_ context.Context, handler func()) {
for _, informer := range us.informers {
informers.MustAddEventHandler(informer.Informer(), eventHandlerFunc(handler))
}
}
// unstructuredWrapper wraps an unstructured.Unstructured to provide both
// typed-style template access ({{ .Name }}, {{ .Namespace }}) and raw map access
// ({{ .Spec.field }}, {{ index .Status.interfaces 0 "ipAddress" }}).
// By embedding *unstructured.Unstructured, it implements kubeObject (runtime.Object + metav1.Object).
type unstructuredWrapper struct {
*unstructured.Unstructured
// Typed-style convenience fields (like typed Kubernetes objects)
Name string
Namespace string
Kind string
APIVersion string
Labels map[string]string
Annotations map[string]string
// Raw map sections for custom field access
Metadata map[string]any
Spec map[string]any
Status map[string]any
}
func (u *unstructuredWrapper) GetObjectMeta() metav1.Object {
return u.Unstructured
}
// newUnstructuredWrapper creates a wrapper around an *unstructured.Unstructured,
// exposing typed convenience fields for templateEngine alongside raw map sections.
func newUnstructuredWrapper(u *unstructured.Unstructured) *unstructuredWrapper {
w := &unstructuredWrapper{
Unstructured: u,
Name: u.GetName(),
Namespace: u.GetNamespace(),
Kind: u.GetKind(),
APIVersion: u.GetAPIVersion(),
Labels: u.GetLabels(),
Annotations: u.GetAnnotations(),
}
// Extract common sections
if metadata, ok := u.Object["metadata"].(map[string]any); ok {
w.Metadata = metadata
}
if spec, ok := u.Object["spec"].(map[string]any); ok {
w.Spec = spec
}
if status, ok := u.Object["status"].(map[string]any); ok {
w.Status = status
}
return w
}
// discoverResources parses and validates resource identifiers against the cluster.
// It uses a cached discovery client to minimize API calls.
func discoverResources(kubeClient kubernetes.Interface, resources []string) ([]schema.GroupVersionResource, error) {
cachedDiscovery := memory.NewMemCacheClient(kubeClient.Discovery())
gvrs := make([]schema.GroupVersionResource, 0, len(resources))
for _, r := range resources {
// Handle core API resources (e.g., "configmaps.v1" -> "configmaps.v1.")
if strings.Count(r, ".") == 1 {
r += "."
}
gvr, _ := schema.ParseResourceArg(r)
if gvr == nil {
return nil, fmt.Errorf("invalid resource identifier %q: expected format resource.version.group (e.g., certificates.v1.cert-manager.io)", r)
}
if err := validateResource(cachedDiscovery, *gvr); err != nil {
return nil, err
}
gvrs = append(gvrs, *gvr)
}
return gvrs, nil
}
// validateResource validates that a resource exists in the cluster.
// It uses the Discovery API to verify the resource is available.
func validateResource(discoveryClient discovery.DiscoveryInterface, gvr schema.GroupVersionResource) error {
gv := gvr.GroupVersion().String()
apiResourceList, err := discoveryClient.ServerResourcesForGroupVersion(gv)
if err != nil {
return fmt.Errorf("failed to discover resources for %q: %w", gv, err)
}
for i := range apiResourceList.APIResources {
if apiResourceList.APIResources[i].Name == gvr.Resource {
return nil
}
}
return fmt.Errorf("resource %q not found in %q", gvr.Resource, gv)
}
// EndpointsForHostsAndTargets creates endpoints by grouping targets by record type
// and creating an endpoint for each hostname/record-type combination.
// The function returns endpoints in deterministic order (sorted by record type).
func EndpointsForHostsAndTargets(hostnames, targets []string) []*endpoint.Endpoint {
if len(hostnames) == 0 || len(targets) == 0 {
return nil
}
// Deduplicate hostnames
hostSet := make(map[string]struct{}, len(hostnames))
for _, h := range hostnames {
hostSet[h] = struct{}{}
}
sortedHosts := slices.Sorted(maps.Keys(hostSet))
// Group and deduplicate targets by record type
targetsByType := make(map[string]map[string]struct{})
for _, target := range targets {
recordType := endpoint.SuitableType(target)
if targetsByType[recordType] == nil {
targetsByType[recordType] = make(map[string]struct{})
}
targetsByType[recordType][target] = struct{}{}
}
// Resolve to sorted slices once
sortedTypes := slices.Sorted(maps.Keys(targetsByType))
sortedTargets := make(map[string][]string, len(targetsByType))
for _, recordType := range sortedTypes {
sortedTargets[recordType] = slices.Sorted(maps.Keys(targetsByType[recordType]))
}
endpoints := make([]*endpoint.Endpoint, 0, len(sortedHosts)*len(sortedTypes))
for _, hostname := range sortedHosts {
for _, recordType := range sortedTypes {
endpoints = append(endpoints, endpoint.NewEndpoint(hostname, recordType, sortedTargets[recordType]...))
}
}
return endpoints
}