Skip to content
Merged
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
20 changes: 15 additions & 5 deletions .github/workflows/operator-integration-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1220,16 +1220,26 @@ jobs:
exit 1
fi

# Expect NO ClusterRole/ClusterRoleBinding
if kubectl get clusterrole gateway-operator-manager-role; then
echo "FAILED: ClusterRole found but should be disabled in scoped mode"
# Expect NO full manager ClusterRole/ClusterRoleBinding (all permissions are namespaced)
if kubectl get clusterrole gateway-operator-manager-role 2>/dev/null; then
echo "FAILED: manager ClusterRole found but should be disabled in scoped mode"
exit 1
fi
if kubectl get clusterrolebinding gateway-operator-manager-binding; then
echo "FAILED: ClusterRoleBinding found but should be disabled in scoped mode"
if kubectl get clusterrolebinding gateway-operator-manager-rolebinding 2>/dev/null; then
echo "FAILED: manager ClusterRoleBinding found but should be disabled in scoped mode"
exit 1
fi

# GatewayClass is cluster-scoped; namespace Roles cannot grant list/watch on it.
if ! kubectl get clusterrole gateway-operator-gatewayclass-cluster-role; then
echo "FAILED: minimal GatewayClass ClusterRole not found (required when watchNamespaces is set)"
exit 1
fi
if ! kubectl get clusterrolebinding gateway-operator-gatewayclass-cluster-rolebinding; then
echo "FAILED: GatewayClass ClusterRoleBinding not found"
exit 1
fi

