Skip to content
17 changes: 17 additions & 0 deletions deploy/Chart/templates/rp/rbac.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,23 @@ rules:
- patch
- update
- watch
- apiGroups:
- gateway.networking.k8s.io
resources:
- gateways
- httproutes
- tlsroutes
- tcproutes
- udproutes
- referencegrants
verbs:
- create
- delete
- get
- list
- patch
- update
- watch
- apiGroups:
- secrets-store.csi.x-k8s.io
resources:
Expand Down
210 changes: 210 additions & 0 deletions eng/design-notes/recipes/2026-05-contour-recipe-packs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
# Move Contour Routing to Gateway API Recipes

* **Author**: Will Smith (@willdavsmith)

## Overview

Radius currently installs Contour by default on Kubernetes and has built-in code that renders Contour `HTTPProxy` resources for application ingress. This design moves application route rendering out of Radius core and into recipes, using Kubernetes Gateway API as the default Contour-backed path.

Radius keeps installing Contour by default for now. When Contour install is enabled, Radius also creates the shared Gateway API infrastructure used by the default route recipe:

- `GatewayClass/contour`
- `Gateway/radius` in `radius-system`
- HTTP listener on port 80 with routes allowed from application namespaces

With that infrastructure in place, the default `Radius.Compute/routes` recipe can create Gateway API route resources such as `HTTPRoute` and attach them to the shared `radius-system/radius` Gateway.

## Current Radius Behavior

Today `rad install kubernetes` installs Contour by default after installing the Radius Helm chart. The install command wires this through the existing Contour chart options:

- Helm release name: `contour`
- Namespace: `radius-system`
- Chart repository: `https://projectcontour.github.io/helm-charts`
- Default chart version: `0.1.0`
- Opt-out flag: `rad install kubernetes --skip-contour-install`

Radius also includes built-in Kubernetes rendering for Contour `HTTPProxy` resources. Gateway rendering creates a root `HTTPProxy`, route rendering creates child `HTTPProxy` resources, and the Radius RP ClusterRole includes permissions for `projectcontour.io/httpproxies`.

This change keeps default Contour installation in place, but replaces the default application routing implementation with Gateway API recipes. When users opt out with `rad install kubernetes --skip-contour-install`, Radius skips both Contour installation and the managed Contour Gateway API setup. Removing Contour from the default install remains a separate design review decision.

Radius already has a default recipe pack experience for development scenarios. `rad init --preview` creates a default recipe pack named `default` in `/planes/radius/local/resourceGroups/default` and links it to the created environment. `rad deploy` also creates or fetches that default recipe pack and injects it into environment resources that do not specify recipe packs.

## Objectives

> **Issue Reference:** https://github.com/radius-project/radius/issues/11952

### Goals

- Keep Contour installed by default for now.
- Create the shared Contour Gateway API `Gateway` during Radius install when Contour install is enabled, with HTTP and HTTPS listeners for existing HTTPProxy behavior.
- Use the existing `Radius.Compute/routes` recipe to render Gateway API route resources by default.
- Keep Gateway API infrastructure out of the application model in the default path.
- Allow users to swap ingress behavior by changing recipe packs.

### Non goals

- Do not change the Radius application resource model.
- Do not remove Contour from the default Radius install as part of this change.
- Do not require users to define a gateway resource in application Bicep.

## User Experience

Users deploy routes with the existing `Radius.Compute/routes` resource. They do not need to define an application-level gateway resource for the default Contour path.

```bicep
resource route 'Radius.Compute/routes@2025-08-01-preview' = {
name: 'web'
properties: {
application: app.id
environment: environment
kind: 'HTTP'
hostnames: [
'web.example.com'
]
rules: [
{
matches: [
{
httpPath: '/'
}
]
destinationContainer: {
resourceId: web.id
containerName: 'web'
containerPort: 80
}
}
]
}
}
```

