Skip to content

Commit 9f35cf6

Browse files
committed
feat(api): per-route port overrides on NebariApp routes
Adds an optional Port field on RouteMatch so a single NebariApp can route different path prefixes to different ports on the same backend Service under one hostname. Routes without a per-route Port continue to forward to spec.service.port, so all existing NebariApp manifests behave identically. A NebariApp still targets exactly one Service. Per-route backend Services are intentionally not supported — packs that need to fan out to multiple Services should split into multiple NebariApps. 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 "the backend Service lives 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 port (the route's Port override if set, else spec.service.port). 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 ports also work on routing.publicRoutes[]. - ValidateService looks up spec.service once and confirms every effective port (spec.service.port plus each route override) is exposed by the Service. Design rationale: docs/design/multi-backend-routes.md. This PR implements only the multi-port + namespace-removal portions of the design; the streaming/BackendTrafficPolicy portion is a follow-up.
1 parent 2d096ea commit 9f35cf6

8 files changed

Lines changed: 346 additions & 211 deletions

File tree

api/v1/nebariapp_types.go

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,23 @@ 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 and is backed by exactly
30+
// one Kubernetes Service (spec.service). Packs that need multiple hostnames,
31+
// or that genuinely need to fan out to multiple Services, must be split into
32+
// multiple NebariApps. This is an intentional boundary so a NebariApp's TLS,
33+
// auth, landing-page card, and routing concerns all scope to a single
34+
// user-visible URL backed by a single Service. To fan out by path under one
35+
// hostname to different ports on that Service, use routing.routes[].port.
2836
// +kubebuilder:validation:Required
2937
// +kubebuilder:validation:MinLength=1
3038
// +kubebuilder:validation:Pattern=`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`
3139
Hostname string `json:"hostname"`
3240

33-
// Service defines the backend Kubernetes Service that should receive traffic.
41+
// Service defines the single backend Kubernetes Service that receives traffic
42+
// for this NebariApp. spec.service.port is the default backend port for routes
43+
// that don't override via routing.routes[].port. Multi-backend (multiple Services
44+
// per NebariApp) is not supported by design — use multiple NebariApps instead.
3445
// +kubebuilder:validation:Required
3546
Service ServiceReference `json:"service"`
3647

@@ -63,26 +74,24 @@ type NebariAppSpec struct {
6374
LandingPage *LandingPageConfig `json:"landingPage,omitempty"`
6475
}
6576

66-
// ServiceReference identifies the Kubernetes Service that backs this application.
77+
// ServiceReference identifies a Kubernetes Service in the NebariApp's own
78+
// namespace. Cross-namespace backends are not supported: the operator-generated
79+
// HTTPRoute would require a Gateway API ReferenceGrant in the target namespace
80+
// to actually carry traffic, and the operator does not create one. Workloads
81+
// that need to talk across namespaces should do so via in-cluster DNS rather
82+
// than via the public HTTPRoute.
6783
type ServiceReference struct {
68-
// Name is the name of the Kubernetes Service in the same namespace.
84+
// Name is the name of the Kubernetes Service in the NebariApp's namespace.
6985
// +kubebuilder:validation:Required
7086
// +kubebuilder:validation:MinLength=1
7187
Name string `json:"name"`
7288

73-
// Port is the port number on the Service to route traffic to.
89+
// Port is the default port number on the Service to route traffic to.
90+
// Individual routes may override this via routing.routes[].port.
7491
// +kubebuilder:validation:Required
7592
// +kubebuilder:validation:Minimum=1
7693
// +kubebuilder:validation:Maximum=65535
7794
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"`
8695
}
8796