# 3. Positive Test: Deploy Gateway & API in scoped-test
echo "Deploying APIGateway in scoped-test..."
# Configure Gateway to use test images (same configmap as before but in new namespace)
Expand Down
313 changes: 290 additions & 23 deletions docs/gateway/kubernetes/gateway-operator.md

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions kubernetes/gateway-operator/cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,15 @@ func main() {
setupLog.Error(err, "unable to create controller", "controller", "K8sGateway")
os.Exit(1)
}
if err = controller.NewK8sGatewayClassReconciler(
mgr.GetClient(),
mgr.GetScheme(),
cfg,
zapLog,
).SetupWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "K8sGatewayClass")
os.Exit(1)
}
if err = controller.NewHTTPRouteReconciler(
mgr.GetClient(),
mgr.GetScheme(),
Expand Down
2 changes: 1 addition & 1 deletion kubernetes/gateway-operator/config/gateway_values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,7 @@ gateway:
pullPolicy: Always
imagePullSecrets: []
service:
type: ClusterIP
type: LoadBalancer
annotations: {}
labels: {}
ports:
Expand Down
30 changes: 16 additions & 14 deletions kubernetes/gateway-operator/config/rbac/role.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -79,25 +79,16 @@ rules:
- apiGroups:
- gateway.networking.k8s.io
resources:
- gateways
- gatewayclasses
- referencegrants
verbs:
- create
- delete
- get
- list
- patch
- update
- watch
- apiGroups:
- gateway.networking.k8s.io
resources:
- gateways/finalizers
- httproutes/finalizers
verbs:
- update
- apiGroups:
- gateway.networking.k8s.io
resources:
- gatewayclasses/status
- gateways/status
- httproutes/status
verbs:
Expand All @@ -107,8 +98,10 @@ rules:
- apiGroups:
- gateway.networking.k8s.io
resources:
- httproutes
- gateways
verbs:
- create
- delete
- get
- list
- patch
Expand All @@ -117,8 +110,17 @@ rules:
- apiGroups:
- gateway.networking.k8s.io
resources:
- referencegrants
- gateways/finalizers
- httproutes/finalizers
verbs:
- update
- apiGroups:
- gateway.networking.k8s.io
resources:
- httproutes
verbs:
- get
- list
- patch
- update
- watch
2 changes: 1 addition & 1 deletion kubernetes/gateway-operator/docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ Environment variable (comma-separated list, overrides the config file list):
| Annotation | Purpose |
| ---------- | ------- |
| `gateway.api-platform.wso2.com/api-version` | Version field in the generated REST payload (default `v1.0`). |
| `gateway.api-platform.wso2.com/context` | Overrides API base context when set. |
| `gateway.api-platform.wso2.com/context` | API base context; default **`/`** when omitted or whitespace-only. |
| `gateway.api-platform.wso2.com/display-name` | Overrides API display name. |
| `gateway.api-platform.wso2.com/api-handle` | REST API handle for `POST`/`PUT`/`DELETE` `/api/management/v0.9/rest-apis/{handle}` (default: `{namespace}-{name}`). |

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ This document is a **short maintainer index** for where code and behaviour live.
| Gateway API scheme registration | `cmd/main.go` (`gatewayv1.AddToScheme`, `apiv1.AddToScheme`) |
| **`APIPolicy` CRD types** | `api/v1alpha1/policy_types.go` |
| Kubernetes `Gateway` reconciler | `internal/controller/k8s_gateway_controller.go` |
| **`GatewayClass` status (Accepted)** | `internal/controller/k8s_gatewayclass_controller.go` (`PlatformGatewayControllerName` in `gateway_api_controller_name.go`) |
| `HTTPRoute` reconciler | `internal/controller/httproute_controller.go` |
| Service / `APIPolicy` / Secret → HTTPRoute enqueue | `internal/controller/httproute_enqueue.go` |
| HTTPRoute → `APIConfigData` mapping | `internal/controller/httproute_mapper.go` |
Expand Down Expand Up @@ -112,12 +113,14 @@ The overlay is applied **after** the ConfigMap overlay, so Gateway listener port
| Annotation | Meaning |
| ---------- | ------- |
| `gateway.api-platform.wso2.com/api-version` | `APIConfigData.Version` (default `v1.0`). |
| `gateway.api-platform.wso2.com/context` | Overrides API **context** path. |
| `gateway.api-platform.wso2.com/context` | API **context** path. When unset or blank after trim, defaults to **`/`** (operation paths are unchanged). |
| `gateway.api-platform.wso2.com/display-name` | Overrides display name (default: route `metadata.name`). |
| `gateway.api-platform.wso2.com/project-id` | User-defined metadata; **all** `HTTPRoute` annotations are copied verbatim into the gateway-controller `api.yaml` payload under `metadata.annotations` (same keys as on the route). |
| `gateway.api-platform.wso2.com/api-handle` | REST handle for `/api/management/v0.9/rest-apis/{handle}` (default: `{namespace}-{name}` with `/` stripped). |
| *(no HTTPRoute policy annotations)* | Policy attachment is via `APIPolicy` only (API-level when `spec.targetRef` is set; rule-scope via `ExtensionRef` when `targetRef` is omitted). |

**`spec.rules[].matches[]`:** Each match must include **`path`** (or the Gateway implementation default applies). **`method`** is optional per Gateway API. When **`method`** is set, one `APIConfigData.operations` entry is emitted for that verb and path. When **`method`** is omitted, the operator emits **seven** operations — **GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS** — each with the same path and rule-attached policies, matching the verbs defined on `RestApi` / `APIConfigData`. A rule with **`matches: []`** is still rejected (at least one explicit match entry is required).

### `APIPolicy` CR (`gateway.api-platform.wso2.com/v1alpha1`)

Recommended way to attach policies for **HTTPRoute**-backed APIs (demo: `kubernetes/helm/resources/gateway-api-httproute-policies-demo/`).
Expand Down Expand Up @@ -161,6 +164,15 @@ Before **`DeployRestAPI`**, the operator **resolves** each `valueFrom` to a sing

## Reconciler behaviour (short)

### `GatewayClass`

The operator reconciles **cluster-scoped** `GatewayClass` resources whose **`spec.controllerName`** matches **`gateway.api-platform.wso2.com/gateway-operator`** (see `PlatformGatewayControllerName` in `gateway_api_controller_name.go`). It patches **`status.conditions`:**

- **`Accepted=True`**, reason **`Accepted`**, when **`metadata.name`** is listed in the operator allowlist (`gatewayApi.managedGatewayClassNames` / `GATEWAY_API_GATEWAY_CLASS_NAMES`).
- **`Accepted=False`**, reason **`Unsupported`**, when the controller name matches but the class name is **not** in that allowlist (so `kubectl get gatewayclasses` shows **Accepted=False** instead of lingering **Unknown**).

**GatewayClass does not use a `Programmed` condition** in the Gateway API spec — that condition applies to **`Gateway`** resources (data plane readiness), not the class definition. Implementation: `internal/controller/k8s_gatewayclass_controller.go`.

### Kubernetes `Gateway`

1. Ignore resources whose `spec.gatewayClassName` is not in the managed list.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ If the Helm values ConfigMap annotation is **omitted**, the operator uses the de
| Annotation | Meaning |
| ---------- | ------- |
| `gateway.api-platform.wso2.com/api-version` | `APIConfigData.Version` (default `v1.0`). |
| `gateway.api-platform.wso2.com/context` | Overrides API **context** path. |
| `gateway.api-platform.wso2.com/context` | API **context** path (default **`/`** when unset or blank after trim). |
| `gateway.api-platform.wso2.com/display-name` | Overrides display name (default: route `metadata.name`). |
| `gateway.api-platform.wso2.com/api-handle` | REST handle for `/api/management/v0.9/rest-apis/{handle}` (default: `{namespace}-{name}` with `/` stripped). |
| *(no HTTPRoute policy annotations)* | Policy attachment is via `APIPolicy` only (API-level when `spec.targetRef` is set; rule-scope via `ExtensionRef` when `targetRef` is omitted). |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
* Copyright (c) 2026, WSO2 LLC. (http://www.wso2.org) All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/

package controller

import gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"

// PlatformGatewayControllerName is the Gateway API controller identifier for this
// operator: GatewayClass.spec.controllerName, HTTPRoute status parents, etc.
const PlatformGatewayControllerName = gatewayv1.GatewayController("gateway.api-platform.wso2.com/gateway-operator")
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,7 @@ import (
"github.com/wso2/api-platform/kubernetes/gateway-operator/internal/registry"
)

const (
httprouteFinalizer = "gateway.api-platform.wso2.com/httproute-finalizer"
httprouteControllerName = gatewayv1.GatewayController("gateway.api-platform.wso2.com/gateway-operator")
)
const httprouteFinalizer = "gateway.api-platform.wso2.com/httproute-finalizer"

// HTTPRouteReconciler maps HTTPRoute + backends to gateway-controller REST APIs.
type HTTPRouteReconciler struct {
Expand Down Expand Up @@ -522,15 +519,15 @@ func (r *HTTPRouteReconciler) patchHTTPRouteParentCondition(ctx context.Context,
idx := -1
for i := range latest.Status.Parents {
if parentRefMatches(latest.Status.Parents[i].ParentRef, parentRef, latest.Namespace) &&
latest.Status.Parents[i].ControllerName == httprouteControllerName {
latest.Status.Parents[i].ControllerName == PlatformGatewayControllerName {
idx = i
break
}
}
if idx < 0 {
latest.Status.Parents = append(latest.Status.Parents, gatewayv1.RouteParentStatus{
ParentRef: parentRef,
ControllerName: httprouteControllerName,
ControllerName: PlatformGatewayControllerName,
})
idx = len(latest.Status.Parents) - 1
}
Expand Down
73 changes: 34 additions & 39 deletions kubernetes/gateway-operator/internal/controller/httproute_mapper.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,29 @@ func IsTransientHTTPRouteConfigError(err error) bool {
return ok && e.Kind == httpRouteConfigErrorTransient
}

// allRESTAPIOperationMethods returns every HTTP verb modeled by APIConfigData / RestApi operations
// (used when an HTTPRoute match omits method, which is valid in Gateway API).
func allRESTAPIOperationMethods() []apiv1.OperationMethod {
return []apiv1.OperationMethod{
apiv1.OperationMethodGET,
apiv1.OperationMethodPOST,
apiv1.OperationMethodPUT,
apiv1.OperationMethodPATCH,
apiv1.OperationMethodDELETE,
apiv1.OperationMethodHEAD,
apiv1.OperationMethodOPTIONS,
}
}

// restAPIOperationMethodsForHTTPRouteMatch returns a single explicit method or all supported methods
// when the Gateway API match leaves method unset.
func restAPIOperationMethodsForHTTPRouteMatch(m gatewayv1.HTTPRouteMatch) []apiv1.OperationMethod {
if m.Method != nil {
return []apiv1.OperationMethod{apiv1.OperationMethod(*m.Method)}
}
return allRESTAPIOperationMethods()
}

// BuildAPIConfigFromHTTPRoute maps HTTPRoute rules to APIConfigData (MVP: single Service backend across rules).
// clusterDomain is the cluster DNS suffix (e.g. cluster.local or from CLUSTER_DOMAIN / gateway_api.cluster_domain).
// log may be nil (tests); when set, emits structured diagnostics for policy loading and mapping.
Expand Down Expand Up @@ -136,17 +159,11 @@ func BuildAPIConfigFromHTTPRoute(ctx context.Context, c client.Client, route *ga
}
if len(rule.Matches) == 0 {
return nil, newInvalidHTTPRouteConfigError(
"method-agnostic HTTPRoute matches are not supported: rule[%d] has no matches; use explicit rule.matches entries with method set on each match",
"rule[%d] has no matches; add at least one rule.matches entry (optional match.method; if omitted, all API verbs GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS are emitted)",
ruleIdx,
)
}
for matchIdx, m := range rule.Matches {
if m.Method == nil {
return nil, newInvalidHTTPRouteConfigError(
"method-agnostic HTTPRoute matches are not supported: rule[%d] match[%d] omits method; set match.method",
ruleIdx, matchIdx,
)
}
for _, m := range rule.Matches {
pathVal := "/"
if m.Path != nil && m.Path.Value != nil {
p := strings.TrimSpace(*m.Path.Value)
Expand All @@ -157,12 +174,14 @@ func BuildAPIConfigFromHTTPRoute(ctx context.Context, c client.Client, route *ga
}
}
}
method := apiv1.OperationMethod(*m.Method)
ops = append(ops, apiv1.Operation{
Method: method,
Path: pathVal,
Policies: copyPolicies(rulePolicies),
})
methods := restAPIOperationMethodsForHTTPRouteMatch(m)
for _, method := range methods {
ops = append(ops, apiv1.Operation{
Method: method,
Path: pathVal,
Policies: copyPolicies(rulePolicies),
})
}
}
}

Expand All @@ -172,10 +191,7 @@ func BuildAPIConfigFromHTTPRoute(ctx context.Context, c client.Client, route *ga

contextPath := strings.TrimSpace(route.Annotations[AnnHTTPRouteContext])
if contextPath == "" {
contextPath = commonPathPrefix(ops)
if contextPath == "" {
contextPath = "/"
}
contextPath = "/"
} else if !strings.HasPrefix(contextPath, "/") {
contextPath = "/" + contextPath
}
Expand Down Expand Up @@ -308,27 +324,6 @@ func resolveServicePort(svc *corev1.Service, refPort *gatewayv1.PortNumber) (int
return ports[0].Port, nil
}

func commonPathPrefix(ops []apiv1.Operation) string {
if len(ops) == 0 {
return "/"
}
prefix := ops[0].Path
for _, o := range ops[1:] {
prefix = sharedPrefix(prefix, o.Path)
if prefix == "" || prefix == "/" {
return "/"
}
}
if prefix == "" {
return "/"
}
// Strip trailing slash except root
for len(prefix) > 1 && strings.HasSuffix(prefix, "/") {
prefix = strings.TrimSuffix(prefix, "/")
}
return prefix
}

func sharedPrefix(a, b string) string {
if !strings.HasPrefix(a, "/") {
a = "/" + a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ func TestBuildAPIConfigFromHTTPRoute_ContextAnnotationTrimAndNormalize(t *testin
pathVal := "/api/hello"
method := gatewayv1.HTTPMethodGet

t.Run("whitespace-only treated as unset => fallback commonPathPrefix", func(t *testing.T) {
t.Run("whitespace-only treated as unset => default context /", func(t *testing.T) {
route := &gatewayv1.HTTPRoute{
ObjectMeta: metav1.ObjectMeta{
Name: "my-route",
Expand Down Expand Up @@ -137,7 +137,7 @@ func TestBuildAPIConfigFromHTTPRoute_ContextAnnotationTrimAndNormalize(t *testin

spec, err := BuildAPIConfigFromHTTPRoute(context.Background(), cl, route, "cluster.local", nil)
require.NoError(t, err)
require.Equal(t, "/api/hello", spec.Context)
require.Equal(t, "/", spec.Context)
})

t.Run("missing leading slash normalized", func(t *testing.T) {
Expand Down Expand Up @@ -307,7 +307,7 @@ func TestBuildAPIConfigFromHTTPRoute_APIPolicyRuleExtensionRef(t *testing.T) {
require.Equal(t, "ext-ref-policy", spec.Operations[0].Policies[0].Name)
}

func TestBuildAPIConfigFromHTTPRoute_RequiresExplicitMethod(t *testing.T) {
func TestBuildAPIConfigFromHTTPRoute_MatchMethodOptionalAndEmptyMatchesRejected(t *testing.T) {
scheme := runtime.NewScheme()
utilruntime.Must(corev1.AddToScheme(scheme))
utilruntime.Must(gatewayv1.AddToScheme(scheme))
Expand Down Expand Up @@ -347,7 +347,7 @@ func TestBuildAPIConfigFromHTTPRoute_RequiresExplicitMethod(t *testing.T) {
require.Contains(t, err.Error(), "no matches")
})

t.Run("nil match method", func(t *testing.T) {
t.Run("omitted match method expands to all RestApi operation methods", func(t *testing.T) {
route := &gatewayv1.HTTPRoute{
ObjectMeta: metav1.ObjectMeta{Name: "r2", Namespace: "default"},
Spec: gatewayv1.HTTPRouteSpec{
Expand All @@ -366,11 +366,14 @@ func TestBuildAPIConfigFromHTTPRoute_RequiresExplicitMethod(t *testing.T) {
},
},
}
_, err := BuildAPIConfigFromHTTPRoute(context.Background(), cl, route, "cluster.local", nil)
require.Error(t, err)
require.True(t, IsInvalidHTTPRouteConfigError(err))
require.Contains(t, err.Error(), "rule[0] match[0]")
require.Contains(t, err.Error(), "omits method")
spec, err := BuildAPIConfigFromHTTPRoute(context.Background(), cl, route, "cluster.local", nil)
require.NoError(t, err)
require.Len(t, spec.Operations, len(allRESTAPIOperationMethods()))
want := allRESTAPIOperationMethods()
for i := range want {
require.Equal(t, want[i], spec.Operations[i].Method)
require.Equal(t, "/x", spec.Operations[i].Path)
}
})
}

Expand Down
Loading
Loading