From fa63ff36c21ce65623f5e19e1fec7ea91d058a16 Mon Sep 17 00:00:00 2001 From: David Papp Date: Thu, 18 Jun 2026 13:37:13 +0200 Subject: [PATCH 1/2] feat(ingress): render regex paths as synapse match_expr routes Gateway RegularExpression path matches and nginx use-regex Ingress paths were dropped ("no regex path support"), but synapse supports regex routing via match_expr. Render them as match_expr regex routes instead: - routeCfg.matchExpr + annSettings.useRegex; addRegexRoute() keyed by the regex (first-writer-wins), match_expr = http.request.path matches "" - emit match_expr in both the v1 and v2 upstreams schemas - rulePaths returns prefixes + regexes; HTTPRoute RegularExpression and Ingress paths under nginx.ingress.kubernetes.io/use-regex now produce regex routes (primary host + server-aliases) Tests: TestRulePaths updated; new TestRegexRouteRender covers v1+v2. --- controllers/gateway.go | 36 ++++++++----- controllers/gateway_test.go | 53 ++++++++++++++----- controllers/ingress_controller.go | 25 +++++++++ controllers/routes.go | 84 +++++++++++++++++++++++++++++++ 4 files changed, 173 insertions(+), 25 deletions(-) diff --git a/controllers/gateway.go b/controllers/gateway.go index 40ef5e3..b9a9246 100644 --- a/controllers/gateway.go +++ b/controllers/gateway.go @@ -148,7 +148,7 @@ func (r *IngressReconciler) renderGateways(ctx context.Context, m *renderModel) continue } req, resp := headerFilters(rule.Filters) - paths, warns := rulePaths(rule) + paths, regexes, warns := rulePaths(rule) for _, w := range warns { logger.Info("HTTPRoute match feature not representable in synapse v1 (best-effort)", "httproute", rt.Namespace+"/"+rt.Name, "detail", w) @@ -173,6 +173,18 @@ func (r *IngressReconciler) renderGateways(ctx context.Context, m *renderModel) } } } + for _, rx := range regexes { + for _, h := range hostnames { + host := string(h) + if !m.addRegexRoute(host, rx, servers, a, req, resp) { + logger.Info("regex route conflict ignored (first-writer-wins; Ingress/earlier source kept)", + "host", host, "regex", rx, "httproute", rt.Namespace+"/"+rt.Name) + mRouteConflicts.Inc() + r.emit(rt, corev1.EventTypeWarning, "RouteConflict", + "host %s regex %s already programmed by an earlier source (first-writer-wins); this rule is ignored", host, rx) + } + } + } } r.acceptRoute(ctx, rt, boundParent) } @@ -254,20 +266,20 @@ func (r *IngressReconciler) ruleBackends(ctx context.Context, rt *gwv1.HTTPRoute return servers } -// rulePaths extracts the path keys for a rule and reports any match -// features synapse's host+prefix v1 model cannot represent, so the -// caller warns instead of silently mis-routing: +// rulePaths extracts the prefix path keys and regex paths for a rule, and +// reports any match features synapse's host+path model cannot represent so +// the caller warns instead of silently mis-routing: // -// PathPrefix used as-is +// PathPrefix used as-is (prefix route) // Exact used as a prefix (best-effort) + warning -// RegularExpression dropped + warning (no regex path support) +// RegularExpression rendered as a synapse match_expr regex route // Headers/Method/Query path still used, constraint dropped + warning // -// A rule with no matches means "match all" → ["/"]. A match with no +// A rule with no matches means "match all" → prefix ["/"]. A match with no // Path defaults to PathPrefix "/" (Gateway API default). -func rulePaths(rule gwv1.HTTPRouteRule) (paths []string, warnings []string) { +func rulePaths(rule gwv1.HTTPRouteRule) (paths []string, regexes []string, warnings []string) { if len(rule.Matches) == 0 { - return []string{"/"}, nil + return []string{"/"}, nil, nil } for _, mt := range rule.Matches { if len(mt.Headers) > 0 || mt.Method != nil || len(mt.QueryParams) > 0 { @@ -286,8 +298,8 @@ func rulePaths(rule gwv1.HTTPRouteRule) (paths []string, warnings []string) { } switch pt { case gwv1.PathMatchRegularExpression: - warnings = append(warnings, - fmt.Sprintf("RegularExpression path match %q dropped (synapse v1 has no regex path support)", pv)) + // Rendered as a synapse `match_expr` regex route (was dropped). + regexes = append(regexes, pv) continue case gwv1.PathMatchExact: warnings = append(warnings, @@ -295,7 +307,7 @@ func rulePaths(rule gwv1.HTTPRouteRule) (paths []string, warnings []string) { } paths = append(paths, pv) } - return paths, warnings + return paths, regexes, warnings } func (r *IngressReconciler) gwBackend(routeNS string, br gwv1.HTTPBackendRef) (string, bool) { diff --git a/controllers/gateway_test.go b/controllers/gateway_test.go index 0019407..de44bec 100644 --- a/controllers/gateway_test.go +++ b/controllers/gateway_test.go @@ -15,34 +15,61 @@ func TestRulePaths(t *testing.T) { {Path: &gwv1.HTTPPathMatch{Value: ptr("/a")}}, {Path: &gwv1.HTTPPathMatch{Value: ptr("/.well-known/acme-challenge/t")}}, }} - got, warns := rulePaths(rule) + got, rx, warns := rulePaths(rule) if len(got) != 2 || got[0] != "/a" || got[1] != "/.well-known/acme-challenge/t" { t.Fatalf("rulePaths = %v", got) } + if len(rx) != 0 { + t.Fatalf("plain prefix paths must not produce regexes: %v", rx) + } if len(warns) != 0 { t.Fatalf("plain prefix paths must not warn: %v", warns) } // No matches ⇒ match-all ⇒ ["/"] with no warning. - if p, w := rulePaths(gwv1.HTTPRouteRule{}); len(p) != 1 || p[0] != "/" || len(w) != 0 { - t.Fatalf("empty rule must default to [/] no warn: %v %v", p, w) + if p, rx, w := rulePaths(gwv1.HTTPRouteRule{}); len(p) != 1 || p[0] != "/" || len(rx) != 0 || len(w) != 0 { + t.Fatalf("empty rule must default to [/] no warn: %v %v %v", p, rx, w) } // Exact ⇒ used (approximated) + warning. - p, w := rulePaths(gwv1.HTTPRouteRule{Matches: []gwv1.HTTPRouteMatch{ + p, rx, w := rulePaths(gwv1.HTTPRouteRule{Matches: []gwv1.HTTPRouteMatch{ {Path: &gwv1.HTTPPathMatch{Type: ptr(gwv1.PathMatchExact), Value: ptr("/exact")}}}}) - if len(p) != 1 || p[0] != "/exact" || len(w) != 1 || !strings.Contains(w[0], "Exact") { - t.Fatalf("Exact must be used + warn: p=%v w=%v", p, w) + if len(p) != 1 || p[0] != "/exact" || len(rx) != 0 || len(w) != 1 || !strings.Contains(w[0], "Exact") { + t.Fatalf("Exact must be used + warn: p=%v rx=%v w=%v", p, rx, w) } - // RegularExpression ⇒ dropped + warning (NOT defaulted to "/"). - p, w = rulePaths(gwv1.HTTPRouteRule{Matches: []gwv1.HTTPRouteMatch{ + // RegularExpression ⇒ emitted as a regex (NOT dropped, NO warning, NOT "/"). + p, rx, w = rulePaths(gwv1.HTTPRouteRule{Matches: []gwv1.HTTPRouteMatch{ {Path: &gwv1.HTTPPathMatch{Type: ptr(gwv1.PathMatchRegularExpression), Value: ptr("/x.*")}}}}) - if len(p) != 0 || len(w) != 1 || !strings.Contains(w[0], "RegularExpression") { - t.Fatalf("regex must be dropped + warn (no '/' default): p=%v w=%v", p, w) + if len(p) != 0 || len(rx) != 1 || rx[0] != "/x.*" || len(w) != 0 { + t.Fatalf("regex must be carried as a regex route (no drop/warn/'/' default): p=%v rx=%v w=%v", p, rx, w) } // Header-only match ⇒ default path "/" + header-dropped warning. - p, w = rulePaths(gwv1.HTTPRouteRule{Matches: []gwv1.HTTPRouteMatch{ + p, rx, w = rulePaths(gwv1.HTTPRouteRule{Matches: []gwv1.HTTPRouteMatch{ {Headers: []gwv1.HTTPHeaderMatch{{Name: "X-Env", Value: "canary"}}}}}) - if len(p) != 1 || p[0] != "/" || len(w) != 1 || !strings.Contains(w[0], "header") { - t.Fatalf("header-only match ⇒ ['/'] + warn: p=%v w=%v", p, w) + if len(p) != 1 || p[0] != "/" || len(rx) != 0 || len(w) != 1 || !strings.Contains(w[0], "header") { + t.Fatalf("header-only match ⇒ ['/'] + warn: p=%v rx=%v w=%v", p, rx, w) + } +} + +// A regex route renders as a match_expr block under a label key, in both +// the v1 and v2 schemas — proving regex Ingress/HTTPRoute paths survive. +func TestRegexRouteRender(t *testing.T) { + m := newRenderModel() + if !m.addRegexRoute("api.example.com", "^/api/runs/[^/]+/stream$", + []backend{{addr: "svc.ns.svc.cluster.local:8091"}}, annSettings{}, nil, nil) { + t.Fatal("addRegexRoute should succeed on a fresh model") + } + // Duplicate regex on the same host ⇒ first-writer-wins (no second add). + if m.addRegexRoute("api.example.com", "^/api/runs/[^/]+/stream$", + []backend{{addr: "other:1"}}, annSettings{}, nil, nil) { + t.Fatal("duplicate regex route must be rejected (first-writer-wins)") + } + wantExpr := `match_expr: "http.request.path matches \"^/api/runs/[^/]+/stream$\""` + for _, out := range []string{renderUpstreams(m), renderUpstreamsV2(m)} { + if !strings.Contains(out, wantExpr) { + t.Fatalf("rendered config missing match_expr:\nwant substring: %s\ngot:\n%s", wantExpr, out) + } + if !strings.Contains(out, "svc.ns.svc.cluster.local:8091") { + t.Fatalf("rendered config missing backend:\n%s", out) + } } } diff --git a/controllers/ingress_controller.go b/controllers/ingress_controller.go index 31c4741..1a98607 100644 --- a/controllers/ingress_controller.go +++ b/controllers/ingress_controller.go @@ -274,6 +274,31 @@ func (r *IngressReconciler) render(ctx context.Context) (bool, int, int, error) if path == "" { path = "/" } + // nginx `use-regex: "true"`: the path is a POSIX regex → + // render a synapse match_expr regex route (under the primary host + // and any server-aliases). Otherwise fall through to prefix/Exact. + if a.useRegex { + if !m.addRegexRoute(host, path, []backend{{addr: addr}}, a, nil, nil) { + logger.Info("regex route conflict ignored (first-writer-wins)", + "host", host, "regex", path, "ingress", ing.Namespace+"/"+ing.Name) + mRouteConflicts.Inc() + r.emit(ing, corev1.EventTypeWarning, "RouteConflict", + "host %s regex %s already programmed by an earlier source (first-writer-wins); this rule is ignored", host, path) + } + for _, alias := range a.serverAliases { + if alias == "" || alias == host { + continue + } + if !m.addRegexRoute(alias, path, []backend{{addr: addr}}, a, nil, nil) { + logger.Info("regex route conflict ignored on server-alias (first-writer-wins)", + "host", alias, "regex", path, "ingress", ing.Namespace+"/"+ing.Name, "primary_host", host) + mRouteConflicts.Inc() + r.emit(ing, corev1.EventTypeWarning, "RouteConflict", + "server-alias %s regex %s already programmed by an earlier source (first-writer-wins); this alias is ignored", alias, path) + } + } + continue + } if p.PathType != nil && *p.PathType == networkingv1.PathTypeExact { logger.Info("Ingress Exact pathType is approximated as a prefix (synapse v1 matches longest-prefix)", "ingress", ing.Namespace+"/"+ing.Name, "host", host, "path", path) diff --git a/controllers/routes.go b/controllers/routes.go index b495fc7..d950642 100644 --- a/controllers/routes.go +++ b/controllers/routes.go @@ -89,6 +89,11 @@ type routeCfg struct { // when set, synapse writes the redirect and never contacts upstream. redirectStatus *uint64 redirectLocation string + // matchExpr, when non-empty, makes this a regex route: synapse matches + // it via `match_expr` (a wirefilter expression) instead of by the path + // key, which becomes just a unique label. Populated from a Gateway + // RegularExpression path match or an nginx `use-regex` Ingress path. + matchExpr string } // annSettings is the subset parsed from an object's annotations, @@ -123,6 +128,10 @@ type annSettings struct { redirectStatus *uint64 redirectLocation string sticky bool + // useRegex mirrors nginx `nginx.ingress.kubernetes.io/use-regex: "true"`: + // the Ingress's paths are POSIX regexes, rendered as synapse `match_expr` + // regex routes rather than longest-prefix paths. + useRegex bool } // certProjection is one TLS Secret to materialize into the synapse @@ -220,6 +229,63 @@ func (m *renderModel) addRoute(host, path string, servers []backend, a annSettin return true } +// regexRouteKey is the unique (host-scoped) path-map label for a regex +// route. synapse ignores the key for matching when match_expr is set, but +// it must be unique and stable; the regex itself makes it deterministic and +// collision-free (same regex twice ⇒ same key ⇒ first-writer-wins). +func regexRouteKey(regex string) string { + return "expr:" + regex +} + +// pathRegexExpr renders the synapse wirefilter match expression for a path +// regex, e.g. `http.request.path matches "^/api/runs/[^/]+/stream$"`. +func pathRegexExpr(regex string) string { + return fmt.Sprintf("http.request.path matches %q", regex) +} + +// addRegexRoute records a regex path route: it is matched by `match_expr` +// (a wirefilter expression over the request path) rather than by longest- +// prefix. Same FIRST-WRITER-WINS semantics as addRoute, keyed by the regex +// so a host can carry multiple distinct regex routes deterministically. +func (m *renderModel) addRegexRoute(host, regex string, servers []backend, a annSettings, extraReq, extraResp []string) bool { + if regex == "" { + return false + } + if _, claimed := m.passthroughHosts[host]; claimed { + return false + } + key := regexRouteKey(regex) + if m.hosts[host] == nil { + m.hosts[host] = map[string]*routeCfg{} + } + if _, exists := m.hosts[host][key]; exists { + return false + } + rc := &routeCfg{ + servers: servers, + ssl: a.ssl, + http2: a.http2, + forceHTTPS: a.forceHTTPS, + healthcheck: a.healthcheck, + disableAccessLog: a.disableAccessLog, + connectTimeout: a.connectTimeout, + readTimeout: a.readTimeout, + writeTimeout: a.writeTimeout, + idleTimeout: a.idleTimeout, + maxBodySize: a.maxBodySize, + reqHeaders: append(append([]string{}, a.reqHeaders...), extraReq...), + respHeaders: append(append([]string{}, a.respHeaders...), extraResp...), + redirectStatus: a.redirectStatus, + redirectLocation: a.redirectLocation, + matchExpr: pathRegexExpr(regex), + } + m.hosts[host][key] = rc + if a.sticky { + m.sticky = true + } + return true +} + // addPassthroughHost claims `host` as an SNI-routed TCP passthrough // upstream. FIRST-WRITER-WINS: returns false if any terminate route // already exists for the host, or another passthrough is already @@ -328,6 +394,18 @@ func parseAnnotations(ann map[string]string) annSettings { if v, ok := get("http2"); ok { s.http2 = parseBool(v) } + // use-regex: treat this Ingress's paths as POSIX regexes and render them + // as synapse `match_expr` regex routes (ingress-nginx parity). synapse- + // compat key first, then the nginx-compat key. + if v, ok := get("use-regex"); ok { + if b := parseBool(v); b != nil { + s.useRegex = *b + } + } else if v, ok := ann[nginxPrefix+"use-regex"]; ok { + if b := parseBool(v); b != nil { + s.useRegex = *b + } + } if v, ok := get("force-https"); ok { s.forceHTTPS = parseBool(v) } else if v, ok := get("ssl-redirect"); ok { @@ -469,6 +547,9 @@ func renderUpstreams(m *renderModel) string { fmt.Fprintf(&b, " - %q\n", sv.addr) } } + if rc.matchExpr != "" { + fmt.Fprintf(&b, " match_expr: %q\n", rc.matchExpr) + } // ssl_enabled: explicit annotation wins; default false // (plain HTTP to backend — unchanged prior behavior). ssl := false @@ -606,6 +687,9 @@ func writeRouteV2(b *strings.Builder, rc *routeCfg) { } } } + if rc.matchExpr != "" { + fmt.Fprintf(b, " match_expr: %q\n", rc.matchExpr) + } if rc.ssl != nil { fmt.Fprintf(b, " ssl_enabled: %t\n", *rc.ssl) } From e809dd33215fa77dc22027ac8f520ce4c73215e9 Mon Sep 17 00:00:00 2001 From: David Papp Date: Thu, 18 Jun 2026 13:59:00 +0200 Subject: [PATCH 2/2] fix(ingress): anchor path regexes at the start (^) An Ingress/HTTPRoute path regex matches from the beginning of the request path (nginx use-regex / Gateway semantics); k8s also requires the stored Ingress path to begin with "/", so the leading ^ is supplied by the renderer. Prevents an unanchored match (e.g. /x/api/runs/1/stream). --- controllers/gateway_test.go | 11 +++++++++++ controllers/routes.go | 9 ++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/controllers/gateway_test.go b/controllers/gateway_test.go index de44bec..604e017 100644 --- a/controllers/gateway_test.go +++ b/controllers/gateway_test.go @@ -73,6 +73,17 @@ func TestRegexRouteRender(t *testing.T) { } } +// A path regex stored without a leading `^` (k8s requires the Ingress path to +// begin with `/`) is anchored at the start by the renderer. +func TestRegexAnchoring(t *testing.T) { + m := newRenderModel() + m.addRegexRoute("h", "/api/runs/[^/]+/stream$", []backend{{addr: "x:1"}}, annSettings{}, nil, nil) + want := `match_expr: "http.request.path matches \"^/api/runs/[^/]+/stream$\""` + if out := renderUpstreams(m); !strings.Contains(out, want) { + t.Fatalf("unanchored path regex must be ^-anchored:\nwant %s\ngot:\n%s", want, out) + } +} + // Gateway API weight semantics: weight 0 ⇒ no traffic (excluded); // unset-with-others ⇒ default 1; no weights at all ⇒ unweighted. func TestRuleBackends_WeightSemantics(t *testing.T) { diff --git a/controllers/routes.go b/controllers/routes.go index d950642..923b300 100644 --- a/controllers/routes.go +++ b/controllers/routes.go @@ -238,8 +238,15 @@ func regexRouteKey(regex string) string { } // pathRegexExpr renders the synapse wirefilter match expression for a path -// regex, e.g. `http.request.path matches "^/api/runs/[^/]+/stream$"`. +// regex, e.g. `http.request.path matches "^/api/runs/[^/]+/stream$"`. The +// regex is anchored at the start (`^`) when not already, because an +// Ingress/HTTPRoute path regex matches from the beginning of the request +// path (nginx use-regex / Gateway semantics) — k8s also requires the stored +// path to begin with `/`, so the `^` is supplied here rather than in the spec. func pathRegexExpr(regex string) string { + if !strings.HasPrefix(regex, "^") { + regex = "^" + regex + } return fmt.Sprintf("http.request.path matches %q", regex) }