diff --git a/docs/advanced/fqdn-templating.md b/docs/advanced/fqdn-templating.md index c6d25be314..c57e9be48f 100644 --- a/docs/advanced/fqdn-templating.md +++ b/docs/advanced/fqdn-templating.md @@ -114,14 +114,22 @@ metadata: ``` ```sh +# Single template external-dns \ --provider=aws \ --source=service \ - --fqdn-template="{{ .Name }}.example.com,{{ .Name }}.{{ .Namespace }}.example.tld" + --fqdn-template="{{ .Name }}.example.com" + +# Multiple templates — specify the flag more than once +external-dns \ + --provider=aws \ + --source=service \ + --fqdn-template="{{ .Name }}.example.com" \ + --fqdn-template="{{ .Name }}.{{ .Namespace }}.example.tld" # This will result in DNS entries like ->route53> my-service.example.com ->route53> my-service.my-namespace.example.tld +# route53> my-service.example.com +# route53> my-service.my-namespace.example.tld ``` ### With Namespace @@ -177,11 +185,16 @@ ExternalDNS allows specifying multiple FQDN templates, which can be useful when > Be cautious, as this will create multiple DNS records per resource, potentially increasing the number of API calls to your DNS provider. +Specify `--fqdn-template` multiple times — one flag per template: + ```yml args: - --fqdn-template={{.Name}}.example.com,{{.Name}}.svc.example.com + - --fqdn-template={{.Name}}.example.com + - --fqdn-template={{.Name}}.svc.example.com ``` +Duplicate templates and leading/trailing whitespace are ignored automatically. + ### Conditional Templating combined with Annotations processing In scenarios where you want to conditionally generate FQDNs based on annotations, you can use Go template functions like or to provide defaults. @@ -416,7 +429,7 @@ This is helpful in scenarios such as: ## Tips -- If `--fqdn-template` is specified, ExternalDNS ignores any `external-dns.kubernetes.io/hostname` annotations. +- If `--fqdn-template` is specified, ExternalDNS ignores any `external-dns.kubernetes.io/hostname` annotations (unless `--combine-fqdn-annotation` is also set). - You must still ensure the resulting FQDN is valid and unique. - Since Go templates can be error-prone, test your template with simple examples before deploying. Mismatched field names or nil values (e.g., missing labels) will result in errors or skipped entries. @@ -424,7 +437,15 @@ This is helpful in scenarios such as: ### Can I specify multiple global FQDN templates? -Yes, you can. Pass in a comma separated list to --fqdn-template. Beware this will double (triple, etc) the amount of DNS entries based on how many services, ingresses and so on you have and will get you faster towards the API request limit of your DNS provider. +Yes. Specify `--fqdn-template` more than once — one flag per template: + +```sh +external-dns \ + --fqdn-template="{{ .Name }}.example.com" \ + --fqdn-template="{{ .Name }}.svc.example.com" +``` + +Beware: this will double (triple, etc.) the number of DNS entries based on how many services, ingresses, and so on you have, and will bring you faster towards the API request limit of your DNS provider. Duplicate templates are deduplicated automatically. ### Where to find template syntax diff --git a/docs/flags.md b/docs/flags.md index d0cb668b79..4b01bc61b2 100644 --- a/docs/flags.md +++ b/docs/flags.md @@ -187,9 +187,9 @@ tags: | `--webhook-provider-write-timeout=10s` | The write timeout for the webhook provider in duration format (default: 10s) | | `--[no-]webhook-server` | When enabled, runs as a webhook server instead of a controller. (default: false). | | `--[no-]combine-fqdn-annotation` | Combine FQDN template and Annotations instead of overwriting (default: false) | -| `--fqdn-template=""` | A templated string that's used to generate DNS names from sources that don't define a hostname themselves, or to add a hostname suffix when paired with the fake source (optional). Accepts comma separated list for multiple global FQDN. | -| `--target-template=""` | A templated string used to generate DNS targets (IP or hostname) from sources that support it (optional). Accepts comma separated list for multiple targets. | -| `--fqdn-target-template=""` | A template that returns host:target pairs (e.g., '{{range .Object.endpoints}}{{.targetRef.name}}.svc.example.com:{{index .addresses 0}},{{end}}'). Accepts comma separated list for multiple pairs. | +| `--fqdn-template=FQDN-TEMPLATE` | A templated string that's used to generate DNS names from sources that don't define a hostname themselves, or to add a hostname suffix when paired with the fake source (optional). Specify multiple times for multiple templates. | +| `--target-template=TARGET-TEMPLATE` | A templated string used to generate DNS targets (IP or hostname) from sources that support it (optional). Specify multiple times for multiple targets. | +| `--fqdn-target-template=FQDN-TARGET-TEMPLATE` | A template that returns host:target pairs (e.g., '{{range .Object.endpoints}}{{.targetRef.name}}.svc.example.com:{{index .addresses 0}},{{end}}'). Specify multiple times for multiple pairs. | | `--kubeconfig=""` | Retrieve target cluster configuration from a Kubernetes configuration file (default: auto-detect) | | `--request-timeout=30s` | [DEPRECATED: use --kube-api-request-timeout] Request timeout when calling Kubernetes APIs. 0s means no timeout | | `--kube-api-request-timeout=30s` | Request timeout when calling Kubernetes APIs. 0s means no timeout | diff --git a/docs/sources/unstructured.md b/docs/sources/unstructured.md index 8d5996950f..4d7f5b45bc 100644 --- a/docs/sources/unstructured.md +++ b/docs/sources/unstructured.md @@ -89,15 +89,15 @@ spec: ## Configuration -| Flag | Description | -|-----------------------------|--------------------------------------------------------------------| -| `--unstructured-resource` | Resources to watch in `resource.version.group` format (repeatable) | -| `--fqdn-template` | Go template for DNS names | -| `--target-template` | Go template for DNS targets | -| `--fqdn-target-template` | Go template returning `host:target` pairs | -| `--label-filter` | Filter resources by labels | -| `--annotation-filter` | Filter resources by annotations | -| `--combine-fqdn-annotation` | Combine FQDN template and Annotations instead of overwriting | +| Flag | Description | +|-----------------------------|-----------------------------------------------------------------------------------| +| `--unstructured-resource` | Resources to watch in `resource.version.group` format (repeatable) | +| `--fqdn-template` | Go template for DNS names (repeatable; comma-separated values within one flag also accepted) | +| `--target-template` | Go template for DNS targets (repeatable; comma-separated values within one flag also accepted) | +| `--fqdn-target-template` | Go template returning `host:target` pairs (repeatable; comma-separated values within one flag also accepted) | +| `--label-filter` | Filter resources by labels | +| `--annotation-filter` | Filter resources by annotations | +| `--combine-fqdn-annotation` | Combine FQDN template and Annotations instead of overwriting | ## Template Syntax @@ -403,10 +403,14 @@ external-dns \ # Result: # app-abc12.pod.com -> 10.244.1.2 (A) # app-def34.pod.com -> 10.244.2.3, 10.244.2.4 (A) -# test-abc12.example.com -> 10.244.1.2, 10.244.2.3, 10.244.2.4 (A) +# test-headless.example.com -> 10.244.1.2, 10.244.2.3, 10.244.2.4 (A) ``` -The `--fqdn-target-template` flag returns `host:target` pairs, enabling 1:1 mapping between hostnames and targets. Useful when a Kubernetes resource contains arrays where each element should produce its own DNS record (e.g., EndpointSlice endpoints, multi-host configurations). +Specifying `--fqdn-target-template` multiple times applies each template independently against the same object and merges all results. This lets you produce per-pod records from one template and a service-level aggregate record from another — in a single ExternalDNS pass. + +The `--fqdn-target-template` flag returns `host:target` pairs, enabling 1:1 mapping between hostnames and targets. +It is most useful when a resource contains arrays where each element produces its own record (e.g., EndpointSlice endpoints). +The same repeatable behaviour applies to `--fqdn-template` and `--target-template`. ## RBAC diff --git a/pkg/apis/externaldns/types.go b/pkg/apis/externaldns/types.go index 2a5586f145..8b6e3d0013 100644 --- a/pkg/apis/externaldns/types.go +++ b/pkg/apis/externaldns/types.go @@ -59,9 +59,9 @@ type Config struct { AnnotationPrefix string LabelFilter string IngressClassNames []string - FQDNTemplate string - TargetTemplate string - FQDNTargetTemplate string + FQDNTemplate []string + TargetTemplate []string + FQDNTargetTemplate []string CombineFQDNAndAnnotation bool IgnoreHostnameAnnotation bool IgnoreNonHostNetworkPods bool @@ -296,9 +296,9 @@ var defaultConfig = &Config{ ExoscaleAPIZone: "ch-gva-2", ExoscaleZoneCacheDuration: 0 * time.Second, ExposeInternalIPV6: false, - FQDNTemplate: "", - TargetTemplate: "", - FQDNTargetTemplate: "", + FQDNTemplate: nil, + TargetTemplate: nil, + FQDNTargetTemplate: nil, GatewayLabelFilter: "", GatewayName: "", GatewayNamespace: "", @@ -736,9 +736,9 @@ func bindFlags(b flags.FlagBinder, cfg *Config) { // FQDN Templating b.BoolVar("combine-fqdn-annotation", "Combine FQDN template and Annotations instead of overwriting (default: false)", false, &cfg.CombineFQDNAndAnnotation) - b.StringVar("fqdn-template", "A templated string that's used to generate DNS names from sources that don't define a hostname themselves, or to add a hostname suffix when paired with the fake source (optional). Accepts comma separated list for multiple global FQDN.", defaultConfig.FQDNTemplate, &cfg.FQDNTemplate) - b.StringVar("target-template", "A templated string used to generate DNS targets (IP or hostname) from sources that support it (optional). Accepts comma separated list for multiple targets.", defaultConfig.TargetTemplate, &cfg.TargetTemplate) - b.StringVar("fqdn-target-template", "A template that returns host:target pairs (e.g., '{{range .Object.endpoints}}{{.targetRef.name}}.svc.example.com:{{index .addresses 0}},{{end}}'). Accepts comma separated list for multiple pairs.", defaultConfig.FQDNTargetTemplate, &cfg.FQDNTargetTemplate) + b.StringsVar("fqdn-template", "A templated string that's used to generate DNS names from sources that don't define a hostname themselves, or to add a hostname suffix when paired with the fake source (optional). Specify multiple times for multiple templates.", defaultConfig.FQDNTemplate, &cfg.FQDNTemplate) + b.StringsVar("target-template", "A templated string used to generate DNS targets (IP or hostname) from sources that support it (optional). Specify multiple times for multiple targets.", defaultConfig.TargetTemplate, &cfg.TargetTemplate) + b.StringsVar("fqdn-target-template", "A template that returns host:target pairs (e.g., '{{range .Object.endpoints}}{{.targetRef.name}}.svc.example.com:{{index .addresses 0}},{{end}}'). Specify multiple times for multiple pairs.", defaultConfig.FQDNTargetTemplate, &cfg.FQDNTargetTemplate) // kube client config flags b.StringVar("kubeconfig", "Retrieve target cluster configuration from a Kubernetes configuration file (default: auto-detect)", defaultConfig.KubeConfig, &cfg.KubeConfig) diff --git a/pkg/apis/externaldns/types_test.go b/pkg/apis/externaldns/types_test.go index fafbf615f5..e99942cf36 100644 --- a/pkg/apis/externaldns/types_test.go +++ b/pkg/apis/externaldns/types_test.go @@ -45,7 +45,7 @@ var ( Sources: []string{"service"}, Namespace: "", AnnotationPrefix: "external-dns.kubernetes.io/", - FQDNTemplate: "", + FQDNTemplate: nil, Compatibility: "", Provider: ProviderGoogle, GoogleProject: "", @@ -155,7 +155,7 @@ var ( IgnoreNonHostNetworkPods: true, IgnoreIngressTLSSpec: true, IgnoreIngressRulesSpec: true, - FQDNTemplate: "{{.Name}}.service.example.com", + FQDNTemplate: []string{"{{.Name}}.service.example.com"}, Compatibility: "mate", Provider: ProviderGoogle, GoogleProject: "project", diff --git a/pkg/apis/externaldns/validation/validation.go b/pkg/apis/externaldns/validation/validation.go index e9c2aec613..a1a124e542 100644 --- a/pkg/apis/externaldns/validation/validation.go +++ b/pkg/apis/externaldns/validation/validation.go @@ -39,7 +39,7 @@ func ValidateConfig(cfg *externaldns.Config) error { return err } - if cfg.IgnoreHostnameAnnotation && cfg.FQDNTemplate == "" { + if cfg.IgnoreHostnameAnnotation && len(cfg.FQDNTemplate) == 0 { return errors.New("FQDN Template must be set if ignoring annotations") } diff --git a/pkg/apis/externaldns/validation/validation_test.go b/pkg/apis/externaldns/validation/validation_test.go index 6496f318df..1705794c83 100644 --- a/pkg/apis/externaldns/validation/validation_test.go +++ b/pkg/apis/externaldns/validation/validation_test.go @@ -54,7 +54,7 @@ func TestValidateFlags(t *testing.T) { cfg = newValidConfig(t) cfg.IgnoreHostnameAnnotation = true - cfg.FQDNTemplate = "" + cfg.FQDNTemplate = []string{} require.Error(t, ValidateConfig(cfg)) cfg = newValidConfig(t) @@ -142,7 +142,7 @@ func newValidConfig(t *testing.T) *externaldns.Config { func TestValidateBadIgnoreHostnameAnnotationsConfig(t *testing.T) { cfg := externaldns.NewConfig() cfg.IgnoreHostnameAnnotation = true - cfg.FQDNTemplate = "" + cfg.FQDNTemplate = []string{} assert.Error(t, ValidateConfig(cfg)) } diff --git a/source/store_test.go b/source/store_test.go index 3da6753857..76923c1ca4 100644 --- a/source/store_test.go +++ b/source/store_test.go @@ -367,6 +367,7 @@ func TestNewSourceConfig(t *testing.T) { wantConfigured bool wantCombining bool wantErr bool + errContains string }{ { name: "no templates configured", @@ -375,14 +376,14 @@ func TestNewSourceConfig(t *testing.T) { { name: "fqdn template only", cfg: &externaldns.Config{ - FQDNTemplate: "{{.Name}}.example.com", + FQDNTemplate: []string{"{{.Name}}.example.com"}, }, wantConfigured: true, }, { name: "fqdn template with combine", cfg: &externaldns.Config{ - FQDNTemplate: "{{.Name}}.example.com", + FQDNTemplate: []string{"{{.Name}}.example.com"}, CombineFQDNAndAnnotation: true, }, wantConfigured: true, @@ -391,28 +392,49 @@ func TestNewSourceConfig(t *testing.T) { { name: "all three templates configured", cfg: &externaldns.Config{ - FQDNTemplate: "{{.Name}}.example.com", - TargetTemplate: "{{.Name}}.targets.example.com", - FQDNTargetTemplate: "{{.Name}}.example.com:{{.Name}}.targets.example.com", + FQDNTemplate: []string{"{{.Name}}.example.com"}, + TargetTemplate: []string{"{{.Name}}.targets.example.com"}, + FQDNTargetTemplate: []string{"{{.Name}}.example.com:{{.Name}}.targets.example.com"}, CombineFQDNAndAnnotation: true, }, wantConfigured: true, wantCombining: true, }, { - name: "invalid fqdn template", - cfg: &externaldns.Config{FQDNTemplate: "{{.Name"}, - wantErr: true, + name: "multiple fqdn templates", + cfg: &externaldns.Config{ + FQDNTemplate: []string{"{{.Name}}.a.example.com", "{{.Name}}.b.example.com"}, + }, + wantConfigured: true, + }, + { + name: "invalid fqdn template", + cfg: &externaldns.Config{FQDNTemplate: []string{"{{.Name"}}, + wantErr: true, + errContains: `--fqdn-template[0]`, }, { - name: "invalid target template", - cfg: &externaldns.Config{TargetTemplate: "{{.Status.LoadBalancer.Ingress"}, - wantErr: true, + name: "invalid target template", + cfg: &externaldns.Config{TargetTemplate: []string{"{{.Status.LoadBalancer.Ingress"}}, + wantErr: true, + errContains: `--target-template[0]`, }, { - name: "invalid fqdn-target template", - cfg: &externaldns.Config{FQDNTargetTemplate: "{{.Name}}.example.com:{{.Status"}, - wantErr: true, + name: "invalid fqdn-target template", + cfg: &externaldns.Config{FQDNTargetTemplate: []string{"{{.Name}}.example.com:{{.Status"}}, + wantErr: true, + errContains: `--fqdn-target-template[0]`, + }, + { + name: "duplicate define block in fqdn templates", + cfg: &externaldns.Config{ + FQDNTemplate: []string{ + `{{ define "zone" }}example.com{{ end }}{{.Name}}.{{ template "zone" }}`, + `{{ define "zone" }}other.com{{ end }}{{.Name}}.{{ template "zone" }}`, + }, + }, + wantErr: true, + errContains: `--fqdn-template[1]`, }, } @@ -421,6 +443,9 @@ func TestNewSourceConfig(t *testing.T) { got, err := NewSourceConfig(tt.cfg) if tt.wantErr { require.Error(t, err) + if tt.errContains != "" { + assert.ErrorContains(t, err, tt.errContains) + } return } require.NoError(t, err) diff --git a/source/template/engine.go b/source/template/engine.go index 57b6af6134..6824752154 100644 --- a/source/template/engine.go +++ b/source/template/engine.go @@ -25,6 +25,7 @@ import ( "strings" "text/template" + log "github.com/sirupsen/logrus" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" @@ -55,25 +56,34 @@ type Engine struct { // NewEngine parses the provided Go template strings into a Engine. // An empty string leaves the corresponding template unset; IsConfigured reflects // whether the FQDN template was provided. Returns an error on the first parse failure. -func NewEngine(fqdnStr, targetStr, fqdnTargetStr string, combineFQDN bool) (Engine, error) { - fqdnTmpl, err := parseTemplate(fqdnStr) +func NewEngine(fqdnTemplates, targetTemplates, fqdnTargetTemplates []string, combineFQDN bool) (Engine, error) { + fqdnTmpl, err := validateAndParse(fqdnTemplates, "--fqdn-template") if err != nil { - return Engine{}, fmt.Errorf("parse --fqdn-template: %w", err) + return Engine{}, err } - targetTmpl, err := parseTemplate(targetStr) + targetTmpl, err := validateAndParse(targetTemplates, "--target-template") if err != nil { - return Engine{}, fmt.Errorf("parse --target-template: %w", err) + return Engine{}, err } - fqdnTargetTmpl, err := parseTemplate(fqdnTargetStr) + fqdnTargetTmpl, err := validateAndParse(fqdnTargetTemplates, "--fqdn-target-template") if err != nil { - return Engine{}, fmt.Errorf("parse --fqdn-target-template: %w", err) - } - return Engine{ - fqdn: fqdnTmpl, - target: targetTmpl, - fqdnTarget: fqdnTargetTmpl, - combine: combineFQDN, - }, nil + return Engine{}, err + } + return Engine{fqdn: fqdnTmpl, target: targetTmpl, fqdnTarget: fqdnTargetTmpl, combine: combineFQDN}, nil +} + +func validateAndParse(templates []string, flag string) (*template.Template, error) { + if err := validateTemplates(templates, flag); err != nil { + return nil, err + } + if log.IsLevelEnabled(log.DebugLevel) { + log.Debugf("%s: %s", flag, strings.Join(templates, ",")) + } + t, err := parseTemplate(strings.Join(templates, ",")) + if err != nil { + return nil, fmt.Errorf("parse %s: %w", flag, err) + } + return t, nil } // IsConfigured reports whether the FQDN template is set and ready to use. @@ -126,6 +136,27 @@ func (e Engine) CombineWithEndpoints( return templatedEndpoints, nil } +// validateTemplates validates each template string individually for syntax errors, +// then checks cumulatively for cross-value {{ define }} block conflicts. +// Duplicate and blank strings are silently skipped. +// validateTemplates parses templates cumulatively so that both syntax errors and +// cross-value {{ define }} block conflicts are caught. Go only reports redefinition +// when both definitions appear in the same Parse call. +func validateTemplates(templates []string, flagName string) error { + var joined []string + for i, tmpl := range templates { + joined = append(joined, tmpl) + t, err := baseTemplate.Clone() + if err != nil { + return err + } + if _, err = t.Parse(strings.Join(joined, ",")); err != nil { + return fmt.Errorf("%s[%d] %q: %w", flagName, i, tmpl, err) + } + } + return nil +} + func parseTemplate(input string) (*template.Template, error) { if strings.TrimSpace(input) == "" { return nil, nil //nolint:nilnil // nil template signals "not configured"; callers check IsConfigured() diff --git a/source/template/engine_test.go b/source/template/engine_test.go index 54059b023e..71ad215ca5 100644 --- a/source/template/engine_test.go +++ b/source/template/engine_test.go @@ -20,6 +20,7 @@ import ( "errors" "testing" + log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" @@ -27,69 +28,70 @@ import ( "k8s.io/apimachinery/pkg/runtime" "sigs.k8s.io/external-dns/endpoint" + logtest "sigs.k8s.io/external-dns/internal/testutils/log" ) func TestNewEngine(t *testing.T) { for _, tt := range []struct { name string - fqdn string - target string - fqdnTarget string + fqdn []string + target []string + fqdnTarget []string errContains string }{ { name: "invalid fqdn template", - fqdn: "{{.Name", - errContains: `parse --fqdn-template: "{{.Name"`, + fqdn: []string{"{{.Name"}, + errContains: `--fqdn-template[0] "{{.Name"`, }, { name: "empty fqdn template", }, { name: "valid fqdn template", - fqdn: "{{.Name}}-{{.Namespace}}.ext-dns.test.com", + fqdn: []string{"{{.Name}}-{{.Namespace}}.ext-dns.test.com"}, }, { name: "valid fqdn template with multiple hosts", - fqdn: "{{.Name}}-{{.Namespace}}.ext-dns.test.com, {{.Name}}-{{.Namespace}}.ext-dna.test.com", + fqdn: []string{"{{.Name}}-{{.Namespace}}.ext-dns.test.com, {{.Name}}-{{.Namespace}}.ext-dna.test.com"}, }, { name: "replace template function", - fqdn: "{{\"hello.world\" | replace \".\" \"-\"}}.ext-dns.test.com", + fqdn: []string{`{{"hello.world" | replace "." "-"}}.ext-dns.test.com`}, }, { name: "isIPv4 template function with valid IPv4", - fqdn: "{{if isIPv4 \"192.168.1.1\"}}valid{{else}}invalid{{end}}.ext-dns.test.com", + fqdn: []string{`{{if isIPv4 "192.168.1.1"}}valid{{else}}invalid{{end}}.ext-dns.test.com`}, }, { name: "isIPv4 template function with invalid IPv4", - fqdn: "{{if isIPv4 \"not.an.ip.addr\"}}valid{{else}}invalid{{end}}.ext-dns.test.com", + fqdn: []string{`{{if isIPv4 "not.an.ip.addr"}}valid{{else}}invalid{{end}}.ext-dns.test.com`}, }, { name: "isIPv6 template function with valid IPv6", - fqdn: "{{if isIPv6 \"2001:db8::1\"}}valid{{else}}invalid{{end}}.ext-dns.test.com", + fqdn: []string{`{{if isIPv6 "2001:db8::1"}}valid{{else}}invalid{{end}}.ext-dns.test.com`}, }, { name: "isIPv6 template function with invalid IPv6", - fqdn: "{{if isIPv6 \"not:ipv6:addr\"}}valid{{else}}invalid{{end}}.ext-dns.test.com", + fqdn: []string{`{{if isIPv6 "not:ipv6:addr"}}valid{{else}}invalid{{end}}.ext-dns.test.com`}, }, { name: "invalid target template", - target: "{{.Status.LoadBalancer.Ingress", - errContains: `parse --target-template: "{{.Status.LoadBalancer.Ingress"`, + target: []string{"{{.Status.LoadBalancer.Ingress"}, + errContains: `--target-template[0] "{{.Status.LoadBalancer.Ingress"`, }, { name: "valid target template", - target: "{{.Name}}.targets.example.com", + target: []string{"{{.Name}}.targets.example.com"}, }, { name: "invalid fqdn-target template", - fqdnTarget: "{{.Name}}.example.com:{{.Status", - errContains: `parse --fqdn-target-template: "{{.Name}}.example.com:{{.Status"`, + fqdnTarget: []string{"{{.Name}}.example.com:{{.Status"}, + errContains: `--fqdn-target-template[0] "{{.Name}}.example.com:{{.Status"`, }, { name: "valid fqdn-target template", - fqdnTarget: "{{.Name}}.example.com:{{.Name}}.targets.example.com", + fqdnTarget: []string{"{{.Name}}.example.com:{{.Name}}.targets.example.com"}, }, } { t.Run(tt.name, func(t *testing.T) { @@ -104,23 +106,23 @@ func TestNewEngine(t *testing.T) { } func TestTemplateEngineIsConfigured(t *testing.T) { - empty, err := NewEngine("", "", "", false) + empty, err := NewEngine(nil, nil, nil, false) require.NoError(t, err) assert.False(t, empty.IsConfigured()) - configured, err := NewEngine("{{ .Name }}.example.com", "", "", false) + configured, err := NewEngine([]string{"{{ .Name }}.example.com"}, nil, nil, false) require.NoError(t, err) assert.True(t, configured.IsConfigured()) } func TestEngine_Combining(t *testing.T) { t.Run("false when not set", func(t *testing.T) { - e, err := NewEngine("{{ .Name }}.example.com", "", "", false) + e, err := NewEngine([]string{"{{ .Name }}.example.com"}, nil, nil, false) require.NoError(t, err) assert.False(t, e.Combining()) }) t.Run("true when set", func(t *testing.T) { - e, err := NewEngine("{{ .Name }}.example.com", "", "", true) + e, err := NewEngine([]string{"{{ .Name }}.example.com"}, nil, nil, true) require.NoError(t, err) assert.True(t, e.Combining()) }) @@ -130,21 +132,21 @@ func TestEngine_ExecTarget(t *testing.T) { obj := &testObject{ObjectMeta: metav1.ObjectMeta{Name: "svc", Namespace: "default"}} t.Run("returns targets from template", func(t *testing.T) { - e, err := NewEngine("", "{{ .Name }}.target.example.com", "", false) + e, err := NewEngine(nil, []string{"{{ .Name }}.target.example.com"}, nil, false) require.NoError(t, err) got, err := e.ExecTarget(obj) require.NoError(t, err) assert.Equal(t, []string{"svc.target.example.com"}, got) }) t.Run("returns empty when target template is unset", func(t *testing.T) { - e, err := NewEngine("", "", "", false) + e, err := NewEngine(nil, nil, nil, false) require.NoError(t, err) got, err := e.ExecTarget(obj) require.NoError(t, err) assert.Empty(t, got) }) t.Run("propagates execution error", func(t *testing.T) { - e, err := NewEngine("", "{{index . 0}}", "", false) + e, err := NewEngine(nil, []string{"{{index . 0}}"}, nil, false) require.NoError(t, err) _, err = e.ExecTarget(obj) require.Error(t, err) @@ -155,21 +157,21 @@ func TestEngine_ExecFQDNTarget(t *testing.T) { obj := &testObject{ObjectMeta: metav1.ObjectMeta{Name: "svc", Namespace: "default"}} t.Run("returns fqdn:target pairs from template", func(t *testing.T) { - e, err := NewEngine("", "", "{{ .Name }}.example.com:1.2.3.4", false) + e, err := NewEngine(nil, nil, []string{"{{ .Name }}.example.com:1.2.3.4"}, false) require.NoError(t, err) got, err := e.ExecFQDNTarget(obj) require.NoError(t, err) assert.Equal(t, []string{"svc.example.com:1.2.3.4"}, got) }) t.Run("returns empty when fqdn-target template is unset", func(t *testing.T) { - e, err := NewEngine("", "", "", false) + e, err := NewEngine(nil, nil, nil, false) require.NoError(t, err) got, err := e.ExecFQDNTarget(obj) require.NoError(t, err) assert.Empty(t, got) }) t.Run("propagates execution error", func(t *testing.T) { - e, err := NewEngine("", "", "{{index . 0}}", false) + e, err := NewEngine(nil, nil, []string{"{{index . 0}}"}, false) require.NoError(t, err) _, err = e.ExecFQDNTarget(obj) require.Error(t, err) @@ -405,7 +407,7 @@ func TestExecFQDN(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - engine, err := NewEngine(tt.tmpl, "", "", false) + engine, err := NewEngine([]string{tt.tmpl}, nil, nil, false) require.NoError(t, err) got, err := engine.ExecFQDN(tt.obj) @@ -416,7 +418,7 @@ func TestExecFQDN(t *testing.T) { } func TestExecFQDNNilObject(t *testing.T) { - engine, err := NewEngine("{{ toLower .Labels.department }}.example.org", "", "", false) + engine, err := NewEngine([]string{"{{ toLower .Labels.department }}.example.org"}, nil, nil, false) require.NoError(t, err) _, err = engine.ExecFQDN(nil) assert.Error(t, err) @@ -424,7 +426,7 @@ func TestExecFQDNNilObject(t *testing.T) { func TestExecFQDNPopulatesEmptyKind(t *testing.T) { // Test that Kind is populated when initially empty (simulates informer behavior) - engine, err := NewEngine("{{ .Kind }}.{{ .Name }}.example.com", "", "", false) + engine, err := NewEngine([]string{"{{ .Kind }}.{{ .Name }}.example.com"}, nil, nil, false) require.NoError(t, err) // Create object with empty TypeMeta (Kind == "") @@ -448,7 +450,7 @@ func TestExecFQDNPopulatesEmptyKind(t *testing.T) { func TestExecFQDNPreservesExistingKind(t *testing.T) { // Test that existing Kind is not overwritten - engine, err := NewEngine("{{ .Kind }}.{{ .Name }}.example.com", "", "", false) + engine, err := NewEngine([]string{"{{ .Kind }}.{{ .Name }}.example.com"}, nil, nil, false) require.NoError(t, err) obj := &testObject{ @@ -471,7 +473,7 @@ func TestExecFQDNPreservesExistingKind(t *testing.T) { } func TestExecFQDNExecutionError(t *testing.T) { - engine, err := NewEngine("{{ call .Name }}", "", "", false) + engine, err := NewEngine([]string{"{{ call .Name }}"}, nil, nil, false) require.NoError(t, err) obj := &metav1.PartialObjectMetadata{ @@ -490,11 +492,11 @@ func TestExecFQDNExecutionError(t *testing.T) { } func TestCombineWithEndpoints(t *testing.T) { - configured, err := NewEngine("{{.Name}}", "", "", false) + configured, err := NewEngine([]string{"{{.Name}}"}, nil, nil, false) require.NoError(t, err) - configuredCombine, err := NewEngine("{{.Name}}", "", "", true) + configuredCombine, err := NewEngine([]string{"{{.Name}}"}, nil, nil, true) require.NoError(t, err) - unconfigured, err := NewEngine("", "", "", false) + unconfigured, err := NewEngine(nil, nil, nil, false) require.NoError(t, err) annotationEndpoints := []*endpoint.Endpoint{ @@ -588,6 +590,96 @@ func TestCombineWithEndpoints(t *testing.T) { } } +func TestNewEngine_DebugLogging(t *testing.T) { + fqdn := []string{"{{.Name}}.example.com"} + target := []string{"{{.Name}}.targets.example.com"} + fqdnTarget := []string{"{{.Name}}.example.com:{{.Name}}.targets.example.com"} + + t.Run("logs templates at debug level", func(t *testing.T) { + hook := logtest.LogsUnderTestWithLogLevel(log.DebugLevel, t) + _, err := NewEngine(fqdn, target, fqdnTarget, false) + require.NoError(t, err) + logtest.TestHelperLogContainsWithLogLevel("--fqdn-template: {{.Name}}.example.com", log.DebugLevel, hook, t) + logtest.TestHelperLogContainsWithLogLevel("--target-template: {{.Name}}.targets.example.com", log.DebugLevel, hook, t) + logtest.TestHelperLogContainsWithLogLevel("--fqdn-target-template: {{.Name}}.example.com:{{.Name}}.targets.example.com", log.DebugLevel, hook, t) + }) + + t.Run("does not log templates below debug level", func(t *testing.T) { + hook := logtest.LogsUnderTestWithLogLevel(log.InfoLevel, t) + _, err := NewEngine(fqdn, target, fqdnTarget, false) + require.NoError(t, err) + logtest.TestHelperLogNotContains("--fqdn-template:", hook, t) + logtest.TestHelperLogNotContains("--target-template:", hook, t) + logtest.TestHelperLogNotContains("--fqdn-target-template:", hook, t) + }) +} + +func TestValidateTemplates(t *testing.T) { + for _, tt := range []struct { + name string + templates []string + flagName string + errContains string + }{ + { + name: "nil slice is valid", + templates: nil, + flagName: "--fqdn-template", + }, + { + name: "empty strings skipped", + templates: []string{"", " "}, + flagName: "--fqdn-template", + }, + { + name: "valid single template", + templates: []string{"{{.Name}}.example.com"}, + flagName: "--fqdn-template", + }, + { + name: "valid multiple templates", + templates: []string{"{{.Name}}.a.com", "{{.Name}}.b.com"}, + flagName: "--fqdn-template", + }, + { + name: "syntax error in first template reported with index", + templates: []string{"{{.Name"}, + flagName: "--fqdn-template", + errContains: `--fqdn-template[0] "{{.Name"`, + }, + { + name: "syntax error in second template reported with index", + templates: []string{"{{.Name}}.a.com", "{{.Name"}, + flagName: "--fqdn-template", + errContains: `--fqdn-template[1] "{{.Name"`, + }, + { + name: "duplicate define block conflict detected", + templates: []string{ + `{{ define "foo" }}bar{{ end }}{{ template "foo" }}`, + `{{ define "foo" }}foobar{{ end }}{{ template "foo" }}`, + }, + flagName: "--fqdn-template", + errContains: `--fqdn-template[1]`, + }, + { + name: "duplicate template strings are silently skipped", + templates: []string{"{{.Name}}.a.com", "{{.Name}}.a.com"}, + flagName: "--fqdn-template", + }, + } { + t.Run(tt.name, func(t *testing.T) { + err := validateTemplates(tt.templates, tt.flagName) + if tt.errContains != "" { + require.Error(t, err) + assert.ErrorContains(t, err, tt.errContains) + } else { + assert.NoError(t, err) + } + }) + } +} + type testObject struct { metav1.TypeMeta metav1.ObjectMeta diff --git a/source/template/testutil/testutil.go b/source/template/testutil/testutil.go index fbe0f8773a..60ec7cbf0c 100644 --- a/source/template/testutil/testutil.go +++ b/source/template/testutil/testutil.go @@ -24,10 +24,10 @@ import ( "sigs.k8s.io/external-dns/source/template" ) -// MustEngine creates an Engine with all three templates and combine flag, failing the test on error. +// MustEngine creates an Engine with the given template strings and combine flag, failing the test on error. func MustEngine(t testing.TB, fqdnStr, targetStr, fqdnTargetStr string, combine bool) template.Engine { t.Helper() - engine, err := template.NewEngine(fqdnStr, targetStr, fqdnTargetStr, combine) + engine, err := template.NewEngine([]string{fqdnStr}, []string{targetStr}, []string{fqdnTargetStr}, combine) require.NoError(t, err) return engine }