Skip to content

Commit f08289d

Browse files
authored
feat: additional template functions (#3949)
* additional template functions * lint * fix typeo * status check fixes * lint fix try 2 * lint fix try 3 * tests for individual functions
1 parent 5eb0c9f commit f08289d

File tree

4 files changed

+198
-0
lines changed

4 files changed

+198
-0
lines changed

docs/advanced/fqdn-templating.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ The template uses the following data from the source object (e.g., a `Service` o
6868
| Function | Description |
6969
|:-------------|:-----------------------------------------------------------------------------------------|
7070
| `trimPrefix` | Function from the `strings` package. Returns `string` without the provided leading prefix. |
71+
| `replace` | Function that performs a simple replacement of all `old` string with `new` in the source string. |
72+
| `isIPv4` | Function that checks if a string is a valid IPv4 address. |
73+
| `isIPv6` | Function that checks if a string is a valid IPv6 address (including IPv4-mapped IPv6). |
7174

7275
---
7376

@@ -304,3 +307,12 @@ args:
304307
```
305308
306309
By setting the hostname annotation in the ingress resource, ExternalDNS constructs the FQDN accordingly. This approach allows for dynamic DNS entries without hardcoding hostnames.
310+
311+
### Using a Node's Addresses for FQDNs
312+
313+
```yml
314+
args:
315+
- --fqdn-template="{{range .Status.Addresses}}{{if and (eq .Type \"ExternalIP\") (isIPv4 .Address)}}{{.Address | replace \".\" \"-\"}}{{break}}{{end}}{{end}}.example.com
316+
```
317+
318+
This is a complex template that iternates through a list of a Node's Addresses and creates a FQDN with public IPv4 addresses.

source/fqdn/fqdn.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ limitations under the License.
1717
package fqdn
1818

1919
import (
20+
"net/netip"
2021
"strings"
2122
"text/template"
2223
)
@@ -27,6 +28,34 @@ func ParseTemplate(fqdnTemplate string) (tmpl *template.Template, err error) {
2728
}
2829
funcs := template.FuncMap{
2930
"trimPrefix": strings.TrimPrefix,
31+
"replace": replace,
32+
"isIPv6": isIPv6String,
33+
"isIPv4": isIPv4String,
3034
}
3135
return template.New("endpoint").Funcs(funcs).Parse(fqdnTemplate)
3236
}
37+
38+
// replace all instances of oldValue with newValue in target string.
39+
// adheres to syntax from https://masterminds.github.io/sprig/strings.html.
40+
func replace(oldValue, newValue, target string) string {
41+
return strings.ReplaceAll(target, oldValue, newValue)
42+
}
43+
44+
// isIPv6String reports whether the target string is an IPv6 address,
45+
// including IPv4-mapped IPv6 addresses.
46+
func isIPv6String(target string) bool {
47+
netIP, err := netip.ParseAddr(target)
48+
if err != nil {
49+
return false
50+
}
51+
return netIP.Is6()
52+
}
53+
54+
// isIPv4String reports whether the target string is an IPv4 address.
55+
func isIPv4String(target string) bool {
56+
netIP, err := netip.ParseAddr(target)
57+
if err != nil {
58+
return false
59+
}
60+
return netIP.Is4()
61+
}

source/fqdn/fqdn_test.go

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,31 @@ func TestParseTemplate(t *testing.T) {
6060
expectError: false,
6161
annotationFilter: "kubernetes.io/ingress.class=nginx",
6262
},
63+
{
64+
name: "replace template function",
65+
expectError: false,
66+
fqdnTemplate: "{{\"hello.world\" | replace \".\" \"-\"}}.ext-dns.test.com",
67+
},
68+
{
69+
name: "isIPv4 template function with valid IPv4",
70+
expectError: false,
71+
fqdnTemplate: "{{if isIPv4 \"192.168.1.1\"}}valid{{else}}invalid{{end}}.ext-dns.test.com",
72+
},
73+
{
74+
name: "isIPv4 template function with invalid IPv4",
75+
expectError: false,
76+
fqdnTemplate: "{{if isIPv4 \"not.an.ip.addr\"}}valid{{else}}invalid{{end}}.ext-dns.test.com",
77+
},
78+
{
79+
name: "isIPv6 template function with valid IPv6",
80+
expectError: false,
81+
fqdnTemplate: "{{if isIPv6 \"2001:db8::1\"}}valid{{else}}invalid{{end}}.ext-dns.test.com",
82+
},
83+
{
84+
name: "isIPv6 template function with invalid IPv6",
85+
expectError: false,
86+
fqdnTemplate: "{{if isIPv6 \"not:ipv6:addr\"}}valid{{else}}invalid{{end}}.ext-dns.test.com",
87+
},
6388
} {
6489
t.Run(tt.name, func(t *testing.T) {
6590
_, err := ParseTemplate(tt.fqdnTemplate)
@@ -71,3 +96,130 @@ func TestParseTemplate(t *testing.T) {
7196
})
7297
}
7398
}
99+
100+
func TestReplace(t *testing.T) {
101+
for _, tt := range []struct {
102+
name string
103+
oldValue string
104+
newValue string
105+
target string
106+
expected string
107+
}{
108+
{
109+
name: "simple replacement",
110+
oldValue: "old",
111+
newValue: "new",
112+
target: "old-value",
113+
expected: "new-value",
114+
},
115+
{
116+
name: "multiple replacements",
117+
oldValue: ".",
118+
newValue: "-",
119+
target: "hello.world.com",
120+
expected: "hello-world-com",
121+
},
122+
{
123+
name: "no replacement needed",
124+
oldValue: "x",
125+
newValue: "y",
126+
target: "hello-world",
127+
expected: "hello-world",
128+
},
129+
{
130+
name: "empty strings",
131+
oldValue: "",
132+
newValue: "",
133+
target: "test",
134+
expected: "test",
135+
},
136+
} {
137+
t.Run(tt.name, func(t *testing.T) {
138+
result := replace(tt.oldValue, tt.newValue, tt.target)
139+
assert.Equal(t, tt.expected, result)
140+
})
141+
}
142+
}
143+
144+
func TestIsIPv6String(t *testing.T) {
145+
for _, tt := range []struct {
146+
name string
147+
input string
148+
expected bool
149+
}{
150+
{
151+
name: "valid IPv6",
152+
input: "2001:db8::1",
153+
expected: true,
154+
},
155+
{
156+
name: "valid IPv6 with multiple segments",
157+
input: "2001:0db8:85a3:0000:0000:8a2e:0370:7334",
158+
expected: true,
159+
},
160+
{
161+
name: "valid IPv4-mapped IPv6",
162+
input: "::ffff:192.168.1.1",
163+
expected: true,
164+
},
165+
{
166+
name: "invalid IPv6",
167+
input: "not:ipv6:addr",
168+
expected: false,
169+
},
170+
{
171+
name: "IPv4 address",
172+
input: "192.168.1.1",
173+
expected: false,
174+
},
175+
{
176+
name: "empty string",
177+
input: "",
178+
expected: false,
179+
},
180+
} {
181+
t.Run(tt.name, func(t *testing.T) {
182+
result := isIPv6String(tt.input)
183+
assert.Equal(t, tt.expected, result)
184+
})
185+
}
186+
}
187+
188+
func TestIsIPv4String(t *testing.T) {
189+
for _, tt := range []struct {
190+
name string
191+
input string
192+
expected bool
193+
}{
194+
{
195+
name: "valid IPv4",
196+
input: "192.168.1.1",
197+
expected: true,
198+
},
199+
{
200+
name: "invalid IPv4",
201+
input: "256.256.256.256",
202+
expected: false,
203+
},
204+
{
205+
name: "IPv6 address",
206+
input: "2001:db8::1",
207+
expected: false,
208+
},
209+
{
210+
name: "invalid format",
211+
input: "not.an.ip",
212+
expected: false,
213+
},
214+
{
215+
name: "empty string",
216+
input: "",
217+
expected: false,
218+
},
219+
} {
220+
t.Run(tt.name, func(t *testing.T) {
221+
result := isIPv4String(tt.input)
222+
assert.Equal(t, tt.expected, result)
223+
})
224+
}
225+
}

source/node_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,11 @@ func testNodeSourceNewNodeSource(t *testing.T) {
6666
expectError: false,
6767
fqdnTemplate: "{{.Name}}-{{.Namespace}}.ext-dns.test.com",
6868
},
69+
{
70+
title: "complex template",
71+
expectError: false,
72+
fqdnTemplate: "{{range .Status.Addresses}}{{if and (eq .Type \"ExternalIP\") (isIPv4 .Address)}}{{.Address | replace \".\" \"-\"}}{{break}}{{end}}{{end}}.ext-dns.test.com",
73+
},
6974
{
7075
title: "non-empty annotation filter label",
7176
expectError: false,

0 commit comments

Comments
 (0)