The default route recipe attaches HTTP and TLS routes to `Gateway/radius` in `radius-system`. Users who want a different Gateway API controller, such as NGINX Gateway Fabric, can select a different recipe pack or pass recipe parameters that target a different Gateway.

@sk593 sk593 Jun 8, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just confirming, the routes recipe has support for TCP and UDP routes depending on the application template. The expectation is that this would not be supported with a Contour installation, correct (I don't think Contour natively supports these)? i.e. we'd use either HTTP or the TLS routing with the Gateway API for default Contour installations


## Design

The default Kubernetes route path becomes:

```text
Radius install with Contour enabled -> GatewayClass/contour + Gateway/radius
Radius.Compute/containers -> Kubernetes Deployment + Service
Radius.Compute/routes -> Gateway API HTTPRoute/TLSRoute/TCPRoute/UDPRoute
```

The route recipe defaults are:

- `gateway_name`: `radius`
- `gateway_namespace`: `radius-system`

For HTTP and TLS routes, the route must include at least one hostname when attaching to the shared default Gateway. This prevents multiple applications from unintentionally claiming the same catch-all listener.

The Radius dynamic RP needs permission to manage Gateway API route resources:

```yaml
apiGroups:
- gateway.networking.k8s.io
resources:
- gateways
- httproutes
- tlsroutes
- tcproutes
- udproutes
- referencegrants
verbs:
- get
- list
- watch
- create
- update
- patch
- delete
```

## Default Recipe Registration

The existing `default` recipe pack should use the Gateway API `Radius.Compute/routes` recipe. Contour installation and recipe selection are separate concerns:

- Installing Contour adds the ingress controller and Gateway API support to the cluster.
- Radius install creates the shared Contour `Gateway` only when Contour install is enabled.
- The default route recipe renders application routes that attach to that Gateway.

Today the default recipe pack follows the Radius version channel, including `latest` on the edge channel. A future hardening step should pin default recipes to the Radius release or another explicit artifact version so the default experience does not depend on floating recipe artifacts.

If Radius later stops installing Contour by default, default Gateway creation and default route recipe selection should be revisited together.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could use PM input here. We might not need to revisit the design it if the expectation is that users bring their own networking infrastructure. This would just need to be called out explicitly in documentation


## API Design

No Radius API changes are required.

This design uses existing resource types:

- `Radius.Compute/routes@2025-08-01-preview`
- `Radius.Compute/containers@2025-08-01-preview`
- `Radius.Core/recipePacks@2025-08-01-preview`

## Implementation Details

Radius should:

- Continue installing Contour by default unless `--skip-contour-install` is set.
- Create or update the default Contour `GatewayClass` and `Gateway` after Contour installation.
- Delete the managed default `Gateway` and `GatewayClass` during uninstall.
- Grant the dynamic RP Gateway API permissions.

`resource-types-contrib` should:

- Keep the Kubernetes container recipe rendering workload and service resources.
- Use Gateway API as the default Kubernetes route recipe.
- Default the route recipe to `Gateway/radius` in `radius-system`.
- Validate that HTTP and TLS routes include hostnames when using the shared Gateway.

## Error Handling

- If Contour is not installed, the default shared Gateway is not created.
- If the recipe execution identity lacks Gateway API RBAC, route deployment fails.
- If a route has no hostname for HTTP or TLS, the default route recipe fails validation.
- If a rendered Gateway API route is invalid, e2e tests should dump the route status and gateway diagnostics.

## Test Plan

The demo validates the default recipe shape end to end:

- Contour Gateway API recipes: https://github.com/willdavsmith/radius-nginx-demo/actions/runs/26665457465
- NGINX Gateway API recipes: https://github.com/willdavsmith/radius-nginx-demo/actions/runs/26665457417

## Security

The main security consideration is Kubernetes RBAC. The recipe execution identity needs explicit permissions for Gateway API route resources.

Because the default Gateway allows routes from application namespaces, route hostnames are required for HTTP and TLS routes. This avoids accidental catch-all route attachment to the shared Gateway.

Recipe artifacts should be published from trusted locations. The local registry and module server used in the demo are test infrastructure, not a production distribution model.

## Compatibility

Keeping Contour installed by default preserves the default install experience. The application model remains stable because users continue defining containers and routes.

This changes the Kubernetes ingress implementation from Contour `HTTPProxy` to Gateway API route resources. Users who require direct HTTPProxy behavior can use an alternate recipe pack, but the default path should be Gateway API because it works with Contour today and lets users swap Gateway API controllers without Radius core changes.

## Development Plan

1. Add default Contour Gateway API infrastructure creation to Radius install and cleanup to uninstall.
2. Grant Gateway API permissions to the dynamic RP.
3. Update the default Kubernetes route recipe to attach to `radius-system/radius`.
4. Validate Contour Gateway API and NGINX Gateway API e2e paths in the demo.
5. Review default Contour installation separately.

## Alternatives Considered

### Preserve HTTPProxy as the default recipe path

This matches the current implementation more closely, but it keeps the default path tied to Contour-specific APIs. Gateway API gives Radius the same application shape while allowing alternate Gateway API controllers through recipe packs.

### Keep Contour rendering in Radius core

This preserves the current implementation but prevents users from swapping ingress behavior through recipes.

## Design Review Notes

Pending.
17 changes: 17 additions & 0 deletions pkg/cli/helm/cluster.go
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,11 @@ func (i *Impl) InstallRadius(ctx context.Context, clusterOptions ClusterOptions,
return fmt.Errorf("failed to apply Contour Helm chart, err: %w", err)
}

output.LogInfo("Configuring Radius Contour Gateway...")
if err := configureDefaultContourGateway(ctx, kubeContext); err != nil {
return fmt.Errorf("failed to configure Radius Contour Gateway, err: %w", err)
}

return nil
}

