mirror of
https://github.com/kubernetes-sigs/external-dns.git
synced 2026-04-15 13:01:03 +02:00
* 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>
388 lines
12 KiB
Go
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
|
|
}
|