Skip to content

Commit 0489f54

Browse files
feat(source): add unstructured source
Signed-off-by: ivan katliarchuk <ivan.katliarchuk@gmail.com>
1 parent e8bb3d8 commit 0489f54

File tree

4 files changed

+323
-152
lines changed

4 files changed

+323
-152
lines changed

docs/sources/unstructured.md

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ Use this source when:
1414

1515
Example CRDs:
1616

17-
- KubeVirt VirtualMachineInstances
17+
- Use ConfigMaps as a lightweight DNS registry without needing custom CRDs
1818
- Crossplane managed resources (RDS, ElastiCache, S3, etc.)
19+
- KubeVirt VirtualMachineInstances
1920
- ArgoCD Applications
2021

2122
> **Note**: Prefer built-in sources when available (e.g., `istio-virtualservice`, `gateway-httproute`) as they provide optimized handling for those resource types.
@@ -65,6 +66,24 @@ status:
6566
status: "True"
6667
```
6768

69+
**ACK FieldExport** - AWS Controllers for Kubernetes can export resource status (RDS endpoints, S3 bucket URLs) to ConfigMaps via FieldExport, enabling dynamic DNS records
70+
71+
```yaml
72+
# FieldExport copies S3 bucket URL to ConfigMap
73+
apiVersion: services.k8s.aws/v1alpha1
74+
kind: FieldExport
75+
spec:
76+
from:
77+
path: ".status.location"
78+
resource:
79+
group: s3.services.k8s.aws
80+
kind: Bucket
81+
name: my-bucket
82+
to:
83+
kind: configmap
84+
name: bucket-dns
85+
```
86+
6887
## Configuration
6988

7089
| Flag | Description |
@@ -93,6 +112,35 @@ Templates have access to typed-style fields and raw object data:
93112

94113
## Examples
95114

115+
### ConfigMap DNS Registry
116+
117+
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.
118+
119+
```yaml
120+
apiVersion: v1
121+
kind: ConfigMap
122+
metadata:
123+
name: api-dns
124+
namespace: production
125+
labels:
126+
external-dns.alpha.kubernetes.io/dns-controller: "dns-controller"
127+
data:
128+
hostname: api.example.com
129+
target: 10.0.0.100
130+
```
131+
132+
```bash
133+
external-dns \
134+
--source=unstructured \
135+
--unstructured-fqdn-resource=configmaps.v1 \
136+
--fqdn-template='{{index .Object.data "hostname"}}' \
137+
--fqdn-target-template='{{index .Object.data "target"}}' \
138+
--label-filter='external-dns.alpha.kubernetes.io/controller=dns-controller'
139+
140+
# Result:
141+
# api.example.com -> 10.0.0.100 (A)
142+
```
143+
96144
### Crossplane RDS Instance
97145

98146
```bash
@@ -240,6 +288,62 @@ external-dns \
240288
# my-node-1.nodes.example.com -> 203.0.113.10 (A)
241289
```
242290

291+
### ACK FieldExport with ConfigMap
292+
293+
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.
294+
295+
```yaml
296+
# 1. ACK creates an S3 bucket
297+
apiVersion: s3.services.k8s.aws/v1alpha1
298+
kind: Bucket
299+
metadata:
300+
name: app-assets
301+
namespace: default
302+
spec:
303+
name: my-app-assets-bucket
304+
---
305+
# 2. FieldExport copies the bucket URL to a ConfigMap
306+
apiVersion: services.k8s.aws/v1alpha1
307+
kind: FieldExport
308+
metadata:
309+
name: export-bucket-url
310+
namespace: default
311+
spec:
312+
from:
313+
path: ".status.location"
314+
resource:
315+
group: s3.services.k8s.aws
316+
kind: Bucket
317+
name: app-assets
318+
to:
319+
kind: configmap
320+
name: app-assets-dns
321+
namespace: default
322+
---
323+
# 3. ConfigMap is populated by FieldExport
324+
apiVersion: v1
325+
kind: ConfigMap
326+
metadata:
327+
name: app-assets-dns
328+
namespace: default
329+
labels:
330+
app.kubernetes.io/managed-by: ack-fieldexport
331+
data:
332+
default.export-bucket-url: "https://my-app-assets-bucket.s3.amazonaws.com/"
333+
```
334+
335+
```bash
336+
external-dns \
337+
--source=unstructured \
338+
--unstructured-fqdn-resource=configmaps.v1 \
339+
--fqdn-template='{{if eq .Kind "ConfigMap"}}{{.Name}}.cdn.example.com{{end}}' \
340+
--fqdn-target-template='{{if eq .Kind "ConfigMap"}}{{$url := index .Object.data "default.export-bucket-url"}}{{trimSuffix (trimPrefix $url "https://") "/"}}{{end}}' \
341+
--label-filter='app.kubernetes.io/managed-by=ack-fieldexport'
342+
343+
# Result:
344+
# app-assets-dns.cdn.example.com -> my-app-assets-bucket.s3.amazonaws.com (CNAME)
345+
```
346+
243347
## RBAC
244348

245349
Grant external-dns access to your custom resources:

source/unstructured.go

Lines changed: 48 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ import (
2222
"strings"
2323
"text/template"
2424

25-
log "github.com/sirupsen/logrus"
2625
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2726
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
2827
"k8s.io/apimachinery/pkg/labels"
@@ -59,7 +58,7 @@ type unstructuredSource struct {
5958
combineFqdnAnnotation bool
6059
fqdnTemplate *template.Template
6160
targetFqdnTemplate *template.Template
62-
resources []resourceConfig
61+
informers []kubeinformers.GenericInformer
6362
}
6463

6564
// NewUnstructuredFQDNSource creates a new unstructuredSource.
@@ -83,21 +82,9 @@ func NewUnstructuredFQDNSource(
8382
return nil, err
8483
}
8584

86-
// Discover and validate all resources using cached discovery client
87-
cachedDiscovery := memory.NewMemCacheClient(kubeClient.Discovery())
88-
resourceConfigs := make([]resourceConfig, 0, len(resources))
89-
for _, r := range resources {
90-
gvr, err := parseResourceIdentifier(r)
91-
if err != nil {
92-
return nil, err
93-
}
94-
95-
rc, err := discoverResource(cachedDiscovery, gvr)
96-
if err != nil {
97-
return nil, err
98-
}
99-
100-
resourceConfigs = append(resourceConfigs, *rc)
85+
gvrs, err := discoverResources(kubeClient, resources)
86+
if err != nil {
87+
return nil, err
10188
}
10289

10390
// Create a single informer factory for all resources
@@ -109,11 +96,12 @@ func NewUnstructuredFQDNSource(
10996
)
11097

11198
// Create informers for each resource
112-
for i := range resourceConfigs {
113-
resourceConfigs[i].informer = informerFactory.ForResource(resourceConfigs[i].gvr)
99+
resourceInformers := make([]kubeinformers.GenericInformer, 0, len(gvrs))
100+
for _, gvr := range gvrs {
101+
informer := informerFactory.ForResource(gvr)
114102

115103
// Add indexers for efficient lookups by namespace and labels (must be before AddEventHandler)
116-
err := resourceConfigs[i].informer.Informer().AddIndexers(
104+
err := informer.Informer().AddIndexers(
117105
informers.IndexerWithOptions[*unstructured.Unstructured](
118106
informers.IndexSelectorWithAnnotationFilter(annotationFilter),
119107
informers.IndexSelectorWithLabelSelector(labelSelector),
@@ -123,7 +111,8 @@ func NewUnstructuredFQDNSource(
123111
return nil, err
124112
}
125113

126-
_, _ = resourceConfigs[i].informer.Informer().AddEventHandler(informers.DefaultEventHandler())
114+
_, _ = informer.Informer().AddEventHandler(informers.DefaultEventHandler())
115+
resourceInformers = append(resourceInformers, informer)
127116
}
128117

129118
informerFactory.Start(ctx.Done())
@@ -137,17 +126,17 @@ func NewUnstructuredFQDNSource(
137126
labelSelector: labelSelector,
138127
fqdnTemplate: fqdnTmpl,
139128
targetFqdnTemplate: targetTmpl,
140-
resources: resourceConfigs,
129+
informers: resourceInformers,
141130
combineFqdnAnnotation: combineFqdnAnnotation,
142131
}, nil
143132
}
144133

145134
// Endpoints returns the list of endpoints from unstructured resources.
146-
func (us *unstructuredSource) Endpoints(ctx context.Context) ([]*endpoint.Endpoint, error) {
135+
func (us *unstructuredSource) Endpoints(_ context.Context) ([]*endpoint.Endpoint, error) {
147136
var endpoints []*endpoint.Endpoint
148137

149-
for _, rc := range us.resources {
150-
resourceEndpoints, err := us.endpointsForResource(ctx, rc)
138+
for _, informer := range us.informers {
139+
resourceEndpoints, err := us.endpointsFromInformer(informer)
151140
if err != nil {
152141
return nil, err
153142
}
@@ -157,17 +146,16 @@ func (us *unstructuredSource) Endpoints(ctx context.Context) ([]*endpoint.Endpoi
157146
return endpoints, nil
158147
}
159148

160-
// endpointsForResource returns endpoints for a single resource type.
161-
func (us *unstructuredSource) endpointsForResource(_ context.Context, rc resourceConfig) ([]*endpoint.Endpoint, error) {
149+
// endpointsFromInformer returns endpoints for a single resource type.
150+
func (us *unstructuredSource) endpointsFromInformer(informer kubeinformers.GenericInformer) ([]*endpoint.Endpoint, error) {
162151
var endpoints []*endpoint.Endpoint
163152

164153
// Get objects that match the indexer filter (annotation and label selectors)
165-
indexKeys := rc.informer.Informer().GetIndexer().ListIndexFuncValues(informers.IndexWithSelectors)
154+
indexKeys := informer.Informer().GetIndexer().ListIndexFuncValues(informers.IndexWithSelectors)
166155

167156
for _, key := range indexKeys {
168-
obj, err := informers.GetByKey[*unstructured.Unstructured](rc.informer.Informer().GetIndexer(), key)
157+
obj, err := informers.GetByKey[*unstructured.Unstructured](informer.Informer().GetIndexer(), key)
169158
if err != nil {
170-
log.Debugf("failed to get object by key %q: %v", key, err)
171159
continue
172160
}
173161

@@ -240,17 +228,11 @@ func (us *unstructuredSource) endpointsFromTemplate(el *unstructuredWrapper) ([]
240228

241229
// AddEventHandler adds an event handler that is called when resources change.
242230
func (us *unstructuredSource) AddEventHandler(_ context.Context, handler func()) {
243-
for _, rc := range us.resources {
244-
_, _ = rc.informer.Informer().AddEventHandler(eventHandlerFunc(handler))
231+
for _, informer := range us.informers {
232+
_, _ = informer.Informer().AddEventHandler(eventHandlerFunc(handler))
245233
}
246234
}
247235

248-
// resourceConfig holds the parsed configuration for a single resource type.
249-
type resourceConfig struct {
250-
gvr schema.GroupVersionResource
251-
informer kubeinformers.GenericInformer
252-
}
253-
254236
// unstructuredWrapper wraps an unstructured.Unstructured to provide both
255237
// typed-style template access ({{ .Name }}, {{ .Namespace }}) and raw map access
256238
// ({{ .Spec.field }}, {{ index .Status.interfaces 0 "ipAddress" }}).
@@ -312,66 +294,48 @@ func newUnstructuredWrapper(obj runtime.Object) *unstructuredWrapper {
312294
return w
313295
}
314296

315-
// parseResourceIdentifier parses a resource identifier in the format "resource.version.group"
316-
// (e.g., "virtualmachineinstances.v1.kubevirt.io") and returns a GroupVersionResource.
317-
//
318-
// Format: resource.version.group
319-
// - resource: plural resource name (e.g., "vmachines")
320-
// - version: API version (e.g., "v1", "v1beta1")
321-
// - group: API group (e.g., "kubevirt.io", "apps")
322-
//
323-
// For core API resources (e.g., pods.v1), the group is empty.
324-
func parseResourceIdentifier(identifier string) (schema.GroupVersionResource, error) {
325-
parts := strings.SplitN(identifier, ".", 3)
326-
if len(parts) < 2 {
327-
return schema.GroupVersionResource{}, fmt.Errorf("invalid resource identifier %q: expected format resource.version.group (e.g., virtualmachineinstances.v1.kubevirt.io)", identifier)
328-
}
297+
// discoverResources parses and validates resource identifiers against the cluster.
298+
// It uses a cached discovery client to minimize API calls.
299+
func discoverResources(kubeClient kubernetes.Interface, resources []string) ([]schema.GroupVersionResource, error) {
300+
cachedDiscovery := memory.NewMemCacheClient(kubeClient.Discovery())
301+
gvrs := make([]schema.GroupVersionResource, 0, len(resources))
329302

330-
resource := parts[0]
331-
version := parts[1]
332-
group := ""
333-
if len(parts) == 3 {
334-
group = parts[2]
335-
}
303+
for _, r := range resources {
304+
// Handle core API resources (e.g., "configmaps.v1" -> "configmaps.v1.")
305+
if strings.Count(r, ".") == 1 {
306+
r += "."
307+
}
336308

337-
if resource == "" {
338-
return schema.GroupVersionResource{}, fmt.Errorf("invalid resource identifier %q: resource name cannot be empty", identifier)
339-
}
340-
if version == "" {
341-
return schema.GroupVersionResource{}, fmt.Errorf("invalid resource identifier %q: version cannot be empty", identifier)
309+
gvr, _ := schema.ParseResourceArg(r)
310+
if gvr == nil {
311+
return nil, fmt.Errorf("invalid resource identifier %q: expected format resource.version.group (e.g., certificates.v1.cert-manager.io)", r)
312+
}
313+
314+
if err := validateResource(cachedDiscovery, *gvr); err != nil {
315+
return nil, err
316+
}
317+
318+
gvrs = append(gvrs, *gvr)
342319
}
343320

344-
return schema.GroupVersionResource{
345-
Group: group,
346-
Version: version,
347-
Resource: resource,
348-
}, nil
321+
return gvrs, nil
349322
}
350323

351-
// discoverResource validates that a resource exists in the cluster and returns its configuration.
352-
// It uses the Discovery API to verify the resource and determine if it's namespaced.
353-
func discoverResource(discoveryClient discovery.DiscoveryInterface, gvr schema.GroupVersionResource) (*resourceConfig, error) {
324+
// validateResource validates that a resource exists in the cluster.
325+
// It uses the Discovery API to verify the resource is available.
326+
func validateResource(discoveryClient discovery.DiscoveryInterface, gvr schema.GroupVersionResource) error {
354327
gv := gvr.GroupVersion().String()
355328

356329
apiResourceList, err := discoveryClient.ServerResourcesForGroupVersion(gv)
357330
if err != nil {
358-
return nil, fmt.Errorf("failed to discover resources for %q: %w", gv, err)
331+
return fmt.Errorf("failed to discover resources for %q: %w", gv, err)
359332
}
360333

361-
var apiResource *metav1.APIResource
362334
for i := range apiResourceList.APIResources {
363-
ar := &apiResourceList.APIResources[i]
364-
if ar.Name == gvr.Resource {
365-
apiResource = ar
366-
break
335+
if apiResourceList.APIResources[i].Name == gvr.Resource {
336+
return nil
367337
}
368338
}
369339

370-
if apiResource == nil {
371-
return nil, fmt.Errorf("resource %q not found in %q", gvr.Resource, gv)
372-
}
373-
374-
return &resourceConfig{
375-
gvr: gvr,
376-
}, nil
340+
return fmt.Errorf("resource %q not found in %q", gvr.Resource, gv)
377341
}

0 commit comments

Comments
 (0)