Skip to content

fix(source): evaluate FQDN templates on typed objects like unstructured - #6611

Open
zanarellidev wants to merge 7 commits into
kubernetes-sigs:masterfrom
zanarellidev:fix/fqdn-template-typed-unstructured
Open

fix(source): evaluate FQDN templates on typed objects like unstructured#6611
zanarellidev wants to merge 7 commits into
kubernetes-sigs:masterfrom
zanarellidev:fix/fqdn-template-typed-unstructured

Conversation

@zanarellidev

@zanarellidev zanarellidev commented Aug 2, 2026

Copy link
Copy Markdown

What does it do?

On FQDN template execution failure, retry with an unstructured-style data shape (Spec/Status as JSON maps, plus Name/Namespace/...). Shared --fqdn-template values that use JSON keys under Spec (e.g. {{ range .Spec.hostnames }}) then work for typed gateway routes the same way they already work for the unstructured source.

Motivation

#6593: the same --fqdn-template is used for unstructured and typed sources. Unstructured wraps objects so Spec is a map with JSON keys; typed v1.HTTPRouteSpec exposes Go field names, so .Spec.hostnames fails with can't evaluate field hostnames in type v1.HTTPRouteSpec (including when hostnames are empty/absent).

More

  • Yes, this PR title follows Conventional Commits
  • Yes, I added unit tests
  • Yes, I updated end user documentation accordingly

Validation:

  • go test ./source/template/ (typed hostnames, empty hostnames, unstructured, Name+Spec mix)
  • Existing TestExecFQDNExecutionError still fails closed on real template errors
  • CI

Fixes #6593

Retry failed FQDN template execution with an unstructured-style data
shape so shared templates using JSON keys under Spec (e.g.
.Spec.hostnames) work for gateway-httproute as well as unstructured.


Signed-off-by: zanarelli <zanarelli.dev@gmail.com>
@kubernetes-prow kubernetes-prow Bot added the do-not-merge/invalid-commit-message Indicates that a PR should not merge because it has an invalid commit message. label Aug 2, 2026
@kubernetes-prow
kubernetes-prow Bot requested a review from mloiseleur August 2, 2026 12:28
@kubernetes-prow
kubernetes-prow Bot requested a review from Raffo August 2, 2026 12:28
@kubernetes-prow kubernetes-prow Bot added the cncf-cla: yes Indicates the PR's author has signed the CNCF CLA. label Aug 2, 2026
@kubernetes-prow

Copy link
Copy Markdown

Hi @zanarellidev. Thanks for your PR.

I'm waiting for a kubernetes-sigs member to verify that this patch is reasonable to test. If it is, they should reply with /ok-to-test on its own line. Until that is done, I will not automatically test new commits in this PR, but the usual testing commands by org members will still work.

Regular contributors should join the org to skip this step.

Once the patch is verified, the new status will be reflected by the ok-to-test label.

I understand the commands that are listed here.

Details

Instructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the kubernetes-sigs/prow repository.

@kubernetes-prow kubernetes-prow Bot added needs-ok-to-test Indicates a PR that requires an org member to verify it is safe to test. size/L Denotes a PR that changes 100-499 lines, ignoring generated files. labels Aug 2, 2026
@zanarellidev
zanarellidev force-pushed the fix/fqdn-template-typed-unstructured branch from cb4336b to c75e92d Compare August 2, 2026 14:53
@kubernetes-prow kubernetes-prow Bot removed the do-not-merge/invalid-commit-message Indicates that a PR should not merge because it has an invalid commit message. label Aug 2, 2026
@ivankatliarchuk

Copy link
Copy Markdown
Member

Not too sure. The approach try, fail, retry - for a shared JSON-style template on a typed source like HTTPRoute, step 1 will always fail and step 2 will always run, on every object, every sync cycle - permanently. What are the other options considered?

@zanarellidev

Copy link
Copy Markdown
Author

Good catch. For a shared JSON-style template against typed HTTPRoute, step 1 does fail every sync and step 2 always runs. That cost is real for that path.

Why try/fail was chosen anyway: always normalizing typed objects to the unstructured/JSON map shape before Execute fixes JSON keys in one pass, but it silently breaks Go-field templates that already work today (e.g. {{ range .Spec.Hostnames }}). On a map, .Spec.Hostnames evaluates empty with no error. Existing coverage also uses .ObjectMeta.Annotations, .Name, .Labels, and .Data on typed objects, so a naive always-convert needs a much richer view and still has the Go-vs-JSON Spec field problem.

