From 67655bd90bd232afdfa442d822bd693b6f9ad1a6 Mon Sep 17 00:00:00 2001 From: ivan katliarchuk Date: Sat, 7 Feb 2026 17:21:20 +0000 Subject: [PATCH 1/4] chore(fqdn): no duplicates, added extra functions Signed-off-by: ivan katliarchuk --- source/fqdn/fqdn.go | 33 +++++++++- source/fqdn/fqdn_test.go | 139 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 166 insertions(+), 6 deletions(-) diff --git a/source/fqdn/fqdn.go b/source/fqdn/fqdn.go index 2e82a79058..f036664167 100644 --- a/source/fqdn/fqdn.go +++ b/source/fqdn/fqdn.go @@ -18,9 +18,12 @@ package fqdn import ( "bytes" + "encoding/json" "fmt" + "maps" "net/netip" "reflect" + "slices" "strings" "text/template" @@ -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) } @@ -54,6 +59,10 @@ type kubeObject interface { metav1.Object } +// ExecTemplate executes the given template against a Kubernetes object and returns +// a list of hostnames. It handles objects with missing TypeMeta by inferring the +// Kind from the scheme or via reflection. Returns an error if obj is nil or +// template execution fails. func ExecTemplate(tmpl *template.Template, obj kubeObject) ([]string, error) { if obj == nil { return nil, fmt.Errorf("object is nil") @@ -80,15 +89,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. @@ -116,6 +125,24 @@ 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 +} + +// toJson converts a Go value to a JSON string representation. +// Returns an empty string if marshaling fails. +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. // diff --git a/source/fqdn/fqdn_test.go b/source/fqdn/fqdn_test.go index d66f9b1e3d..b1f1437b18 100644 --- a/source/fqdn/fqdn_test.go +++ b/source/fqdn/fqdn_test.go @@ -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" @@ -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", @@ -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", @@ -243,7 +244,7 @@ func TestExecTemplate(t *testing.T) { Name: "test", }, }, - want: []string{}, + want: nil, }, { name: "ignore trailing comma output", @@ -255,6 +256,46 @@ 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: "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"}, + }, } for _, tt := range tests { @@ -487,6 +528,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 From 580c32550193e29bae658aad8ea6da0dd840fe86 Mon Sep 17 00:00:00 2001 From: ivan katliarchuk Date: Sat, 7 Feb 2026 17:28:28 +0000 Subject: [PATCH 2/4] chore(fqdn): no duplicates, added extra functions Signed-off-by: ivan katliarchuk --- source/fqdn/fqdn.go | 9 +++++++-- source/fqdn/fqdn_test.go | 15 +++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/source/fqdn/fqdn.go b/source/fqdn/fqdn.go index f036664167..19356954de 100644 --- a/source/fqdn/fqdn.go +++ b/source/fqdn/fqdn.go @@ -135,8 +135,13 @@ func hasKey(m map[string]string, key string) bool { return ok } -// toJson converts a Go value to a JSON string representation. -// Returns an empty string if marshaling fails. +// 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) diff --git a/source/fqdn/fqdn_test.go b/source/fqdn/fqdn_test.go index b1f1437b18..a5b9627752 100644 --- a/source/fqdn/fqdn_test.go +++ b/source/fqdn/fqdn_test.go @@ -282,6 +282,21 @@ func TestExecTemplate(t *testing.T) { }, 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 }}`, From c32bad75b76ed1910db3c6e9ca859831c8335630 Mon Sep 17 00:00:00 2001 From: ivan katliarchuk Date: Fri, 13 Feb 2026 00:26:27 +0000 Subject: [PATCH 3/4] chore(fqdn): no duplicates, added extra functions Signed-off-by: ivan katliarchuk --- source/fqdn/fqdn_test.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/source/fqdn/fqdn_test.go b/source/fqdn/fqdn_test.go index a5b9627752..6a380e5822 100644 --- a/source/fqdn/fqdn_test.go +++ b/source/fqdn/fqdn_test.go @@ -311,6 +311,25 @@ func TestExecTemplate(t *testing.T) { }, 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 { From 4ba5f2a9b0fd922da916a1cd0bab2905e0421603 Mon Sep 17 00:00:00 2001 From: ivan katliarchuk Date: Fri, 13 Feb 2026 08:16:43 +0000 Subject: [PATCH 4/4] chore(fqdn): no duplicates, added extra functions Signed-off-by: ivan katliarchuk --- source/fqdn/fqdn.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/source/fqdn/fqdn.go b/source/fqdn/fqdn.go index 19356954de..571b352f7a 100644 --- a/source/fqdn/fqdn.go +++ b/source/fqdn/fqdn.go @@ -59,10 +59,8 @@ type kubeObject interface { metav1.Object } -// ExecTemplate executes the given template against a Kubernetes object and returns -// a list of hostnames. It handles objects with missing TypeMeta by inferring the -// Kind from the scheme or via reflection. Returns an error if obj is nil or -// template execution fails. +// 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")