Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 33 additions & 3 deletions source/fqdn/fqdn.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,12 @@ package fqdn

import (
"bytes"
"encoding/json"
"fmt"
"maps"
"net/netip"
"reflect"
"slices"
"strings"
"text/template"

Expand All @@ -45,6 +48,8 @@ func ParseTemplate(input string) (*template.Template, error) {
"replace": replace,
"isIPv6": isIPv6String,
"isIPv4": isIPv4String,
"hasKey": hasKey,
"fromJson": fromJson,
}
return template.New("endpoint").Funcs(funcs).Parse(input)
}
Expand All @@ -54,6 +59,8 @@ type kubeObject interface {
metav1.Object
}

// ExecTemplate executes a template against a Kubernetes object and returns hostnames.
// It infers Kind if TypeMeta is missing. Returns error if obj is nil or execution fails.
func ExecTemplate(tmpl *template.Template, obj kubeObject) ([]string, error) {
if obj == nil {
return nil, fmt.Errorf("object is nil")
Expand All @@ -80,15 +87,15 @@ func ExecTemplate(tmpl *template.Template, obj kubeObject) ([]string, error) {
return nil, fmt.Errorf("failed to apply template on %s %s/%s: %w", kind, obj.GetNamespace(), obj.GetName(), err)
}
hosts := strings.Split(buf.String(), ",")
hostnames := make([]string, 0, len(hosts))
hostnames := make(map[string]struct{}, len(hosts))
for _, name := range hosts {
name = strings.TrimSpace(name)
name = strings.TrimSuffix(name, ".")
if name != "" {
hostnames = append(hostnames, name)
hostnames[name] = struct{}{}
}
}
return hostnames, nil
return slices.Sorted(maps.Keys(hostnames)), nil
}

// replace all instances of oldValue with newValue in target string.
Expand Down Expand Up @@ -116,6 +123,29 @@ func isIPv4String(target string) bool {
return netIP.Is4()
}

// hasKey checks if a key exists in a map. This is needed because Go templates'
// `index` function returns the zero value ("") for missing keys, which is
// indistinguishable from keys with empty values. Kubernetes uses empty-value
// labels for markers (e.g., `service.kubernetes.io/headless: ""`), so we need
// explicit key existence checking.
func hasKey(m map[string]string, key string) bool {
_, ok := m[key]
return ok
}

// fromJson decodes a JSON string into a Go value (map, slice, etc.).
// This enables templates to work with structured data stored as JSON strings
// in complex labels or annotations or Configmap data fields, e.g. ranging over a list of entries:
//
// {{ range $entry := (index .Data "entries" | fromJson) }}{{ index $entry "dns" }},{{ end }}
//
// Returns nil if the input is not valid JSON.
func fromJson(v string) any {
var output any
_ = json.Unmarshal([]byte(v), &output)
return output
}

// CombineWithTemplatedEndpoints merges annotation-based endpoints with template-based endpoints
// according to the FQDN template configuration.
//
Expand Down
173 changes: 170 additions & 3 deletions source/fqdn/fqdn_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"

Expand Down Expand Up @@ -121,7 +122,7 @@ func TestExecTemplate(t *testing.T) {
Namespace: "default",
},
},
want: []string{"test.example.com", "default.example.org"},
want: []string{"default.example.org", "test.example.com"},
},
{
name: "multiple hostnames",
Expand Down Expand Up @@ -200,7 +201,7 @@ func TestExecTemplate(t *testing.T) {
},
},
},
want: []string{"production.example.com", "internal.production.company.org"},
want: []string{"internal.production.company.org", "production.example.com"},
},
{
name: "labels to lowercase",
Expand Down Expand Up @@ -243,7 +244,7 @@ func TestExecTemplate(t *testing.T) {
Name: "test",
},
},
want: []string{},
want: nil,
},
{
name: "ignore trailing comma output",
Expand All @@ -255,6 +256,80 @@ func TestExecTemplate(t *testing.T) {
},
want: []string{"test.example.com"},
},
{
name: "contains label with empty value",
tmpl: `{{if hasKey .Labels "service.kubernetes.io/headless"}}{{ .Name }}.example.com,{{end}}`,
obj: &testObject{
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Labels: map[string]string{
"service.kubernetes.io/headless": "",
},
},
},
want: []string{"test.example.com"},
},
{
name: "result only contains unique values",
tmpl: `{{ .Name }}.example.com,{{ .Name }}.example.com,{{ .Name }}.example.com`,
obj: &testObject{
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Labels: map[string]string{
"service.kubernetes.io/headless": "",
},
},
},
want: []string{"test.example.com"},
},
{
name: "dns entries in labels",
tmpl: `
{{ if hasKey .Labels "records" }}{{ range $entry := (index .Labels "records" | fromJson) }}{{ index $entry "dns" }},{{ end }}{{ end }}`,
obj: &testObject{
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Labels: map[string]string{
"records": `
[{"dns":"entry1.internal.tld","target":"10.10.10.10"},{"dns":"entry2.example.tld","target":"my.cluster.local"}]`,
},
},
},
want: []string{"entry1.internal.tld", "entry2.example.tld"},
},
{
name: "configmap with multiple entries",
tmpl: `{{ range $entry := (index .Data "entries" | fromJson) }}{{ index $entry "dns" }},{{ end }}`,
obj: &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "test-configmap",
},
Data: map[string]string{
"entries": `
[{"dns":"entry1.internal.tld","target":"10.10.10.10"},{"dns":"entry2.example.tld","target":"my.cluster.local"}]`,
},
},
want: []string{"entry1.internal.tld", "entry2.example.tld"},
},
{
name: "rancher publicEndpoints annotation",
tmpl: `
{{ range $entry := (index .Annotations "field.cattle.io/publicEndpoints" | fromJson) }}{{ index $entry "hostname" }},{{ end }}`,
obj: &testObject{
ObjectMeta: metav1.ObjectMeta{
Name: "test",
Annotations: map[string]string{
"field.cattle.io/publicEndpoints": `
[{"addresses":[""],"port":80,"protocol":"HTTP",
"serviceName":"development:keycloak-ha-service",
"ingressName":"development:keycloak-ha-ingress",
"hostname":"keycloak.snip.com","allNodes":false
}]`,
},
},
},
want: []string{"keycloak.snip.com"},
},
}