8897
// RoutingConfig configures routing behavior for the application.
@@ -125,7 +134,7 @@ type RoutingConfig struct {
125134
// RouteMatch defines a path-based routing rule.
126135
type RouteMatch struct {
127136
// PathPrefix specifies the path prefix to match for routing.
128-
// Traffic matching this prefix will be routed to the service.
137+
// Traffic matching this prefix will be routed to spec.service on the resolved port.
129138
// Must start with "/". Example: "/app-1", "/api/v1"
130139
// +kubebuilder:validation:Required
131140
// +kubebuilder:validation:Pattern=`^/.*`
@@ -140,6 +149,16 @@ type RouteMatch struct {
140149
// +kubebuilder:validation:Enum=PathPrefix;Exact
141150
// +optional
142151
PathType string `json:"pathType,omitempty"`
152+
153+
// Port optionally overrides the default backend port (spec.service.port)
154+
// for this route. The referenced port must be exposed by spec.service.
155+
// When omitted, the route forwards to spec.service.port. This is the only
156+
// mechanism for path-based port differentiation; per-route backend Services
157+
// are not supported (use multiple NebariApps instead).
158+
// +optional
159+
// +kubebuilder:validation:Minimum=1
160+
// +kubebuilder:validation:Maximum=65535
161+
Port *int32 `json:"port,omitempty"`
143162
}
144163

145164
// 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: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,14 @@ 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 and is backed by exactly
330+
one Kubernetes Service (spec.service). Packs that need multiple hostnames,
331+
or that genuinely need to fan out to multiple Services, must be split into
332+
multiple NebariApps. This is an intentional boundary so a NebariApp's TLS,
333+
auth, landing-page card, and routing concerns all scope to a single
334+
user-visible URL backed by a single Service. To fan out by path under one
335+
hostname to different ports on that Service, use routing.routes[].port.
328336
minLength: 1
329337
pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
330338
type: string
@@ -445,7 +453,7 @@ spec:
445453
pathPrefix:
446454
description: |-
447455
PathPrefix specifies the path prefix to match for routing.
448-
Traffic matching this prefix will be routed to the service.
456+
Traffic matching this prefix will be routed to spec.service on the resolved port.
449457
Must start with "/". Example: "/app-1", "/api/v1"
450458
pattern: ^/.*
451459
type: string
@@ -461,6 +469,17 @@ spec:
461469
- PathPrefix
462470
- Exact
463471
type: string
472+
port:
473+
description: |-
474+
Port optionally overrides the default backend port (spec.service.port)
475+
for this route. The referenced port must be exposed by spec.service.
476+
When omitted, the route forwards to spec.service.port. This is the only
477+
mechanism for path-based port differentiation; per-route backend Services
478+
are not supported (use multiple NebariApps instead).
479+
format: int32
480+
maximum: 65535
481+
minimum: 1
482+
type: integer
464483
required:
465484
- pathPrefix
466485
type: object
@@ -477,7 +496,7 @@ spec:
477496
pathPrefix:
478497
description: |-
479498
PathPrefix specifies the path prefix to match for routing.
480-
Traffic matching this prefix will be routed to the service.
499+
Traffic matching this prefix will be routed to spec.service on the resolved port.
481500
Must start with "/". Example: "/app-1", "/api/v1"
482501
pattern: ^/.*
483502
type: string
@@ -493,6 +512,17 @@ spec:
493512
- PathPrefix
494513
- Exact
495514
type: string
515+
port:
516+
description: |-
517+
Port optionally overrides the default backend port (spec.service.port)
518+
for this route. The referenced port must be exposed by spec.service.
519+
When omitted, the route forwards to spec.service.port. This is the only
520+
mechanism for path-based port differentiation; per-route backend Services
521+
are not supported (use multiple NebariApps instead).
522+
format: int32
523+
maximum: 65535
524+
minimum: 1
525+
type: integer
496526
required:
497527
- pathPrefix
498528
type: object
@@ -514,25 +544,21 @@ spec:
514544
type: object
515545
type: object
516546
service:
517-
description: Service defines the backend Kubernetes Service that should
518-
receive traffic.
547+
description: |-
548+
Service defines the single backend Kubernetes Service that receives traffic
549+
for this NebariApp. spec.service.port is the default backend port for routes
550+
that don't override via routing.routes[].port. Multi-backend (multiple Services
551+
per NebariApp) is not supported by design — use multiple NebariApps instead.
519552
properties:
520553
name:
521554
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.
555+
NebariApp's namespace.
531556
minLength: 1
532557
type: string
533558
port:
534-
description: Port is the port number on the Service to route traffic
535-
to.
559+
description: |-
560+
Port is the default port number on the Service to route traffic to.
561+
Individual routes may override this via routing.routes[].port.
536562
format: int32
537563
maximum: 65535
538564
minimum: 1

docs/api-reference.md

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -232,8 +232,8 @@ _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 /> |
236-
| `service` _[ServiceReference](#servicereference)_ | Service defines the backend Kubernetes Service that should receive traffic. | | 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 and is backed by exactly<br />one Kubernetes Service (spec.service). Packs that need multiple hostnames,<br />or that genuinely need to fan out to multiple Services, must be split into<br />multiple NebariApps. This is an intentional boundary so a NebariApp's TLS,<br />auth, landing-page card, and routing concerns all scope to a single<br />user-visible URL backed by a single Service. To fan out by path under one<br />hostname to different ports on that Service, use routing.routes[].port. | | 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 /> |
236+
| `service` _[ServiceReference](#servicereference)_ | Service defines the single backend Kubernetes Service that receives traffic<br />for this NebariApp. spec.service.port is the default backend port for routes<br />that don't override via routing.routes[].port. Multi-backend (multiple Services<br />per NebariApp) is not supported by design — use multiple NebariApps instead. | | 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 /> |
239239
| `gateway` _string_ | Gateway specifies which shared Gateway to use for routing.<br />Valid values are "public" (default) or "internal". | public | Enum: [public internal] <br />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 spec.service on the resolved port.<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+
| `port` _integer_ | Port optionally overrides the default backend port (spec.service.port)<br />for this route. The referenced port must be exposed by spec.service.<br />When omitted, the route forwards to spec.service.port. This is the only<br />mechanism for path-based port differentiation; per-route backend Services<br />are not supported (use multiple NebariApps instead). | | Maximum: 65535 <br />Minimum: 1 <br />Optional: \{\} <br /> |
292293

293294

294295
---
@@ -373,16 +374,20 @@ _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)
380386

381387
| Field | Description | Default | Validation |
382388
| --- | --- | --- | --- |
383-
| `name` _string_ | Name is the name of the Kubernetes Service in the same namespace. | | MinLength: 1 <br />Required: \{\} <br /> |
384-
| `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 /> |
389+
| `name` _string_ | Name is the name of the Kubernetes Service in the NebariApp's namespace. | | MinLength: 1 <br />Required: \{\} <br /> |
390+
| `port` _integer_ | Port is the default port number on the Service to route traffic to.<br />Individual routes may override this via routing.routes[].port. | | Maximum: 65535 <br />Minimum: 1 <br />Required: \{\} <br /> |
386391

387392

388393
---

internal/controller/reconcilers/core/reconciler.go

Lines changed: 33 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -115,43 +115,58 @@ 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 spec.service exists in the NebariApp's namespace
119+
// and exposes both the default port (spec.service.port) and any per-route port
120+
// overrides declared in routing.routes[].port and routing.publicRoutes[].port.
121+
// Cross-namespace backends are not supported; the Service is always looked up
122+
// in the NebariApp's own namespace.
120123
func ValidateService(ctx context.Context, c client.Client, nebariApp *appsv1.NebariApp) error {
121124
service := &corev1.Service{}
122-
123-
// Use specified service namespace, or default to NebariApp's namespace
124-
serviceNamespace := nebariApp.Spec.Service.Namespace
125-
if serviceNamespace == "" {
126-
serviceNamespace = nebariApp.Namespace
127-
}
128-
129125
serviceKey := client.ObjectKey{
130126
Name: nebariApp.Spec.Service.Name,
131-
Namespace: serviceNamespace,
127+
Namespace: nebariApp.Namespace,
132128
}
133129

134130
if err := c.Get(ctx, serviceKey, service); err != nil {
135131
if errors.IsNotFound(err) {
136132
return fmt.Errorf("service %s not found in namespace %s",
137-
nebariApp.Spec.Service.Name, serviceNamespace)
133+
nebariApp.Spec.Service.Name, nebariApp.Namespace)
138134
}
139135
return fmt.Errorf("failed to get service: %w", err)
140136
}
141137

142-
// Validate that the specified port exists on the service
143-
portFound := false
138+
exposed := map[int32]struct{}{}
144139
for _, port := range service.Spec.Ports {
145-
if port.Port == nebariApp.Spec.Service.Port {
146-
portFound = true
147-
break
148-
}
140+
exposed[port.Port] = struct{}{}
149141
}
150142

151-
if !portFound {
143+
if _, ok := exposed[nebariApp.Spec.Service.Port]; !ok {
152144
return fmt.Errorf("service %s does not expose port %d",
153145
nebariApp.Spec.Service.Name, nebariApp.Spec.Service.Port)
154146
}
155147

148+
if nebariApp.Spec.Routing == nil {
149+
return nil
150+
}
151+
152+
for _, route := range nebariApp.Spec.Routing.Routes {
153+
if route.Port == nil {
154+
continue
155+
}
156+
if _, ok := exposed[*route.Port]; !ok {
157+
return fmt.Errorf("route %q: service %s does not expose port %d",
158+
route.PathPrefix, nebariApp.Spec.Service.Name, *route.Port)
159+
}
160+
}
161+
for _, route := range nebariApp.Spec.Routing.PublicRoutes {
162+
if route.Port == nil {
163+
continue
164+
}
165+
if _, ok := exposed[*route.Port]; !ok {
166+
return fmt.Errorf("public route %q: service %s does not expose port %d",
167+
route.PathPrefix, nebariApp.Spec.Service.Name, *route.Port)
168+
}
169+
}
170+
156171
return nil
157172
}

0 commit comments

Comments
 (0)