Skip to content

Commit de42a7c

Browse files
committed
feat(api): per-route backend overrides on NebariApp routes
Adds an optional Backend field on RouteMatch so a single NebariApp can fan out by path to multiple backend services under one hostname. Routes without a per-route Backend continue to forward to spec.service, so all existing NebariApp manifests behave identically. Tightens the same-namespace contract by removing ServiceReference.Namespace. The field was a half-feature: the operator emitted a cross-namespace BackendObjectReference on the HTTPRoute, but never created the Gateway API ReferenceGrant the gateway needs in the target namespace, so traffic would silently fail. The new contract is "backends live in the NebariApp's own namespace"; workloads that need cross-namespace communication should use in-cluster DNS rather than the public HTTPRoute. Reconciler changes: - buildHTTPRouteRules now emits one HTTPRouteRule per RouteMatch when routes are configured, each with its own resolved backend. The "no routes" case still emits a single rule with empty matches so Gateway API's "/" default applies (unchanged behavior). - buildPublicHTTPRoute applies the same shape so per-route backends also work on routing.publicRoutes[]. - ValidateService now walks every backend the NebariApp references (spec.service plus any per-route backend in routes[] and publicRoutes[]) and confirms each exists in the NebariApp's namespace with the requested port exposed. Design rationale: docs/design/multi-backend-routes.md.
1 parent b7375d9 commit de42a7c

8 files changed

Lines changed: 346 additions & 200 deletions

File tree

