Skip to content

Commit 536bc0e

Browse files
feat: cross namespace support for aigatewayroute and aiservicebackend (#1361)
**Description** This PR implements comprehensive cross-namespace access support between AIServiceBackend and AIGatewayRoute resources, following Gateway API patterns with ReferenceGrant validation. **API Enhancements** - Added namespace field to AIGatewayRouteRuleBackendRef allowing cross-namespace backend references - Updated CRDs to include proper validation for the namespace field Enhanced documentation explaining ReferenceGrant requirements for cross-namespace access **ReferenceGrant Integration** - ReferenceGrant Validator: New component that validates cross-namespace references against ReferenceGrant resources - ReferenceGrant Controller: Watches ReferenceGrant resources and triggers reconciliation of affected AIGatewayRoutes when grants change - Automatic validation: Cross-namespace references are automatically validated at reconciliation time **Controller Updates** - Cross-namespace backend lookups: AIGatewayRoute controller now supports referencing AIServiceBackend resources in different namespaces - ReferenceGrant enforcement: Fails reconciliation with clear error messages when valid ReferenceGrant is missing - Event-driven reconciliation: ReferenceGrant changes trigger automatic reconciliation of affected routes **Comprehensive Testing** - Unit tests: Full coverage for ReferenceGrant validation logic, cross-namespace scenarios, and error cases - Integration tests: Controller tests validating cross-namespace backend references with and without ReferenceGrant - E2E test updates: Modified existing cross-namespace test to validate AIServiceBackend cross-namespace access with ReferenceGrant AIGatewayRouteBackendRefChange: ``` # AIGatewayRouteRuleBackendRef now supports: backendRefs: - name: my-backend namespace: other-namespace # NEW: Cross-namespace reference weight: 1 ``` Reference Grant Example: ``` apiVersion: gateway.networking.k8s.io/v1beta1 kind: ReferenceGrant metadata: name: allow-route-to-backend namespace: backend-ns spec: from: - group: aigateway.envoyproxy.io kind: AIGatewayRoute namespace: route-ns to: - group: aigateway.envoyproxy.io kind: AIServiceBackend ``` **Related Issues/PRs (if applicable)** close #1374 --------- Signed-off-by: siddharth1036 <[email protected]>
1 parent 9bdd87e commit 536bc0e

17 files changed

+1848
-41
lines changed

api/v1alpha1/ai_gateway_route.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,11 @@ type AIGatewayRouteRule struct {
200200
// BackendRefs is the list of backends that this rule will route the traffic to.
201201
// Each backend can have a weight that determines the traffic distribution.
202202
//
203-
// The namespace of each backend is "local", i.e. the same namespace as the AIGatewayRoute.
203+
// The namespace of each backend defaults to the same namespace as the AIGatewayRoute when not specified.
204+
// Cross-namespace references are supported by specifying the namespace field.
205+
// When a namespace different than the AIGatewayRoute's namespace is specified,
206+
// a ReferenceGrant object is required in the referent namespace to allow that
207+
// namespace's owner to accept the reference.
204208
//
205209
// BackendRefs can reference either AIServiceBackend resources (default) or InferencePool resources
206210
// from the Gateway API Inference Extension. When referencing InferencePool resources:
@@ -278,6 +282,17 @@ type AIGatewayRouteRuleBackendRef struct {
278282
// +kubebuilder:validation:MinLength=1
279283
Name string `json:"name"`
280284

285+
// Namespace is the namespace of the backend resource.
286+
// When unspecified (or empty string), this refers to the local namespace of the AIGatewayRoute.
287+
//
288+
// Note that when a namespace different than the local namespace is specified,
289+
// a ReferenceGrant object is required in the referent namespace to allow that
290+
// namespace's owner to accept the reference. See the ReferenceGrant
291+
// documentation for details.
292+
//
293+
// +optional
294+
Namespace *gwapiv1.Namespace `json:"namespace,omitempty"`
295+
281296
// Group is the group of the backend resource.
282297
// When not specified, defaults to aigateway.envoyproxy.io (AIServiceBackend).
283298
// Currently, only "inference.networking.k8s.io" is supported for InferencePool resources.

api/v1alpha1/ai_gateway_route_helper.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,21 @@ func (r *AIGatewayRouteRule) HasAIServiceBackends() bool {
8282
}
8383
return false
8484
}
85+
86+
// GetNamespace returns the namespace for the backend reference.
87+
// If the namespace is not specified, it returns the provided defaultNamespace.
88+
func (ref *AIGatewayRouteRuleBackendRef) GetNamespace(defaultNamespace string) string {
89+
if ref.Namespace != nil && *ref.Namespace != "" {
90+
return string(*ref.Namespace)
91+
}
92+
return defaultNamespace
93+
}
94+
95+
// IsCrossNamespace returns true if the backend reference is a cross-namespace reference.
96+
// A cross-namespace reference is one where the namespace field is specified and differs from the routeNamespace.
97+
func (ref *AIGatewayRouteRuleBackendRef) IsCrossNamespace(routeNamespace string) bool {
98+
if ref.Namespace == nil || *ref.Namespace == "" {
99+
return false
100+
}
101+
return string(*ref.Namespace) != routeNamespace
102+
}

api/v1alpha1/ai_gateway_route_helper_test.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,11 @@ func TestAIGatewayRouteRuleBackendRef_IsInferencePool(t *testing.T) {
8080
ref *AIGatewayRouteRuleBackendRef
8181
expected bool
8282
}{
83+
{
84+
name: "Nil reference",
85+
ref: nil,
86+
expected: false,
87+
},
8388
{
8489
name: "AIServiceBackend reference (no group/kind)",
8590
ref: &AIGatewayRouteRuleBackendRef{
@@ -161,6 +166,11 @@ func TestAIGatewayRouteRule_HasInferencePoolBackends(t *testing.T) {
161166
rule *AIGatewayRouteRule
162167
expected bool
163168
}{
169+
{
170+
name: "Nil rule",
171+
rule: nil,
172+
expected: false,
173+
},
164174
{
165175
name: "No backends",
166176
rule: &AIGatewayRouteRule{
@@ -221,6 +231,11 @@ func TestAIGatewayRouteRule_HasAIServiceBackends(t *testing.T) {
221231
rule *AIGatewayRouteRule
222232
expected bool
223233
}{
234+
{
235+
name: "Nil rule",
236+
rule: nil,
237+
expected: false,
238+
},
224239
{
225240
name: "No backends",
226241
rule: &AIGatewayRouteRule{
@@ -260,3 +275,107 @@ func TestAIGatewayRouteRule_HasAIServiceBackends(t *testing.T) {
260275
})
261276
}
262277
}
278+
279+
func TestAIGatewayRouteRuleBackendRef_GetNamespace(t *testing.T) {
280+
tests := []struct {
281+
name string
282+
ref *AIGatewayRouteRuleBackendRef
283+
defaultNamespace string
284+
expected string
285+
}{
286+
{
287+
name: "No namespace specified - should use default",
288+
ref: &AIGatewayRouteRuleBackendRef{
289+
Name: "test-backend",
290+
},
291+
defaultNamespace: "default",
292+
expected: "default",
293+
},
294+
{
295+
name: "Empty namespace specified - should use default",
296+
ref: &AIGatewayRouteRuleBackendRef{
297+
Name: "test-backend",
298+
Namespace: ptr.To(gwapiv1.Namespace("")),
299+
},
300+
defaultNamespace: "default",
301+
expected: "default",
302+
},
303+
{
304+
name: "Specific namespace specified - should use specified namespace",
305+
ref: &AIGatewayRouteRuleBackendRef{
306+
Name: "test-backend",
307+
Namespace: ptr.To(gwapiv1.Namespace("other-namespace")),
308+
},
309+
defaultNamespace: "default",
310+
expected: "other-namespace",
311+
},
312+
{
313+
name: "Same namespace as default - should use specified namespace",
314+
ref: &AIGatewayRouteRuleBackendRef{
315+
Name: "test-backend",
316+
Namespace: ptr.To(gwapiv1.Namespace("default")),
317+
},
318+
defaultNamespace: "default",
319+
expected: "default",
320+
},
321+
}
322+
323+
for _, tt := range tests {
324+
t.Run(tt.name, func(t *testing.T) {
325+
result := tt.ref.GetNamespace(tt.defaultNamespace)
326+
require.Equal(t, tt.expected, result)
327+
})
328+
}
329+
}
330+
331+
func TestAIGatewayRouteRuleBackendRef_IsCrossNamespace(t *testing.T) {
332+
tests := []struct {
333+
name string
334+
ref *AIGatewayRouteRuleBackendRef
335+
routeNamespace string
336+
expected bool
337+
}{
338+
{
339+
name: "No namespace specified - not cross-namespace",
340+
ref: &AIGatewayRouteRuleBackendRef{
341+
Name: "test-backend",
342+
},
343+
routeNamespace: "default",
344+
expected: false,
345+
},
346+
{
347+
name: "Empty namespace specified - not cross-namespace",
348+
ref: &AIGatewayRouteRuleBackendRef{
349+
Name: "test-backend",
350+
Namespace: ptr.To(gwapiv1.Namespace("")),
351+
},
352+
routeNamespace: "default",
353+
expected: false,
354+
},
355+
{
356+
name: "Same namespace as route - not cross-namespace",
357+
ref: &AIGatewayRouteRuleBackendRef{
358+
Name: "test-backend",
359+
Namespace: ptr.To(gwapiv1.Namespace("default")),
360+
},
361+
routeNamespace: "default",
362+
expected: false,
363+
},
364+
{
365+
name: "Different namespace from route - is cross-namespace",
366+
ref: &AIGatewayRouteRuleBackendRef{
367+
Name: "test-backend",
368+
Namespace: ptr.To(gwapiv1.Namespace("other-namespace")),
369+
},
370+
routeNamespace: "default",
371+
expected: true,
372+
},
373+
}
374+
375+
for _, tt := range tests {
376+
t.Run(tt.name, func(t *testing.T) {
377+
result := tt.ref.IsCrossNamespace(tt.routeNamespace)
378+
require.Equal(t, tt.expected, result)
379+
})
380+
}
381+
}

api/v1alpha1/zz_generated.deepcopy.go

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

internal/controller/ai_gateway_route.go

Lines changed: 58 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ type AIGatewayRouteController struct {
5858
gatewayEventChan chan event.GenericEvent
5959
// rootPrefix is the prefix for the root path of the AI Gateway.
6060
rootPrefix string
61+
// referenceGrantValidator validates cross-namespace references using ReferenceGrant.
62+
referenceGrantValidator *referenceGrantValidator
6163
}
6264

6365
// NewAIGatewayRouteController creates a new reconcile.TypedReconciler[reconcile.Request] for the AIGatewayRoute resource.
@@ -67,11 +69,12 @@ func NewAIGatewayRouteController(
6769
rootPrefix string,
6870
) *AIGatewayRouteController {
6971
return &AIGatewayRouteController{
70-
client: client,
71-
kube: kube,
72-
logger: logger,
73-
gatewayEventChan: gatewayEventChan,
74-
rootPrefix: rootPrefix,
72+
client: client,
73+
kube: kube,
74+
logger: logger,
75+
gatewayEventChan: gatewayEventChan,
76+
rootPrefix: rootPrefix,
77+
referenceGrantValidator: newReferenceGrantValidator(client),
7578
}
7679
}
7780

@@ -252,7 +255,8 @@ func (c *AIGatewayRouteController) newHTTPRoute(ctx context.Context, dst *gwapiv
252255
var backendRefs []gwapiv1.HTTPBackendRef
253256
for j := range rule.BackendRefs {
254257
br := &rule.BackendRefs[j]
255-
dstName := fmt.Sprintf("%s.%s", br.Name, aiGatewayRoute.Namespace)
258+
backendNamespace := br.GetNamespace(aiGatewayRoute.Namespace)
259+
dstName := fmt.Sprintf("%s.%s", br.Name, backendNamespace)
256260

257261
if br.IsInferencePool() {
258262
// Handle InferencePool backend reference.
@@ -268,14 +272,28 @@ func (c *AIGatewayRouteController) newHTTPRoute(ctx context.Context, dst *gwapiv
268272
}},
269273
)
270274
} else {
271-
// Handle AIServiceBackend reference.
272-
backend, err := c.backend(ctx, aiGatewayRoute.Namespace, br.Name)
275+
// Handle AIServiceBackend reference with cross-namespace validation.
276+
backend, err := c.validateAndGetBackend(ctx, aiGatewayRoute, br)
273277
if err != nil {
274-
return fmt.Errorf("AIServiceBackend %s not found", dstName)
278+
return fmt.Errorf("failed to get AIServiceBackend %s: %w", dstName, err)
275279
}
280+
281+
// Copy the BackendObjectReference from the AIServiceBackend.
282+
backendObjRef := backend.Spec.BackendRef
283+
284+
// Ensure the namespace is explicitly set in the BackendObjectReference
285+
// only for cross-namespace references.
286+
// If the AIServiceBackend is in a different namespace than the AIGatewayRoute,
287+
// the Backend it references is also in that namespace, and we need to set
288+
// the namespace explicitly in the HTTPRoute's backendRef.
289+
if backendObjRef.Namespace == nil && backend.Namespace != "" && backend.Namespace != aiGatewayRoute.Namespace {
290+
ns := gwapiv1.Namespace(backend.Namespace)
291+
backendObjRef.Namespace = &ns
292+
}
293+
276294
backendRefs = append(backendRefs,
277295
gwapiv1.HTTPBackendRef{BackendRef: gwapiv1.BackendRef{
278-
BackendObjectReference: backend.Spec.BackendRef,
296+
BackendObjectReference: backendObjRef,
279297
Weight: br.Weight,
280298
}},
281299
)
@@ -372,6 +390,36 @@ func (c *AIGatewayRouteController) backend(ctx context.Context, namespace, name
372390
return backend, nil
373391
}
374392

393+
// validateAndGetBackend validates a backend reference (including cross-namespace ReferenceGrant check)
394+
// and returns the AIServiceBackend if valid.
395+
func (c *AIGatewayRouteController) validateAndGetBackend(
396+
ctx context.Context,
397+
aiGatewayRoute *aigv1a1.AIGatewayRoute,
398+
backendRef *aigv1a1.AIGatewayRouteRuleBackendRef,
399+
) (*aigv1a1.AIServiceBackend, error) {
400+
backendNamespace := backendRef.GetNamespace(aiGatewayRoute.Namespace)
401+
402+
// Validate cross-namespace reference if applicable
403+
if backendRef.IsCrossNamespace(aiGatewayRoute.Namespace) {
404+
if err := c.referenceGrantValidator.validateAIServiceBackendReference(
405+
ctx,
406+
aiGatewayRoute.Namespace,
407+
backendNamespace,
408+
backendRef.Name,
409+
); err != nil {
410+
return nil, err
411+
}
412+
}
413+
414+
// Get the backend
415+
backend, err := c.backend(ctx, backendNamespace, backendRef.Name)
416+
if err != nil {
417+
return nil, fmt.Errorf("AIServiceBackend %s.%s not found", backendRef.Name, backendNamespace)
418+
}
419+
420+
return backend, nil
421+
}
422+
375423
// updateAIGatewayRouteStatus updates the status of the AIGatewayRoute.
376424
func (c *AIGatewayRouteController) updateAIGatewayRouteStatus(ctx context.Context, route *aigv1a1.AIGatewayRoute, conditionType string, message string) {
377425
err := retry.RetryOnConflict(retry.DefaultRetry, func() error {

0 commit comments

Comments
 (0)