Expand All @@ -256,6 +261,13 @@ func (i *Impl) UninstallRadius(ctx context.Context, clusterOptions ClusterOption
return err
}

if !clusterOptions.Contour.Disabled {
output.LogInfo("Deleting Radius Contour Gateway...")
if err := removeDefaultContourGateway(ctx, kubeContext); err != nil {
return fmt.Errorf("failed to delete Radius Contour Gateway, err: %w", err)
}
}

// Uninstall Contour
if err := i.uninstallHelmRelease("Contour", contourReleaseName, clusterOptions.Radius.Namespace, kubeContext); err != nil {
return err
Expand Down Expand Up @@ -372,6 +384,11 @@ func (i *Impl) UpgradeRadius(ctx context.Context, clusterOptions ClusterOptions,
}
output.LogInfo("Contour upgrade complete")

output.LogInfo("Configuring Radius Contour Gateway...")
if err := configureDefaultContourGateway(ctx, kubeContext); err != nil {
return fmt.Errorf("failed to configure Radius Contour Gateway, err: %w", err)
}

return nil
}

Expand Down
52 changes: 43 additions & 9 deletions pkg/cli/helm/cluster_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,11 @@ import (
)

func Test_Helm_InstallRadius(t *testing.T) {
t.Parallel()

ctrl := gomock.NewController(t)
defer ctrl.Finish()

mockHelmClient := NewMockHelmClient(ctrl)
gatewayCalls := stubDefaultContourGateway(t)
impl := &Impl{Helm: mockHelmClient}
ctx := context.Background()
kubeContext := "test-context"
Expand Down Expand Up @@ -87,15 +86,16 @@ func Test_Helm_InstallRadius(t *testing.T) {

err := impl.InstallRadius(ctx, options, kubeContext)
require.NoError(t, err)
require.Equal(t, 1, gatewayCalls.configureCalls)
require.Equal(t, kubeContext, gatewayCalls.kubeContext)
}

func Test_Helm_UninstallRadius(t *testing.T) {
t.Parallel()

ctrl := gomock.NewController(t)
defer ctrl.Finish()

mockHelmClient := NewMockHelmClient(ctrl)
gatewayCalls := stubDefaultContourGateway(t)
impl := &Impl{Helm: mockHelmClient}
ctx := context.Background()
kubeContext := "test-context"
Expand All @@ -117,15 +117,16 @@ func Test_Helm_UninstallRadius(t *testing.T) {

err := impl.UninstallRadius(ctx, options, kubeContext)
require.NoError(t, err)
require.Equal(t, 1, gatewayCalls.removeCalls)
require.Equal(t, kubeContext, gatewayCalls.kubeContext)
}

func Test_Helm_UninstallRadius_ReleaseNotFound(t *testing.T) {
t.Parallel()

ctrl := gomock.NewController(t)
defer ctrl.Finish()

mockHelmClient := NewMockHelmClient(ctrl)
stubDefaultContourGateway(t)
impl := &Impl{Helm: mockHelmClient}
ctx := context.Background()
kubeContext := "test-context"
Expand Down Expand Up @@ -196,6 +197,38 @@ func Test_Helm_CheckRadiusInstall(t *testing.T) {
require.Equal(t, "", state.ContourVersion)
}

type defaultContourGatewayCalls struct {
configureCalls int
removeCalls int
kubeContext string
}

func stubDefaultContourGateway(t *testing.T) *defaultContourGatewayCalls {
t.Helper()

originalConfigure := configureDefaultContourGateway
originalRemove := removeDefaultContourGateway
calls := &defaultContourGatewayCalls{}

configureDefaultContourGateway = func(ctx context.Context, kubeContext string) error {
calls.configureCalls++
calls.kubeContext = kubeContext
return nil
}
removeDefaultContourGateway = func(ctx context.Context, kubeContext string) error {
calls.removeCalls++
calls.kubeContext = kubeContext
return nil
}

t.Cleanup(func() {
configureDefaultContourGateway = originalConfigure
removeDefaultContourGateway = originalRemove
})

return calls
}

func Test_Helm_CheckRadiusInstall_ErrorOnQuery(t *testing.T) {
t.Parallel()

Expand All @@ -220,12 +253,11 @@ func Test_Helm_CheckRadiusInstall_ErrorOnQuery(t *testing.T) {
}

func Test_Helm_UpgradeRadius(t *testing.T) {
t.Parallel()

ctrl := gomock.NewController(t)
defer ctrl.Finish()

mockHelmClient := NewMockHelmClient(ctrl)
gatewayCalls := stubDefaultContourGateway(t)
impl := &Impl{Helm: mockHelmClient}
ctx := context.Background()
kubeContext := "test-context"
Expand Down Expand Up @@ -266,7 +298,7 @@ func Test_Helm_UpgradeRadius(t *testing.T) {
mockHelmClient.EXPECT().
RunHelmHistory(gomock.AssignableToTypeOf(&helm.Configuration{}), options.Radius.ReleaseName).
Return([]*release.Release{radiusRelease}, nil).Times(1)

contourRelease := newRel(options.Contour.ReleaseName, "0.1.0")
mockHelmClient.EXPECT().
RunHelmGet(gomock.AssignableToTypeOf(&helm.Configuration{}), options.Contour.ReleaseName).
Expand All @@ -291,6 +323,8 @@ func Test_Helm_UpgradeRadius(t *testing.T) {

err := impl.UpgradeRadius(ctx, options, kubeContext)
require.NoError(t, err)
require.Equal(t, 1, gatewayCalls.configureCalls)
require.Equal(t, kubeContext, gatewayCalls.kubeContext)
}

func Test_Helm_UpgradeRadius_ContourNotInstalled(t *testing.T) {
Expand Down
Loading
Loading