Skip to content
Open
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
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,20 @@ The operator runs as **one** of two controllers per process, selected by `--ingr
| One-shot initContainer prime | — | ✅ `--render-once` |
| Multi-replica shared-status HA | — | ✅ `--status-leader-election` |

### Path matching

In `--ingress-mode`, each Ingress/HTTPRoute path is rendered into a Synapse route by path type:

| Source | Rendered as |
|---|---|
| Ingress `Prefix` · Gateway `PathPrefix` | Synapse longest-prefix path (key = the path) |
| Ingress `Exact` · Gateway `Exact` | Approximated as a prefix (Synapse matches longest-prefix) + a warning event |
| Ingress `ImplementationSpecific` + `nginx.ingress.kubernetes.io/use-regex: "true"` | Regex route — `match_expr: http.request.path matches "<regex>"` |
| Gateway `RegularExpression` | Regex route — `match_expr: http.request.path matches "<regex>"` |
| `/.well-known/acme-challenge/*` | `internal_paths` (cert-manager HTTP-01 solver) |

Regex paths are **anchored at the start** (`^`) since they match from the beginning of the request path (the Kubernetes Ingress spec also requires the stored path to begin with `/`, so the leading `^` is supplied by the renderer). The path-map key for a regex route is a unique label; matching is driven entirely by `match_expr`. Header / method / query-param match conditions are not representable in Synapse's host+path model and are dropped with a warning event.

---

## Architecture
Expand Down Expand Up @@ -163,7 +177,9 @@ flowchart TD
|---|---|---|
| `--render-once` | `false` | One-shot: render `upstreams.yaml` and exit (initContainer prime) |
| `--ingress-class` | `synapse` | `spec.ingressClassName` this controller serves |
| `--upstreams-out` | `/shared/upstreams.yaml` | Path of the rendered upstreams file |
| `--upstreams-out` | `/shared/upstreams.yaml` | Path of the rendered upstreams file (sidecar layout) |
| `--upstreams-out-configmap` | _(none)_ | Central layout: write the rendered `upstreams.yaml` to this ConfigMap (`namespace/name`) instead of a file. Only the `upstreams.yaml` key is updated; other keys are preserved. Synapse reloads from its ConfigMap mount. |
| `--resolve-backend-cluster-ips` | `false` | Emit `<clusterIP>:port` instead of `<svc>.<ns>.svc.<cluster-domain>:port`, so Synapse skips backend DNS (falls back to the FQDN for headless / ExternalName / unallocated Services) |
| `--cluster-domain` | `cluster.local` | Cluster DNS domain for backend FQDNs |
| `--certs-out` | _(disabled)_ | Directory to project referenced TLS Secrets into |
| `--gateway-api` | `false` | Also reconcile Gateway API (requires the CRDs) |
Expand Down
36 changes: 24 additions & 12 deletions controllers/gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand All @@ -286,16 +298,16 @@ 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,
fmt.Sprintf("Exact path match %q is approximated as a prefix (synapse v1 matches longest-prefix)", pv))
}
paths = append(paths, pv)
}
return paths, warnings
return paths, regexes, warnings
}

func (r *IngressReconciler) gwBackend(routeNS string, br gwv1.HTTPBackendRef) (string, bool) {
Expand Down
64 changes: 51 additions & 13 deletions controllers/gateway_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,34 +15,72 @@ 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)
}
}
}

// 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)
}
}

Expand Down
25 changes: 25 additions & 0 deletions controllers/ingress_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
91 changes: 91 additions & 0 deletions controllers/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -220,6 +229,70 @@ 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$"`. 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)
}

// 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
Expand Down Expand Up @@ -328,6 +401,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 {
Expand Down Expand Up @@ -469,6 +554,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
Expand Down Expand Up @@ -606,6 +694,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)
}
Expand Down
Loading