api/v1/nebariapp_types.go

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ type NebariAppSpec struct {
2525
// Hostname is the fully qualified domain name where the application should be accessible.
2626
// This will be used to generate HTTPRoute.
2727
// Example: "myapp.nebari.local" or "api.example.com"
28+
//
29+
// Each NebariApp exposes exactly one public hostname. Packs that need to expose
30+
// multiple hostnames must be split into multiple NebariApps. This is an intentional
31+
// boundary so a NebariApp's TLS, auth, landing-page card, and routing concerns all
32+
// scope to a single user-visible URL. To fan out by path under one hostname to
33+
// multiple backend services, use routing.routes[].backend.
2834
// +kubebuilder:validation:Required
2935
// +kubebuilder:validation:MinLength=1
3036
// +kubebuilder:validation:Pattern=`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`
@@ -63,9 +69,14 @@ type NebariAppSpec struct {
6369
LandingPage *LandingPageConfig `json:"landingPage,omitempty"`
6470
}
6571

66-
// ServiceReference identifies the Kubernetes Service that backs this application.
72+
// ServiceReference identifies a Kubernetes Service in the NebariApp's own
73+
// namespace. Cross-namespace backends are not supported: the operator-generated
74+
// HTTPRoute would require a Gateway API ReferenceGrant in the target namespace
75+
// to actually carry traffic, and the operator does not create one. Workloads
76+
// that need to talk across namespaces should do so via in-cluster DNS rather
77+
// than via the public HTTPRoute.
6778
type ServiceReference struct {
68-
// Name is the name of the Kubernetes Service in the same namespace.
79+
// Name is the name of the Kubernetes Service in the NebariApp's namespace.
6980
// +kubebuilder:validation:Required
7081
// +kubebuilder:validation:MinLength=1
7182
Name string `json:"name"`
@@ -75,14 +86,6 @@ type ServiceReference struct {
7586
// +kubebuilder:validation:Minimum=1
7687
// +kubebuilder:validation:Maximum=65535
7788
Port int32 `json:"port"`
78-
79-
// Namespace is the namespace of the Service (if different from the NebariApp).
80-
// If not specified, defaults to the NebariApp's namespace.
81-
// This allows referencing services in other namespaces for centralized service architectures.
82-
// Note: The operator has cluster-scoped permissions to read Services across all namespaces.
83-
// +optional
84-
// +kubebuilder:validation:MinLength=1
85-
Namespace string `json:"namespace,omitempty"`
8689
}
8790

8891
// RoutingConfig configures routing behavior for the application.
@@ -125,7 +128,7 @@ type RoutingConfig struct {
125128
// RouteMatch defines a path-based routing rule.
126129
type RouteMatch struct {
127130
// PathPrefix specifies the path prefix to match for routing.
128-
// Traffic matching this prefix will be routed to the service.
131+
// Traffic matching this prefix will be routed to the backend.
129132
// Must start with "/". Example: "/app-1", "/api/v1"
130133
// +kubebuilder:validation:Required
131134
// +kubebuilder:validation:Pattern=`^/.*`
@@ -140,6 +143,18 @@ type RouteMatch struct {
140143
// +kubebuilder:validation:Enum=PathPrefix;Exact
141144
// +optional
142145
PathType string `json:"pathType,omitempty"`
146+
147+
// Backend optionally overrides the default backend (spec.service) for
148+
// this route. When set, traffic matching this route is forwarded to the
149+
// referenced Service instead of spec.service. The referenced Service must
150+
// exist in the NebariApp's own namespace; cross-namespace backends are
151+
// not supported (see ServiceReference).
152+
//
153+
// When omitted, the route falls back to spec.service. This allows a single
154+
// NebariApp to fan out by path to multiple backend services under one
155+
// hostname (e.g. "/api" → api-service, "/" → frontend-service).
156+
// +optional
157+
Backend *ServiceReference `json:"backend,omitempty"`
143158
}
144159

145160
// RoutingTLSConfig controls TLS termination for the HTTPRoute.

api/v1/zz_generated.deepcopy.go

Lines changed: 11 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

config/crd/bases/reconcilers.nebari.dev_nebariapps.yaml

Lines changed: 65 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,12 @@ spec:
325325
Hostname is the fully qualified domain name where the application should be accessible.
326326
This will be used to generate HTTPRoute.
327327
Example: "myapp.nebari.local" or "api.example.com"
328+
329+
Each NebariApp exposes exactly one public hostname. Packs that need to expose
330+
multiple hostnames must be split into multiple NebariApps. This is an intentional
331+
boundary so a NebariApp's TLS, auth, landing-page card, and routing concerns all
332+
scope to a single user-visible URL. To fan out by path under one hostname to
333+
multiple backend services, use routing.routes[].backend.
328334
minLength: 1
329335
pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
330336
type: string
@@ -442,10 +448,38 @@ spec:
442448
items:
443449
description: RouteMatch defines a path-based routing rule.
444450
properties:
451+
backend:
452+
description: |-
453+
Backend optionally overrides the default backend (spec.service) for
454+
this route. When set, traffic matching this route is forwarded to the
455+
referenced Service instead of spec.service. The referenced Service must
456+
exist in the NebariApp's own namespace; cross-namespace backends are
457+
not supported (see ServiceReference).
458+
459+
When omitted, the route falls back to spec.service. This allows a single
460+
NebariApp to fan out by path to multiple backend services under one
461+
hostname (e.g. "/api" → api-service, "/" → frontend-service).
462+
properties:
463+
name:
464+
description: Name is the name of the Kubernetes Service
465+
in the NebariApp's namespace.
466+
minLength: 1
467+
type: string
468+
port:
469+
description: Port is the port number on the Service
470+
to route traffic to.
471+
format: int32
472+
maximum: 65535
473+
minimum: 1
474+
type: integer
475+
required:
476+
- name
477+
- port
478+
type: object
445479
pathPrefix:
446480
description: |-
447481
PathPrefix specifies the path prefix to match for routing.
448-
Traffic matching this prefix will be routed to the service.
482+
Traffic matching this prefix will be routed to the backend.
449483
Must start with "/". Example: "/app-1", "/api/v1"
450484
pattern: ^/.*
451485
type: string
@@ -474,10 +508,38 @@ spec:
474508
items:
475509
description: RouteMatch defines a path-based routing rule.
476510
properties:
511+
backend:
512+
description: |-
513+
Backend optionally overrides the default backend (spec.service) for
514+
this route. When set, traffic matching this route is forwarded to the
515+
referenced Service instead of spec.service. The referenced Service must
516+
exist in the NebariApp's own namespace; cross-namespace backends are
517+
not supported (see ServiceReference).
518+
519+
When omitted, the route falls back to spec.service. This allows a single
520+
NebariApp to fan out by path to multiple backend services under one
521+
hostname (e.g. "/api" → api-service, "/" → frontend-service).
522+
properties:
523+
name:
524+
description: Name is the name of the Kubernetes Service
525+
in the NebariApp's namespace.
526+
minLength: 1
527+
type: string
528+
port:
529+
description: Port is the port number on the Service
530+
to route traffic to.
531+
format: int32
532+
maximum: 65535
533+
minimum: 1
534+
type: integer
535+
required:
536+
- name
537+
- port
538+
type: object
477539
pathPrefix:
478540
description: |-
479541
PathPrefix specifies the path prefix to match for routing.
480-
Traffic matching this prefix will be routed to the service.
542+
Traffic matching this prefix will be routed to the backend.
481543
Must start with "/". Example: "/app-1", "/api/v1"
482544
pattern: ^/.*
483545
type: string
@@ -519,15 +581,7 @@ spec:
519581
properties:
520582
name:
521583
description: Name is the name of the Kubernetes Service in the
522-
same namespace.
523-
minLength: 1
524-
type: string
525-
namespace:
526-
description: |-
527-
Namespace is the namespace of the Service (if different from the NebariApp).
528-
If not specified, defaults to the NebariApp's namespace.
529-
This allows referencing services in other namespaces for centralized service architectures.
530-
Note: The operator has cluster-scoped permissions to read Services across all namespaces.
584+
NebariApp's namespace.
531585
minLength: 1
532586
type: string
533587
port:

docs/api-reference.md

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ _Appears in:_
232232

233233
| Field | Description | Default | Validation |
234234
| --- | --- | --- | --- |
235-
| `hostname` _string_ | Hostname is the fully qualified domain name where the application should be accessible.<br />This will be used to generate HTTPRoute.<br />Example: "myapp.nebari.local" or "api.example.com" | | MinLength: 1 <br />Pattern: `^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$` <br />Required: \{\} <br /> |
235+
| `hostname` _string_ | Hostname is the fully qualified domain name where the application should be accessible.<br />This will be used to generate HTTPRoute.<br />Example: "myapp.nebari.local" or "api.example.com"<br />Each NebariApp exposes exactly one public hostname. Packs that need to expose<br />multiple hostnames must be split into multiple NebariApps. This is an intentional<br />boundary so a NebariApp's TLS, auth, landing-page card, and routing concerns all<br />scope to a single user-visible URL. To fan out by path under one hostname to<br />multiple backend services, use routing.routes[].backend. | | MinLength: 1 <br />Pattern: `^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$` <br />Required: \{\} <br /> |
236236
| `service` _[ServiceReference](#servicereference)_ | Service defines the backend Kubernetes Service that should receive traffic. | | Required: \{\} <br /> |
237237
| `routing` _[RoutingConfig](#routingconfig)_ | Routing configures routing behavior including path-based rules and TLS. | | Optional: \{\} <br /> |
238238
| `auth` _[AuthConfig](#authconfig)_ | Auth configures authentication/authorization for the application.<br />When enabled, the application will require OIDC authentication via supporting OIDC Provider. | | Optional: \{\} <br /> |
@@ -287,8 +287,9 @@ _Appears in:_
287287

288288
| Field | Description | Default | Validation |
289289
| --- | --- | --- | --- |
290-
| `pathPrefix` _string_ | PathPrefix specifies the path prefix to match for routing.<br />Traffic matching this prefix will be routed to the service.<br />Must start with "/". Example: "/app-1", "/api/v1" | | Pattern: `^/.*` <br />Required: \{\} <br /> |
290+
| `pathPrefix` _string_ | PathPrefix specifies the path prefix to match for routing.<br />Traffic matching this prefix will be routed to the backend.<br />Must start with "/". Example: "/app-1", "/api/v1" | | Pattern: `^/.*` <br />Required: \{\} <br /> |
291291
| `pathType` _string_ | PathType specifies how the path should be matched.<br />Valid values:<br /> - "PathPrefix": Match requests with the specified path prefix<br /> - "Exact": Match requests with the exact path<br />When used in routing.routes, defaults to "PathPrefix".<br />When used in routing.publicRoutes, defaults to "Exact" (safer for auth bypass). | | Enum: [PathPrefix Exact] <br />Optional: \{\} <br /> |
292+
| `backend` _[ServiceReference](#servicereference)_ | Backend optionally overrides the default backend (spec.service) for<br />this route. When set, traffic matching this route is forwarded to the<br />referenced Service instead of spec.service. The referenced Service must<br />exist in the NebariApp's own namespace; cross-namespace backends are<br />not supported (see ServiceReference).<br />When omitted, the route falls back to spec.service. This allows a single<br />NebariApp to fan out by path to multiple backend services under one<br />hostname (e.g. "/api" → api-service, "/" → frontend-service). | | Optional: \{\} <br /> |
292293

293294

294295
---
@@ -373,16 +374,21 @@ _Appears in:_
373374

374375
#### ServiceReference
375376

376-
ServiceReference identifies the Kubernetes Service that backs this application.
377+
ServiceReference identifies a Kubernetes Service in the NebariApp's own
378+
namespace. Cross-namespace backends are not supported: the operator-generated
379+
HTTPRoute would require a Gateway API ReferenceGrant in the target namespace
380+
to actually carry traffic, and the operator does not create one. Workloads
381+
that need to talk across namespaces should do so via in-cluster DNS rather
382+
than via the public HTTPRoute.
377383

378384
_Appears in:_
379385
- [NebariAppSpec](#nebariappspec)
386+
- [RouteMatch](#routematch)
380387

381388
| Field | Description | Default | Validation |
382389
| --- | --- | --- | --- |
383-
| `name` _string_ | Name is the name of the Kubernetes Service in the same namespace. | | MinLength: 1 <br />Required: \{\} <br /> |
390+
| `name` _string_ | Name is the name of the Kubernetes Service in the NebariApp's namespace. | | MinLength: 1 <br />Required: \{\} <br /> |
384391
| `port` _integer_ | Port is the port number on the Service to route traffic to. | | Maximum: 65535 <br />Minimum: 1 <br />Required: \{\} <br /> |
385-
| `namespace` _string_ | Namespace is the namespace of the Service (if different from the NebariApp).<br />If not specified, defaults to the NebariApp's namespace.<br />This allows referencing services in other namespaces for centralized service architectures.<br />Note: The operator has cluster-scoped permissions to read Services across all namespaces. | | MinLength: 1 <br />Optional: \{\} <br /> |
386392

387393

388394
---

internal/controller/reconcilers/core/reconciler.go

Lines changed: 40 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -115,43 +115,60 @@ func ValidateNamespaceOptIn(ctx context.Context, c client.Client, nebariApp *app
115115
return nil
116116
}
117117

118-
// ValidateService checks if the referenced service exists in the namespace and has the specified port.
119-
// Returns an error if the service doesn't exist or the port is not exposed.
118+
// ValidateService checks that every Service the NebariApp references exists in
119+
// the NebariApp's namespace and exposes the requested port. This covers
120+
// spec.service (the default backend) as well as any per-route backend
121+
// overrides declared in routing.routes[].backend and routing.publicRoutes[].backend.
120122
func ValidateService(ctx context.Context, c client.Client, nebariApp *appsv1.NebariApp) error {
121-
service := &corev1.Service{}
123+
if err := validateBackend(ctx, c, nebariApp.Namespace, &nebariApp.Spec.Service); err != nil {
124+
return err
125+
}
126+
127+
if nebariApp.Spec.Routing == nil {
128+
return nil
129+
}
122130

123-
// Use specified service namespace, or default to NebariApp's namespace
124-
serviceNamespace := nebariApp.Spec.Service.Namespace
125-
if serviceNamespace == "" {
126-
serviceNamespace = nebariApp.Namespace
131+
for _, route := range nebariApp.Spec.Routing.Routes {
132+
if route.Backend == nil {
133+
continue
134+
}
135+
if err := validateBackend(ctx, c, nebariApp.Namespace, route.Backend); err != nil {
136+
return fmt.Errorf("route %q: %w", route.PathPrefix, err)
137+
}
138+
}
139+
for _, route := range nebariApp.Spec.Routing.PublicRoutes {
140+
if route.Backend == nil {
141+
continue
142+
}
143+
if err := validateBackend(ctx, c, nebariApp.Namespace, route.Backend); err != nil {
144+
return fmt.Errorf("public route %q: %w", route.PathPrefix, err)
145+
}
127146
}
128147

148+
return nil
149+
}
150+
151+
// validateBackend checks that the referenced Service exists in the given
152+
// namespace and exposes the requested port. The namespace is always the
153+
// NebariApp's own namespace — cross-namespace backends are not supported.
154+
func validateBackend(ctx context.Context, c client.Client, namespace string, backend *appsv1.ServiceReference) error {
155+
service := &corev1.Service{}
129156
serviceKey := client.ObjectKey{
130-
Name: nebariApp.Spec.Service.Name,
131-
Namespace: serviceNamespace,
157+
Name: backend.Name,
158+
Namespace: namespace,
132159
}
133160

134161
if err := c.Get(ctx, serviceKey, service); err != nil {
135162
if errors.IsNotFound(err) {
136-
return fmt.Errorf("service %s not found in namespace %s",
137-
nebariApp.Spec.Service.Name, serviceNamespace)
163+
return fmt.Errorf("service %s not found in namespace %s", backend.Name, namespace)
138164
}
139165
return fmt.Errorf("failed to get service: %w", err)
140166
}
141167

142-
// Validate that the specified port exists on the service
143-
portFound := false
144168
for _, port := range service.Spec.Ports {
145-
if port.Port == nebariApp.Spec.Service.Port {
146-
portFound = true
147-
break
169+
if port.Port == backend.Port {
170+
return nil
148171
}
149172
}
150-
151-
if !portFound {
152-
return fmt.Errorf("service %s does not expose port %d",
153-
nebariApp.Spec.Service.Name, nebariApp.Spec.Service.Port)
154-
}
155-
156-
return nil
173+
return fmt.Errorf("service %s does not expose port %d", backend.Name, backend.Port)
157174
}

internal/controller/reconcilers/core/reconciler_test.go

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -184,11 +184,11 @@ func TestValidateService(t *testing.T) {
184184
expectError: true,
185185
},
186186
{
187-
name: "Cross-namespace service reference",
187+
name: "Per-route backend missing in NebariApp namespace",
188188
service: &corev1.Service{
189189
ObjectMeta: metav1.ObjectMeta{
190-
Name: "external-service",
191-
Namespace: "other-namespace",
190+
Name: "test-service",
191+
Namespace: "default",
192192
},
193193
Spec: corev1.ServiceSpec{
194194
Ports: []corev1.ServicePort{
@@ -203,13 +203,23 @@ func TestValidateService(t *testing.T) {
203203
},
204204
Spec: appsv1.NebariAppSpec{
205205
Service: appsv1.ServiceReference{
206-
Name: "external-service",
207-
Namespace: "other-namespace",
208-
Port: 8080,
206+
Name: "test-service",
207+
Port: 8080,
208+
},
209+
Routing: &appsv1.RoutingConfig{
210+
Routes: []appsv1.RouteMatch{
211+
{
212+
PathPrefix: "/api",
213+
Backend: &appsv1.ServiceReference{
214+
Name: "missing-backend",
215+
Port: 8080,
216+
},
217+
},
218+
},
209219
},
210220
},
211221
},
212-
expectError: false,
222+
expectError: true,
213223
},
214224
}
215225

0 commit comments

Comments
 (0)