Options considered:

  1. Keep try/fail (this PR). Preserves both template styles. Cost is permanent on the JSON-on-typed path only. Unstructured already succeeds on step 1.

  2. Separate --unstructured-fqdn-template (preferred long-term in Combining unstructured source with structured source for same resource can lead to templating errors #6593). Removes the shared-template conflict entirely. Larger API/docs change; gateway and unstructured no longer share one string.

  3. Soft-skip: do not run the shared FQDN template for gateway (also floated in Combining unstructured source with structured source for same resource can lead to templating errors #6593: not needed for the normal Gateway source). Behavior change for anyone who does rely on FQDN templates on gateway routes.

  4. Cache the failure once per template (or GVK+template): after the first typed Execute miss, subsequent objects use the unstructured shape directly. Same semantics as (1), without paying fail+retry every sync.

  5. Template helpers / missingkey=zero: does not fix struct missing-field errors (can't evaluate field hostnames).

I lean toward (2) as the clean product fix, or (4) if we want to keep a single --fqdn-template and only remove the permanent retry cost. Happy to implement whichever you prefer.

@ivankatliarchuk

Copy link
Copy Markdown
Member

I'm not too sure what the right solution should be. There are missing tests related to fqdn in source/gateway_httproute_test.go and in source/unstructured_fqdn_test.go, that syntax works aka for httproute something like .Spec.Hostnames and for unstructured .Spec.hostnames

Adds an integration-level test through the real gateway route resolver
(not just source/template/engine_test.go's isolated unit tests) proving
a JSON-style Spec.hostnames template now evaluates successfully against
a typed HTTPRoute via the unstructured fallback, matching the exact
scenario and error from kubernetes-sigs#6593.
@zanarellidev

Copy link
Copy Markdown
Author

Good catch, that gap was real. Added an integration test through the actual gateway resolver path (not just the isolated source/template/engine_test.go unit tests): source/gateway_httproute_test.go's new "FQDNTemplate with JSON-style Spec access on typed HTTPRoute" case. Confirmed it fails with the exact #6593 error against the pre-fix engine.go, and passes with this PR's fix — proving .Spec.hostnames (JSON-style) now works via the fallback while .Spec.Hostnames (the existing typed-field path, still covered by the other test cases in the same file) keeps working unchanged.

Comment thread source/template/engine.go Outdated
Shortened the fallback comment in engine.go and the corresponding test
doc comments; removed the kubernetes-sigs#6593 references from code comments per
ivankatliarchuk's review.
@zanarellidev

Copy link
Copy Markdown
Author

Ivan, adjusted as requested. Thanks for the help.

@ivankatliarchuk ivankatliarchuk left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So there is currently an asymmetry in how templating behaves for different casings.

Typed HTTPRoute (--source=gateway-httproute), with this PR applied:

  • .Spec.Hostnames (capital) - succeeds on the first tmpl.Execute attempt directly against the real struct, since Hostnames is the actual Go field name. No retry ever
    triggers; it just works, same as before this PR existed.
  • .Spec.hostnames (lowercase) - first attempt hard-errors (can't evaluate field hostnames in type v1.HTTPRouteSpec), which is an error, so the retry fires, converts to
    the map shape, and the second attempt succeeds via the JSON key.

Unstructured source (any object, dynamic-client-backed):

  • .Spec.hostnames (lowercase) - .Spec is already map[string]any with the raw JSON key hostnames; direct map lookup on the first attempt succeeds immediately.
  • .Spec.Hostnames (capital) - map lookup for key "Hostnames" misses. A map miss is not an error in Go's text/template (confirmed by the throwaway test earlier: err= output="DONE", zero range iterations). So tmpl.Execute returns successfully with empty output — the retry gate (if err != nil) never fires, because there's no error to catch, and there's nowhere to fall back to anyway since the map is already the "final" shape.
  • Only lowercase works; capital silently renders nothing, no error, no log line.

Most likely, we should widen the fix; make both casings resolves correctly on unstructured as well. Something like

// source/unstructured.go
 // addTitleCaseAliases makes each key in m reachable by both its native JSON
// and a naive Title-cased variant. Recurses so nested access works both ways too.
func addTitleCaseAliases(m map[string]any) map[string]any {}

...usage...
if spec, ok := u.Object["spec"].(map[string]any); ok {
        w.Spec = addTitleCaseAliases(copyMap(spec)) // copy first — see caveat below
  }

Comment thread source/template/engine.go
@ivankatliarchuk

Copy link
Copy Markdown
Member

/ok-to-test

@kubernetes-prow kubernetes-prow Bot added ok-to-test Indicates a non-member PR verified by an org member that is safe to test. and removed needs-ok-to-test Indicates a PR that requires an org member to verify it is safe to test. labels Aug 2, 2026
@coveralls

coveralls commented Aug 2, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 31402468539

Warning

Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes.
Quick fix: rebase this PR. Learn more →

Coverage increased (+0.1%) to 81.891%

Details

  • Coverage increased (+0.1%) from the base build.
  • Patch coverage: No coverable lines changed in this PR.
  • 22 coverage regressions across 3 files.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

22 previously-covered lines in 3 files lost coverage.

File Lines Losing Coverage Coverage
skipper_routegroup.go 13 92.07%
template/engine.go 6 80.81%
unstructured.go 3 97.09%

Coverage Stats

Coverage Status
Relevant Lines: 21465
Covered Lines: 17578
Line Coverage: 81.89%
Coverage Strength: 1454.02 hits per line

💛 - Coveralls

A map key miss in text/template renders empty instead of erroring, so
templates on the unstructured source silently produced nothing when
using the Go field name casing (.Spec.Hostnames) instead of the raw
JSON key (.Spec.hostnames). Alias each map key to its title-cased form
so both spellings resolve.
…late

Extract wrapTemplateErr to remove the duplicated kind/namespace/name
formatting between the two retry error paths, and propagate the
retry's own error instead of re-wrapping the original one.
@zanarellidev

Copy link
Copy Markdown
Author

Pushed both remaining items:

  • Widened the fix for the casing asymmetry you flagged: withTitleCaseAliases on the unstructured source's Spec/Status maps, so .Spec.Hostnames and .Spec.hostnames both resolve. Added a test covering that exact case plus unit tests for the alias helper.
  • Simplified the retry block with a wrapTemplateErr helper and now propagate the retry's own error instead of discarding it.

One tradeoff to flag: anything that ranges over the whole .Spec/.Status map (instead of indexing a specific field) will now also see the title-cased alias as a separate key.

Comment thread source/unstructured_fqdn_test.go Outdated
@ivankatliarchuk

Copy link
Copy Markdown
Member

/lgtm

@kubernetes-prow kubernetes-prow Bot added the lgtm "Looks good to me", indicates that a PR is ready to be merged. label Aug 8, 2026
Comment thread source/template/engine_test.go
Comment thread source/unstructured.go
Comment thread source/unstructured.go
mloiseleur found three real regressions in the FQDN-template typed/
unstructured retry added earlier in this branch, each with a reproducing
test:

- The unstructured retry in execTemplate used the default text/template
  missingkey mode, so a template referencing a field that doesn't exist
  on either the typed object or its JSON shape rendered "<no value>"
  instead of failing. Now cloning the template and setting
  missingkey=error for that retry only, so a real typo in --fqdn-template
  fails loudly instead of producing a wrong-but-valid hostname/target.

- titleCaseKey only capitalized the first letter of a JSON key, so CRD
  fields using Go initialisms (cert-manager's spec.url / spec.dnsNames)
  never matched their real generated Go names (URL, DNSNames). It now
  upper-cases a leading word in full when it's a known initialism.

- withTitleCaseAliases recursed into nested map values to alias their
  keys too, silently doubling every key one level down. Any template
  ranging or taking len() over nested data (not just the top-level Spec/
  Status fields the alias exists for) saw twice as many entries as were
  declared. Aliasing is now shallow; nested JSON keys stay reachable
  through their literal path.

go test -race ./source/... ./source/template/... green.

Signed-off-by: zanarelli <zanarelli.dev@gmail.com>
@kubernetes-prow kubernetes-prow Bot removed the lgtm "Looks good to me", indicates that a PR is ready to be merged. label Aug 10, 2026
@kubernetes-prow

Copy link
Copy Markdown

New changes are detected. LGTM label has been removed.

@kubernetes-prow

Copy link
Copy Markdown

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by:
Once this PR has been reviewed and has the lgtm label, please ask for approval from ivankatliarchuk. For more information see the Code Review Process.

The full list of commands accepted by this bot can be found here.

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@zanarellidev

Copy link
Copy Markdown
Author

@mloiseleur thanks for the concrete repros, all three are real and now fixed in d6c3b2da:

  • fail-open on unknown field: the unstructured retry now runs with missingkey=error (cloned template, so it only affects that retry, not the rest of the engine), so an unresolvable field fails the template instead of rendering <no value>. Added TestExecFQDNFailsClosedOnUnknownField with your three sub-cases.
  • URL/DNSNames casing: titleCaseKey now upper-cases known initialisms (url -> URL, dnsNames -> DNSNames) instead of only capitalizing the first letter. Added TestTitleCaseKey_KnownInitialisms covering the cert-manager fields you named.
  • alias pollution: withTitleCaseAliases no longer recurses into nested map values — it only aliases the direct children of Spec/Status now, so a template ranging or len()-ing nested data doesn't see doubled keys. Deep JSON paths (e.g. spec.endpoint.hostname) stay reachable through their literal key; I updated the one existing test that relied on a nested alias to reflect that. Added TestUnstructuredWrapper_MapIterationIsNotAliasPolluted with your exact repro.

go test -race ./source/... ./source/template/... green locally. Happy to adjust the initialism list or the alias-depth call if you'd rather it worked differently.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cncf-cla: yes Indicates the PR's author has signed the CNCF CLA. ok-to-test Indicates a non-member PR verified by an org member that is safe to test. size/L Denotes a PR that changes 100-499 lines, ignoring generated files. source

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Combining unstructured source with structured source for same resource can lead to templating errors

4 participants