Skip to content

Commit b5f13e7

Browse files
committed
[FEAT] Domain(s): Custom DNSMode added
Enable flexible DNS management by introducing a new `dnsMode: Custom`. Allow consumers to use Go templates via `spec.dnsTemplates` to specify custom DNS records. Resolves: #216
1 parent abab16d commit b5f13e7

31 files changed

+1254
-375
lines changed

crds/sme.sap.com_clusterdomains.yaml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,24 @@ spec:
4949
- None
5050
- Wildcard
5151
- Subdomain
52+
- Custom
5253
type: string
5354
dnsTarget:
5455
pattern: ^[a-z0-9-.]+$
5556
type: string
57+
dnsTemplates:
58+
items:
59+
properties:
60+
name:
61+
type: string
62+
target:
63+
type: string
64+
required:
65+
- name
66+
- target
67+
type: object
68+
maxItems: 10
69+
type: array
5670
domain:
5771
pattern: ^[a-z0-9-.]+$
5872
type: string

crds/sme.sap.com_domains.yaml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,24 @@ spec:
4949
- None
5050
- Wildcard
5151
- Subdomain
52+
- Custom
5253
type: string
5354
dnsTarget:
5455
pattern: ^[a-z0-9-.]+$
5556
type: string
57+
dnsTemplates:
58+
items:
59+
properties:
60+
name:
61+
type: string
62+
target:
63+
type: string
64+
required:
65+
- name
66+
- target
67+
type: object
68+
maxItems: 10
69+
type: array
5670
domain:
5771
pattern: ^[a-z0-9-.]+$
5872
type: string

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ require (
1010
github.com/gardener/external-dns-management v0.26.0
1111
github.com/go-logr/logr v1.4.3
1212
github.com/go-logr/zapr v1.3.0
13+
github.com/go-task/slim-sprig/v3 v3.0.0
1314
github.com/golang-jwt/jwt/v5 v5.3.0
1415
github.com/google/go-cmp v0.7.0
1516
github.com/google/uuid v1.6.0

internal/controller/dns-manager.go

Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
/*
2+
SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and cap-operator contributors
3+
SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package controller
7+
8+
import (
9+
"context"
10+
"fmt"
11+
"slices"
12+
"strings"
13+
"text/template"
14+
15+
dnsv1alpha1 "github.com/gardener/external-dns-management/pkg/apis/dns/v1alpha1"
16+
sprig "github.com/go-task/slim-sprig/v3"
17+
"github.com/sap/cap-operator/pkg/apis/sme.sap.com/v1alpha1"
18+
corev1 "k8s.io/api/core/v1"
19+
"k8s.io/apimachinery/pkg/api/errors"
20+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
21+
"k8s.io/apimachinery/pkg/labels"
22+
"k8s.io/apimachinery/pkg/runtime"
23+
"k8s.io/apimachinery/pkg/selection"
24+
)
25+
26+
const (
27+
DomainEventSubdomainAlreadyInUse = "SubdomainAlreadyInUse"
28+
LabelDomainHostHash = "sme.sap.com/domain-host-hash"
29+
subDomainTemplateVar = ".subDomain"
30+
)
31+
32+
var (
33+
cNameLookup = int64(30)
34+
ttl = int64(600)
35+
)
36+
37+
type dnsInfo struct {
38+
name string
39+
target string
40+
appId string
41+
}
42+
43+
func handleDnsEntries[T v1alpha1.DomainEntity](ctx context.Context, c *Controller, dom T, ownerId, subResourceName string, subResourceNamespace string) (err error) {
44+
if dnsManager() != dnsManagerGardener {
45+
// skip dns entry handling if not using gardener dns manager
46+
return nil
47+
}
48+
49+
list, err := c.gardenerDNSClient.DnsV1alpha1().DNSEntries(subResourceNamespace).List(ctx, metav1.ListOptions{
50+
LabelSelector: labels.SelectorFromSet(labels.Set{
51+
LabelOwnerIdentifierHash: sha1Sum(ownerId),
52+
}).String(),
53+
})
54+
if err != nil {
55+
return fmt.Errorf("failed to list dns entries for %s: %w", ownerId, err)
56+
}
57+
58+
overallDNSInfo, err := getDnsInfo(c, dom)
59+
if err != nil {
60+
return err
61+
}
62+
63+
// check and update relevant existing dns entries
64+
aRelevantDNSNameHashes, relevantDNSInfo, err := checkRelevantDNSEntries(ctx, list.Items, overallDNSInfo, dom.GetMetadata().Generation, c)
65+
if err != nil {
66+
return err
67+
}
68+
69+
// delete outdated dns entries
70+
// Add a requirement for OwnerIdentifierHash and SubdomainHash
71+
ownerReq, _ := labels.NewRequirement(LabelOwnerIdentifierHash, selection.Equals, []string{sha1Sum(ownerId)})
72+
// Create label selector based on the above requirement for filtering out all outdated dns entries
73+
deletionSelector := labels.NewSelector().Add(*ownerReq)
74+
if len(aRelevantDNSNameHashes) > 0 {
75+
// Add all known DNSName hash to new requirement
76+
dnsNameReq, _ := labels.NewRequirement(LabelDNSNameHash, selection.NotIn, aRelevantDNSNameHashes)
77+
deletionSelector = deletionSelector.Add(*dnsNameReq)
78+
}
79+
err = c.gardenerDNSClient.DnsV1alpha1().DNSEntries(subResourceNamespace).DeleteCollection(ctx, metav1.DeleteOptions{}, metav1.ListOptions{LabelSelector: deletionSelector.String()})
80+
if err != nil && !errors.IsNotFound(err) {
81+
return err
82+
}
83+
84+
// create new dns entries
85+
for _, info := range relevantDNSInfo {
86+
hash := sha256Sum(info.name, info.target, info.appId)
87+
dnsHash := sha1Sum(info.name)
88+
dnsEntry := &dnsv1alpha1.DNSEntry{
89+
ObjectMeta: metav1.ObjectMeta{
90+
GenerateName: subResourceName + "-",
91+
Namespace: subResourceNamespace,
92+
Labels: map[string]string{
93+
LabelOwnerIdentifierHash: sha1Sum(ownerId),
94+
LabelOwnerGeneration: fmt.Sprintf("%d", dom.GetMetadata().Generation),
95+
LabelBTPApplicationIdentifierHash: info.appId,
96+
LabelDNSNameHash: dnsHash,
97+
},
98+
Annotations: map[string]string{
99+
AnnotationResourceHash: hash,
100+
AnnotationOwnerIdentifier: ownerId,
101+
GardenerDNSClassIdentifier: GardenerDNSClassValue,
102+
},
103+
// Finalizers: []string{FinalizerDomain},
104+
OwnerReferences: []metav1.OwnerReference{
105+
*metav1.NewControllerRef(metav1.Object(dom), v1alpha1.SchemeGroupVersion.WithKind(dom.GetKind())),
106+
},
107+
},
108+
Spec: getDnsEntrySpec(info),
109+
}
110+
_, err = c.gardenerDNSClient.DnsV1alpha1().DNSEntries(subResourceNamespace).Create(ctx, dnsEntry, metav1.CreateOptions{})
111+
if err != nil {
112+
return fmt.Errorf("failed to create dns entry for %s: %w", info.name, err)
113+
}
114+
}
115+
116+
return
117+
118+
}
119+
120+
func checkRelevantDNSEntries(ctx context.Context, dnsEntries []dnsv1alpha1.DNSEntry, overallDNSInfo []*dnsInfo, generation int64, c *Controller) (aRelevantDNSNameHashes []string, relevantDNSInfo []*dnsInfo, err error) {
121+
relevantDNSInfo = slices.Clone(overallDNSInfo)
122+
aRelevantDNSNameHashes = []string{}
123+
for _, entry := range dnsEntries {
124+
index := slices.IndexFunc(relevantDNSInfo, func(d *dnsInfo) bool {
125+
return d.name == entry.Spec.DNSName
126+
})
127+
if index >= 0 {
128+
info := relevantDNSInfo[index]
129+
dnsHash := sha1Sum(info.name)
130+
// update dns entry, if needed
131+
hash := sha256Sum(info.name, info.target, info.appId)
132+
if entry.Annotations[AnnotationResourceHash] != hash {
133+
updateResourceAnnotation(&entry.ObjectMeta, hash)
134+
entry.Labels[LabelOwnerGeneration] = fmt.Sprintf("%d", generation)
135+
entry.Labels[LabelBTPApplicationIdentifierHash] = info.appId
136+
entry.Labels[LabelDNSNameHash] = dnsHash
137+
entry.Spec = getDnsEntrySpec(info)
138+
_, err = c.gardenerDNSClient.DnsV1alpha1().DNSEntries(entry.Namespace).Update(ctx, &entry, metav1.UpdateOptions{})
139+
if err != nil {
140+
return nil, nil, fmt.Errorf("failed to update dns entry %s.%s: %w", entry.Namespace, entry.Name, err)
141+
}
142+
}
143+
// remove the existing entry relevantDNSInfo to avoid creating a new entry, add to relevantDNSNameHashes
144+
relevantDNSInfo = slices.Delete(relevantDNSInfo, index, index+1)
145+
aRelevantDNSNameHashes = append(aRelevantDNSNameHashes, dnsHash)
146+
}
147+
}
148+
return aRelevantDNSNameHashes, relevantDNSInfo, nil
149+
}
150+
151+
func getDnsEntrySpec(info *dnsInfo) dnsv1alpha1.DNSEntrySpec {
152+
return dnsv1alpha1.DNSEntrySpec{
153+
DNSName: info.name,
154+
Targets: []string{info.target},
155+
CNameLookupInterval: &cNameLookup,
156+
TTL: &ttl,
157+
}
158+
}
159+
160+
func getDnsInfo[T v1alpha1.DomainEntity](c *Controller, dom T) (resolvedDNSInfo []*dnsInfo, err error) {
161+
dnsTemplates, subdomainInfo, err := getDNSDetails(dom, c)
162+
if err != nil {
163+
return nil, err
164+
}
165+
166+
domVars := map[string]any{
167+
"domain": dom.GetSpec().Domain,
168+
"dnsTarget": dom.GetStatus().DnsTarget,
169+
}
170+
// Setup template engine with sprig functions
171+
tpl := template.New("dnsTemplate").Funcs(sprig.FuncMap())
172+
173+
resolvedDNSInfo = []*dnsInfo{}
174+
175+
for _, dnsTemplate := range dnsTemplates {
176+
var parsedDnsInfo *dnsInfo
177+
domVars["subDomain"] = ""
178+
if !strings.Contains(dnsTemplate.Name, subDomainTemplateVar) {
179+
parsedDnsInfo, err = parseDNSTemplate(tpl, dnsTemplate, domVars)
180+
if err != nil {
181+
return nil, err
182+
}
183+
}
184+
for subDomain, appId := range subdomainInfo {
185+
domVars["subDomain"] = subDomain
186+
parsedDnsInfo, err = parseDNSTemplate(tpl, dnsTemplate, domVars)
187+
if err != nil {
188+
return nil, err
189+
}
190+
parsedDnsInfo.appId = appId
191+
}
192+
if parsedDnsInfo != nil {
193+
resolvedDNSInfo = append(resolvedDNSInfo, parsedDnsInfo)
194+
}
195+
}
196+
197+
return resolvedDNSInfo, err
198+
}
199+
200+
func getDNSDetails[T v1alpha1.DomainEntity](dom T, c *Controller) (dnsTemplates []v1alpha1.DNSTemplate, subdomainInfo map[string]string, err error) {
201+
dnsTemplates = []v1alpha1.DNSTemplate{}
202+
collectSubdomains := false
203+
204+
switch dom.GetSpec().DNSMode {
205+
case v1alpha1.DnsModeWildcard:
206+
dnsTemplates = append(dnsTemplates, v1alpha1.DNSTemplate{Name: "*.{{.domain}}", Target: "{{.dnsTarget}}"})
207+
case v1alpha1.DnsModeSubdomain:
208+
dnsTemplates = append(dnsTemplates, v1alpha1.DNSTemplate{Name: "{{.subDomain}}.{{.domain}}", Target: "{{.dnsTarget}}"})
209+
// If subdomain is used, we need to collect subdomains from applications
210+
collectSubdomains = true
211+
case v1alpha1.DnsModeCustom:
212+
dnsTemplates = dom.GetSpec().DNSTemplates
213+
// If subdomain is used, we need to collect subdomains from applications
214+
collectSubdomains = slices.ContainsFunc(dnsTemplates, func(t v1alpha1.DNSTemplate) bool {
215+
return strings.Contains(t.Name, subDomainTemplateVar)
216+
})
217+
default: // Default is None
218+
//do nothing here
219+
}
220+
221+
if collectSubdomains {
222+
subdomainInfo, err = collectAppSubdomainInfos(c, dom)
223+
if err != nil {
224+
return nil, nil, fmt.Errorf("failed to collect subdomains from applications: %w", err)
225+
}
226+
}
227+
228+
return dnsTemplates, subdomainInfo, nil
229+
}
230+
231+
func parseDNSTemplate(tpl *template.Template, dnsTemplate v1alpha1.DNSTemplate, domVars map[string]any) (*dnsInfo, error) {
232+
// Parse the DNS templates
233+
parseTemplate := func(templateString string, templateVars map[string]any) (string, error) {
234+
var tmpS strings.Builder
235+
t := template.Must(tpl.Parse(templateString))
236+
err := t.Execute(&tmpS, templateVars)
237+
if err != nil {
238+
return "", fmt.Errorf("failed to parse template %s: %w", templateString, err)
239+
}
240+
return tmpS.String(), nil
241+
}
242+
var dns dnsInfo
243+
244+
// Parse DNS name
245+
res, err := parseTemplate(dnsTemplate.Name, domVars)
246+
if err != nil {
247+
return nil, err
248+
}
249+
dns.name = res
250+
251+
// Parse DNS target
252+
res, err = parseTemplate(dnsTemplate.Target, domVars)
253+
if err != nil {
254+
return nil, err
255+
}
256+
dns.target = res
257+
258+
return &dns, nil
259+
}
260+
261+
func collectAppSubdomainInfos[T v1alpha1.DomainEntity](c *Controller, dom T) (subdomains map[string]string, err error) {
262+
cas, err := getReferencingApplications(c, dom)
263+
if err != nil {
264+
return nil, err
265+
}
266+
267+
subdomains = map[string]string{}
268+
269+
for _, ca := range cas {
270+
if len(ca.Status.ObservedSubdomains) > 0 {
271+
for _, subdomain := range ca.Status.ObservedSubdomains {
272+
if appId, ok := subdomains[subdomain]; !ok {
273+
subdomains[subdomain] = ca.Labels[LabelBTPApplicationIdentifierHash]
274+
} else if appId != ca.Labels[LabelBTPApplicationIdentifierHash] {
275+
// this subdomain is already used by another application
276+
// skip and raise warning event
277+
c.Event(ca, runtime.Object(dom), corev1.EventTypeWarning, DomainEventSubdomainAlreadyInUse, EventActionProcessingDomainResources,
278+
fmt.Sprintf("Subdomain %s is already used by another application with domain %s (%s)", subdomain, formOwnerIdFromDomain(dom), dom.GetSpec().Domain))
279+
}
280+
}
281+
}
282+
}
283+
return subdomains, nil
284+
}
285+
286+
func areDnsEntriesReady(ctx context.Context, c *Controller, ownerId string) (ready bool, err error) {
287+
if dnsManager() != dnsManagerGardener {
288+
// assume ready if not using gardener dns manager
289+
return true, nil
290+
}
291+
292+
// create a label selector to filter dns entries by owner identifier hash
293+
selector := labels.SelectorFromSet(labels.Set{
294+
LabelOwnerIdentifierHash: sha1Sum(ownerId),
295+
})
296+
297+
// list all dns entries which match the the domains (and subdomain, if supplied)
298+
dnsEntries, err := c.gardenerDNSClient.DnsV1alpha1().DNSEntries(corev1.NamespaceAll).List(ctx, metav1.ListOptions{
299+
LabelSelector: selector.String(),
300+
})
301+
if err != nil {
302+
return false, fmt.Errorf("failed to list dns entries: %w", err)
303+
}
304+
305+
// Check all matching dns entries
306+
for _, entry := range dnsEntries.Items {
307+
// check for ready state
308+
if entry.Status.State == dnsv1alpha1.StateError || entry.Status.State == dnsv1alpha1.StateInvalid {
309+
return false, fmt.Errorf("%s in state %s for %s: %s", dnsv1alpha1.DNSEntryKind, entry.Status.State, ownerId, *entry.Status.Message)
310+
} else if entry.Status.State != dnsv1alpha1.STATE_READY {
311+
return false, nil
312+
}
313+
}
314+
315+
return true, nil
316+
}

0 commit comments

Comments
 (0)