for _, tt := range tests {
Expand Down Expand Up @@ -487,6 +562,98 @@ func TestIsIPv4String(t *testing.T) {
}
}

func TestHasKey(t *testing.T) {
for _, tt := range []struct {
name string
m map[string]string
key string
expected bool
}{
{
name: "key exists with non-empty value",
m: map[string]string{"foo": "bar"},
key: "foo",
expected: true,
},
{
name: "key exists with empty value",
m: map[string]string{"service.kubernetes.io/headless": ""},
key: "service.kubernetes.io/headless",
expected: true,
},
{
name: "key does not exist",
m: map[string]string{"foo": "bar"},
key: "baz",
expected: false,
},
{
name: "nil map",
m: nil,
key: "foo",
expected: false,
},
{
name: "empty map",
m: map[string]string{},
key: "foo",
expected: false,
},
} {
t.Run(tt.name, func(t *testing.T) {
result := hasKey(tt.m, tt.key)
assert.Equal(t, tt.expected, result)
})
}
}

func TestFromJson(t *testing.T) {
for _, tt := range []struct {
name string
input string
expected any
}{
{
name: "map of strings",
input: `{"dns":"entry1.internal.tld","target":"10.10.10.10"}`,
expected: map[string]any{"dns": "entry1.internal.tld", "target": "10.10.10.10"},
},
{
name: "slice of maps",
input: `[{"dns":"entry1.internal.tld","target":"10.10.10.10"},{"dns":"entry2.example.tld","target":"my.cluster.local"}]`,
expected: []any{
map[string]any{"dns": "entry1.internal.tld", "target": "10.10.10.10"},
map[string]any{"dns": "entry2.example.tld", "target": "my.cluster.local"},
},
},
{
name: "null input",
input: "null",
expected: nil,
},
{
name: "empty object",
input: "{}",
expected: map[string]any{},
},
{
name: "string value",
input: `"hello"`,
expected: "hello",
},
{
name: "invalid json",
input: "not valid json",
expected: nil,
},
} {
t.Run(tt.name, func(t *testing.T) {
result := fromJson(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}

type testObject struct {
metav1.TypeMeta
metav1.ObjectMeta
Expand Down
Loading