diff --git a/.github/workflows/operator-integration-test.yml b/.github/workflows/operator-integration-test.yml index c87e20492..046a5bac8 100644 --- a/.github/workflows/operator-integration-test.yml +++ b/.github/workflows/operator-integration-test.yml @@ -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) diff --git a/docs/gateway/kubernetes/gateway-operator.md b/docs/gateway/kubernetes/gateway-operator.md index 652d92276..85f1a5536 100644 --- a/docs/gateway/kubernetes/gateway-operator.md +++ b/docs/gateway/kubernetes/gateway-operator.md @@ -1,16 +1,29 @@ # Gateway Operator -The WSO2 API Platform Gateway Operator enables native Kubernetes deployment using a GitOps-friendly, operator-based model. It manages the full lifecycle of API gateways and REST APIs through custom resources. +The WSO2 API Platform Gateway Operator enables native Kubernetes deployment using a GitOps-friendly, operator-based model. It manages the full lifecycle of API gateways and REST APIs. You can use **either** platform CRDs **or** the **Kubernetes Gateway API** on the same operator build. ## Overview -The Gateway Operator watches for two custom resource types: +### Path A — Platform CRDs (`APIGateway` + `RestApi`) | CRD | Purpose | |-----|---------| | `APIGateway` | Deploys and configures gateway infrastructure (controller, router, policy engine) | | `RestApi` | Defines API routes, upstreams, and policies | +The operator watches these CRs, runs Helm for the gateway runtime, and deploys APIs through gateway-controller’s management REST API. + +### Path B — Kubernetes Gateway API (`Gateway` + `HTTPRoute`) + +| Resource | Purpose | +|----------|---------| +| `GatewayClass` | Cluster-scoped class your `Gateway` references (`spec.gatewayClassName` must match the operator allowlist). | +| `Gateway` (`gateway.networking.k8s.io`) | Triggers the same Helm-based gateway deployment as `APIGateway`; controller endpoint is registered for discovery by routes. | +| `HTTPRoute` | Parents attach to a `Gateway`; `backendRefs` target a Kubernetes `Service`. The operator maps the route to `APIConfigData` and calls gateway-controller **`/api/management/v0.9/rest-apis`** (same outcome as `RestApi`, different user surface). | +| `APIPolicy` (optional) | Rule or API-level policies for Gateway API flows; same CRD as HTTPRoute policy demos in-repo. | + +**Hands-on walkthrough:** manifests are in **[Kubernetes Gateway API path](#kubernetes-gateway-api-path)** below. + ## Prerequisites - Kubernetes cluster (Docker Desktop, Kind, Minikube, OpenShift, etc.) @@ -37,7 +50,7 @@ helm upgrade --install \ ### 2. Install Gateway Operator ```sh -helm install my-gateway-operator oci://ghcr.io/wso2/api-platform/helm-charts/gateway-operator --version 0.4.0 +helm install my-gateway-operator oci://ghcr.io/wso2/api-platform/helm-charts/gateway-operator --version 0.6.0 ``` ## Deploying an API Gateway @@ -118,6 +131,218 @@ kubectl apply -f https://raw.githubusercontent.com/wso2/api-platform/refs/heads/ kubectl get restapi -n default -o json | jq '.items[0].status' ``` +## Kubernetes Gateway API path + +Use this when you prefer standard Gateway API resources instead of `APIGateway` / `RestApi`. The manifests below match the **`gateway-api-demo`** demo in this repository (`kubernetes/helm/resources/gateway-api-operator-demo/`). Apply them **in order**, or concatenate and `kubectl apply -f -`. + +### What you need + +- **Gateway Operator** Helm install with RBAC for `gateway.networking.k8s.io` (included in the operator chart). +- **Gateway API CRDs** in the cluster (cloud add-on, another controller, or `--set gatewayApi.installStandardCRDs=true` on a greenfield cluster where no conflicting CRD owner exists). +- **`GatewayClass`** whose `metadata.name` is listed in **`gatewayApi.managedGatewayClassNames`** (default includes `wso2-api-platform`). +- **`spec.controllerName`** on the `GatewayClass` should match the operator (`gateway.api-platform.wso2.com/gateway-operator`) so the operator can set **`Accepted`** status on the class. +- **cert-manager** if you add **Certificate** / **Issuer** via per-Gateway Helm values (not included in the minimal YAMLs below; extend with a `ConfigMap` and **`gateway.api-platform.wso2.com/helm-values-configmap`** on the `Gateway` when needed). +- A **`Service`** backend referenced from **`HTTPRoute.spec.rules[].backendRefs`**. + +### 1. Namespace + +```yaml +apiVersion: v1 +kind: Namespace +metadata: + name: gateway-api-demo + labels: + app.kubernetes.io/part-of: gateway-api-operator-demo +``` + +### 2. GatewayClass + +```yaml +# GatewayClass must use controllerName matching the operator so the operator can set status.conditions[Accepted]. +apiVersion: gateway.networking.k8s.io/v1 +kind: GatewayClass +metadata: + name: wso2-api-platform +spec: + controllerName: gateway.api-platform.wso2.com/gateway-operator +``` + +### 3. Gateway + +```yaml +# Triggers the operator: Helm installs release named platform-gw-gateway, then registers the gateway-controller Service. +# Optional: set metadata.annotations["gateway.api-platform.wso2.com/helm-values-configmap"] to a ConfigMap name (key values.yaml) +# for per-Gateway Helm overrides (TLS, auth, developmentMode). See operator chart defaults in gateway_values.yaml. +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: platform-gw + namespace: gateway-api-demo + annotations: + # Prevent this Gateway from matching RestApi CRs intended for APIGateway (CRD mode) in mixed demos. + gateway.api-platform.wso2.com/api-selector: '{"scope":"LabelSelector","matchLabels":{"gateway.api-platform.wso2.com/restapi-target":"k8s"}}' + labels: + app.kubernetes.io/part-of: gateway-api-operator-demo +spec: + gatewayClassName: wso2-api-platform + infrastructure: + labels: + environment: dev + team: platform + annotations: + prometheus.io/scrape: "true" + listeners: + - name: http + port: 8080 + protocol: HTTP + allowedRoutes: + namespaces: + from: Same + - name: https + port: 8443 + protocol: HTTPS + allowedRoutes: + namespaces: + from: Same +``` + +### 4. Sample backend (Deployment + Service) + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: hello-backend + namespace: gateway-api-demo + labels: + app: hello-backend + app.kubernetes.io/part-of: gateway-api-operator-demo +spec: + replicas: 1 + selector: + matchLabels: + app: hello-backend + template: + metadata: + labels: + app: hello-backend + spec: + containers: + - name: sample-backend + image: ghcr.io/wso2/api-platform/sample-service:latest + args: + - "-addr" + - ":9080" + - "-pretty" + ports: + - name: http + containerPort: 9080 + resources: + requests: + cpu: 10m + memory: 32Mi +--- +apiVersion: v1 +kind: Service +metadata: + name: hello-backend + namespace: gateway-api-demo + labels: + app.kubernetes.io/part-of: gateway-api-operator-demo +spec: + type: ClusterIP + selector: + app: hello-backend + ports: + - name: http + port: 9080 + targetPort: 9080 +``` + +Wait until the **Gateway** is **Programmed** and gateway workloads are **Ready**, then apply the HTTPRoute(s). + +### 5. HTTPRoute (`hello-api`) + +```yaml +# Operator maps this route to APIConfigData and calls gateway-controller /api/management/v0.9/rest-apis. +# Default REST handle is namespace-name: gateway-api-demo-hello-api (override with gateway.api-platform.wso2.com/api-handle). +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: hello-api + namespace: gateway-api-demo + labels: + app.kubernetes.io/part-of: gateway-api-operator-demo + annotations: + gateway.api-platform.wso2.com/api-version: "v1.0" + gateway.api-platform.wso2.com/context: "/hello-context" + gateway.api-platform.wso2.com/display-name: "Hello API" +spec: + parentRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: platform-gw + namespace: gateway-api-demo + hostnames: + - demo.gateway-api.local + rules: + - matches: + # match.method is optional; if omitted, the operator emits GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS for this path. + - path: + type: PathPrefix + value: /hello + method: GET + backendRefs: + - group: "" + kind: Service + name: hello-backend + port: 9080 + weight: 1 +``` + +### 6. Optional: second HTTPRoute (`hello-api-2`) + +```yaml +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: hello-api-2 + namespace: gateway-api-demo + labels: + app.kubernetes.io/part-of: gateway-api-operator-demo + annotations: + gateway.api-platform.wso2.com/display-name: "Hello API 2" +spec: + parentRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: platform-gw + namespace: gateway-api-demo + hostnames: + - demo.gateway-api.local + rules: + - matches: + - path: + type: PathPrefix + value: /hello + backendRefs: + - group: "" + kind: Service + name: hello-backend + port: 9080 + weight: 1 +``` + +Verify: `kubectl get gateway,httproute -n gateway-api-demo`, wait for parent conditions on the HTTPRoute, then exercise the API (port-forward or in-cluster curl to **gateway-runtime** HTTPS as in **Testing APIs** below). + +### HTTPRoute annotations (payload metadata) + +Common annotations on `HTTPRoute` are copied into the **`api.yaml`** payload (for example **`gateway.api-platform.wso2.com/context`**, **`api-version`**, **`api-handle`**, **`display-name`**, **`project-id`**). If **`context`** is omitted or only whitespace, it defaults to **`/`**. If a rule **`match`** omits **`method`**, the operator emits all RestApi-supported verbs for that path: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS. + +### Mixed clusters (`RestApi` + `Gateway`) + +If you run **both** `APIGateway`-selected **`RestApi`** resources and **Gateway API** routes, keep the **`gateway.api-platform.wso2.com/api-selector`** annotation on the **`Gateway`** (as in the YAML above) so this gateway does not select `RestApi` CRs meant for another `APIGateway`. + ## Testing APIs ### Port-Forward Gateway Components @@ -126,19 +351,33 @@ kubectl get restapi -n default -o json | jq '.items[0].status' # Kill existing port-forward sessions pkill -f "kubectl.*port-forward" -# Forward controller and router ports -kubectl port-forward $(kubectl get pods -l app.kubernetes.io/component=controller -o jsonpath='{.items[0].metadata.name}') 9090:9090 & -kubectl port-forward $(kubectl get pods -l app.kubernetes.io/component=router -o jsonpath='{.items[0].metadata.name}') \ +# Forward controller and router ports (add -n if your gateway runs outside default), +# e.g. for the Kubernetes Gateway API demo: -n gateway-api-demo +NS=default +kubectl port-forward -n "$NS" "$(kubectl get pods -n "$NS" -l app.kubernetes.io/component=controller -o jsonpath='{.items[0].metadata.name}')" 9090:9090 & +kubectl port-forward -n "$NS" "$(kubectl get pods -n "$NS" -l app.kubernetes.io/component=router -o jsonpath='{.items[0].metadata.name}')" \ 8081:8080 8444:8443 9901:9901 & ``` ### Test API Endpoints +**`RestApi` / APIGateway-managed API** (example context `/test`, operation `GET /info`): + ```sh -# Test via HTTPS curl https://localhost:8444/test/info -vk ``` +**Kubernetes Gateway API** — HTTPRoute **`hello-api`** from [above](#5-httproute-hello-api): API **`context`** `/hello-context`, route match path prefix **`/hello`** (hits Envoy HTTPS on the forwarded router port): + +```sh +curl --request GET \ + --url 'https://localhost:8444/hello-context/hello' \ + --header 'Accept: application/json' \ + -k +``` + +Use **`NS=gateway-api-demo`** in the port-forward snippet when testing that demo. The sample backend may respond with a short plain-text body (e.g. `hello from gateway api demo`) depending on chart and image version. + ## Adding Backend Certificates For APIs connecting to backends with self-signed certificates: @@ -161,7 +400,11 @@ curl -X POST http://localhost:9090/api/management/v0.9/certificates -u "admin:ad ## Custom Configuration -The `APIGateway` resource supports custom configuration via a ConfigMap reference. Create a ConfigMap with custom Helm values: +Per-gateway Helm values are supplied as a **ConfigMap** whose data includes **`values.yaml`** (partial YAML is fine; the operator **deep-merges** it onto the operator’s default gateway values file loaded from **`gateway.helm.valuesFilePath`**). + +### `APIGateway` (`spec.configRef`) + +Create the ConfigMap: ```yaml apiVersion: v1 @@ -180,7 +423,7 @@ data: type: LoadBalancer ``` -Reference it in your APIGateway: +Reference it from the **APIGateway**: ```yaml spec: @@ -188,25 +431,49 @@ spec: name: gateway-custom-config ``` +### Kubernetes Gateway API (`Gateway`) + +Use the **same ConfigMap** shape (`data.values.yaml`). Put the ConfigMap in the **same namespace** as the **`Gateway`**, then point the **`Gateway`** at it with this annotation (not a field on **`spec`**): + +```yaml +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: platform-gw + namespace: gateway-api-demo + annotations: + gateway.api-platform.wso2.com/helm-values-configmap: gateway-custom-config + # ... other annotations (e.g. api-selector) as needed +spec: + gatewayClassName: wso2-api-platform + # listeners, infrastructure, ... +``` + +The operator reads **`metadata.annotations[gateway.api-platform.wso2.com/helm-values-configmap]`**, loads **`ConfigMap.data["values.yaml"]`**, and merges it into the Helm values used for **`{metadata.name}-gateway`**, same merge rules as **`APIGateway.spec.configRef`**. + ## Architecture ``` -┌─────────────────────────────────────────────────────────────┐ -│ Gateway Operator │ -│ Watches: APIGateway, RestApi CRDs │ -└─────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ Gateway Components │ -│ ┌─────────────────┐ ┌────────┐ ┌──────────────────┐ │ -│ │ Gateway │ │ Router │ │ Policy Engine │ │ -│ │ Controller │ │(Envoy) │ │ │ │ -│ │ (Control Plane) │ │ │ │ │ │ -│ └─────────────────┘ └────────┘ └──────────────────┘ │ -└─────────────────────────────────────────────────────────────┘ +┌─────────────────────────────────────────────────────────────────┐ +│ Gateway Operator │ +│ Watches: APIGateway, RestApi; Gateway, HTTPRoute (+ Service, │ +│ APIPolicy, Secret, ConfigMap for Gateway API path) │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Gateway Components │ +│ ┌─────────────────┐ ┌────────┐ ┌──────────────────┐ │ +│ │ Gateway │ │ Router │ │ Policy Engine │ │ +│ │ Controller │ │(Envoy) │ │ │ │ +│ │ (Control Plane) │ │ │ │ │ │ +│ └─────────────────┘ └────────┘ └──────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ ``` +- **CRD path:** `APIGateway` drives Helm; `RestApi` drives management REST deploys. +- **Gateway API path:** `Gateway` drives the same Helm install pattern; `HTTPRoute` is translated to the same management REST payload shape as `RestApi`. + ## Default Ports | Port | Component | Description | diff --git a/kubernetes/gateway-operator/cmd/main.go b/kubernetes/gateway-operator/cmd/main.go index 9bbeb4188..a34673e24 100644 --- a/kubernetes/gateway-operator/cmd/main.go +++ b/kubernetes/gateway-operator/cmd/main.go @@ -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(), diff --git a/kubernetes/gateway-operator/config/gateway_values.yaml b/kubernetes/gateway-operator/config/gateway_values.yaml index 0f2b54b86..d39a20f13 100644 --- a/kubernetes/gateway-operator/config/gateway_values.yaml +++ b/kubernetes/gateway-operator/config/gateway_values.yaml @@ -311,7 +311,7 @@ gateway: pullPolicy: Always imagePullSecrets: [] service: - type: ClusterIP + type: LoadBalancer annotations: {} labels: {} ports: diff --git a/kubernetes/gateway-operator/config/rbac/role.yaml b/kubernetes/gateway-operator/config/rbac/role.yaml index 7eef2b763..00d44821a 100644 --- a/kubernetes/gateway-operator/config/rbac/role.yaml +++ b/kubernetes/gateway-operator/config/rbac/role.yaml @@ -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: @@ -107,8 +98,10 @@ rules: - apiGroups: - gateway.networking.k8s.io resources: - - httproutes + - gateways verbs: + - create + - delete - get - list - patch @@ -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 diff --git a/kubernetes/gateway-operator/docs/CONFIGURATION.md b/kubernetes/gateway-operator/docs/CONFIGURATION.md index 69fe234c7..91de8a344 100644 --- a/kubernetes/gateway-operator/docs/CONFIGURATION.md +++ b/kubernetes/gateway-operator/docs/CONFIGURATION.md @@ -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}`). | diff --git a/kubernetes/gateway-operator/docs/GATEWAY_API_IMPLEMENTATION_NOTES.md b/kubernetes/gateway-operator/docs/GATEWAY_API_IMPLEMENTATION_NOTES.md index 3e9e9266b..28474a6f2 100644 --- a/kubernetes/gateway-operator/docs/GATEWAY_API_IMPLEMENTATION_NOTES.md +++ b/kubernetes/gateway-operator/docs/GATEWAY_API_IMPLEMENTATION_NOTES.md @@ -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` | @@ -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/`). @@ -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. diff --git a/kubernetes/gateway-operator/docs/GITHUB_DISCUSSION_GATEWAY_API.md b/kubernetes/gateway-operator/docs/GITHUB_DISCUSSION_GATEWAY_API.md index 830d6fdc1..4cd268fd7 100644 --- a/kubernetes/gateway-operator/docs/GITHUB_DISCUSSION_GATEWAY_API.md +++ b/kubernetes/gateway-operator/docs/GITHUB_DISCUSSION_GATEWAY_API.md @@ -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). | diff --git a/kubernetes/gateway-operator/internal/controller/gateway_api_controller_name.go b/kubernetes/gateway-operator/internal/controller/gateway_api_controller_name.go new file mode 100644 index 000000000..9e90f99d3 --- /dev/null +++ b/kubernetes/gateway-operator/internal/controller/gateway_api_controller_name.go @@ -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") diff --git a/kubernetes/gateway-operator/internal/controller/httproute_controller.go b/kubernetes/gateway-operator/internal/controller/httproute_controller.go index d41d488ba..a9fcfdfcb 100644 --- a/kubernetes/gateway-operator/internal/controller/httproute_controller.go +++ b/kubernetes/gateway-operator/internal/controller/httproute_controller.go @@ -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 { @@ -522,7 +519,7 @@ 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 } @@ -530,7 +527,7 @@ func (r *HTTPRouteReconciler) patchHTTPRouteParentCondition(ctx context.Context, if idx < 0 { latest.Status.Parents = append(latest.Status.Parents, gatewayv1.RouteParentStatus{ ParentRef: parentRef, - ControllerName: httprouteControllerName, + ControllerName: PlatformGatewayControllerName, }) idx = len(latest.Status.Parents) - 1 } diff --git a/kubernetes/gateway-operator/internal/controller/httproute_mapper.go b/kubernetes/gateway-operator/internal/controller/httproute_mapper.go index 8de0ffb8a..27456d161 100644 --- a/kubernetes/gateway-operator/internal/controller/httproute_mapper.go +++ b/kubernetes/gateway-operator/internal/controller/httproute_mapper.go @@ -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. @@ -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) @@ -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), + }) + } } } @@ -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 } @@ -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 diff --git a/kubernetes/gateway-operator/internal/controller/httproute_mapper_test.go b/kubernetes/gateway-operator/internal/controller/httproute_mapper_test.go index 67b04d552..b89e80e3f 100644 --- a/kubernetes/gateway-operator/internal/controller/httproute_mapper_test.go +++ b/kubernetes/gateway-operator/internal/controller/httproute_mapper_test.go @@ -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", @@ -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) { @@ -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)) @@ -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{ @@ -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) + } }) } diff --git a/kubernetes/gateway-operator/internal/controller/k8s_gatewayclass_controller.go b/kubernetes/gateway-operator/internal/controller/k8s_gatewayclass_controller.go new file mode 100644 index 000000000..2f2efa4d4 --- /dev/null +++ b/kubernetes/gateway-operator/internal/controller/k8s_gatewayclass_controller.go @@ -0,0 +1,141 @@ +/* + * 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 ( + "context" + "fmt" + + "go.uber.org/zap" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + contctrl "sigs.k8s.io/controller-runtime/pkg/controller" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + + "github.com/wso2/api-platform/kubernetes/gateway-operator/internal/config" +) + +// K8sGatewayClassReconciler sets GatewayClass status (Accepted) for classes that +// point spec.controllerName at this operator. +type K8sGatewayClassReconciler struct { + client.Client + Scheme *runtime.Scheme + Config *config.OperatorConfig + Logger *zap.Logger +} + +// NewK8sGatewayClassReconciler creates a reconciler for gateway.networking.k8s.io GatewayClass. +func NewK8sGatewayClassReconciler(cl client.Client, scheme *runtime.Scheme, cfg *config.OperatorConfig, logger *zap.Logger) *K8sGatewayClassReconciler { + return &K8sGatewayClassReconciler{ + Client: cl, + Scheme: scheme, + Config: cfg, + Logger: logger, + } +} + +//+kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=gatewayclasses,verbs=get;list;watch +//+kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=gatewayclasses/status,verbs=get;update;patch + +// Reconcile implements ctrl.Reconciler. +func (r *K8sGatewayClassReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + gc := &gatewayv1.GatewayClass{} + if err := r.Get(ctx, req.NamespacedName, gc); err != nil { + if apierrors.IsNotFound(err) { + return ctrl.Result{}, nil + } + return ctrl.Result{}, err + } + + if gc.Spec.ControllerName != PlatformGatewayControllerName { + return ctrl.Result{}, nil + } + + log := r.Logger.With( + zap.String("controller", "K8sGatewayClass"), + zap.String("name", gc.Name), + zap.Int64("generation", gc.Generation), + ) + + var cond metav1.Condition + if r.Config.ManagedGatewayClass(gc.Name) { + cond = metav1.Condition{ + Type: string(gatewayv1.GatewayClassConditionStatusAccepted), + Status: metav1.ConditionTrue, + Reason: string(gatewayv1.GatewayClassReasonAccepted), + Message: fmt.Sprintf("GatewayClass %q is managed by this operator (listed in gateway class allowlist).", gc.Name), + ObservedGeneration: gc.Generation, + LastTransitionTime: metav1.Now(), + } + log.Debug("GatewayClass accepted: name in operator allowlist") + } else { + cond = metav1.Condition{ + Type: string(gatewayv1.GatewayClassConditionStatusAccepted), + Status: metav1.ConditionFalse, + Reason: string(gatewayv1.GatewayClassReasonUnsupported), + Message: fmt.Sprintf("GatewayClass name %q is not in the operator gateway class allowlist (gatewayApi.managedGatewayClassNames / GATEWAY_API_GATEWAY_CLASS_NAMES).", gc.Name), + ObservedGeneration: gc.Generation, + LastTransitionTime: metav1.Now(), + } + log.Debug("GatewayClass not accepted: name not in operator allowlist") + } + + if existing := meta.FindStatusCondition(gc.Status.Conditions, string(gatewayv1.GatewayClassConditionStatusAccepted)); gatewayClassAcceptedConditionUnchanged(existing, &cond) { + return ctrl.Result{}, nil + } + + latest := &gatewayv1.GatewayClass{} + if err := r.Get(ctx, client.ObjectKeyFromObject(gc), latest); err != nil { + return ctrl.Result{}, err + } + base := latest.DeepCopy() + meta.SetStatusCondition(&latest.Status.Conditions, cond) + if err := r.Status().Patch(ctx, latest, client.MergeFrom(base)); err != nil { + return ctrl.Result{}, err + } + log.Info("updated GatewayClass Accepted status", + zap.String("accepted", string(cond.Status)), + zap.String("reason", cond.Reason)) + return ctrl.Result{}, nil +} + +func gatewayClassAcceptedConditionUnchanged(existing *metav1.Condition, desired *metav1.Condition) bool { + if existing == nil { + return false + } + return existing.Status == desired.Status && + existing.Reason == desired.Reason && + existing.Message == desired.Message && + existing.ObservedGeneration == desired.ObservedGeneration +} + +// SetupWithManager registers the GatewayClass controller. +func (r *K8sGatewayClassReconciler) SetupWithManager(mgr ctrl.Manager) error { + opts := contctrl.Options{MaxConcurrentReconciles: r.Config.Reconciliation.MaxConcurrentReconciles} + if opts.MaxConcurrentReconciles <= 0 { + opts.MaxConcurrentReconciles = 1 + } + return ctrl.NewControllerManagedBy(mgr). + WithOptions(opts). + For(&gatewayv1.GatewayClass{}). + Complete(r) +} diff --git a/kubernetes/gateway-operator/internal/controller/k8s_gatewayclass_controller_test.go b/kubernetes/gateway-operator/internal/controller/k8s_gatewayclass_controller_test.go new file mode 100644 index 000000000..bcafaa230 --- /dev/null +++ b/kubernetes/gateway-operator/internal/controller/k8s_gatewayclass_controller_test.go @@ -0,0 +1,135 @@ +/* + * 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 ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + "go.uber.org/zap" + "go.uber.org/zap/zaptest" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + + "github.com/wso2/api-platform/kubernetes/gateway-operator/internal/config" +) + +func testZapLogger(t *testing.T) *zap.Logger { + t.Helper() + return zaptest.NewLogger(t) +} + +func testOperatorConfigForGatewayClass(t *testing.T, classNames ...string) *config.OperatorConfig { + t.Helper() + cfg := &config.OperatorConfig{ + GatewayAPI: config.GatewayAPIConfig{GatewayClassNames: classNames}, + Reconciliation: config.ReconciliationConfig{ + MaxConcurrentReconciles: 1, + MaxRetryAttempts: 1, + InitialBackoff: time.Second, + MaxBackoffDuration: time.Minute, + }, + Logging: config.LoggingConfig{Level: "info", Format: "text"}, + } + require.NoError(t, cfg.Validate()) + return cfg +} + +func TestK8sGatewayClassReconciler_AcceptedWhenManaged(t *testing.T) { + scheme := runtime.NewScheme() + utilruntime.Must(gatewayv1.AddToScheme(scheme)) + gc := &gatewayv1.GatewayClass{ + ObjectMeta: metav1.ObjectMeta{Name: "wso2-api-platform", Generation: 1}, + Spec: gatewayv1.GatewayClassSpec{ + ControllerName: PlatformGatewayControllerName, + }, + } + cl := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(gc). + WithStatusSubresource(gc). + Build() + r := NewK8sGatewayClassReconciler(cl, scheme, testOperatorConfigForGatewayClass(t, "wso2-api-platform"), testZapLogger(t)) + _, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: types.NamespacedName{Name: gc.Name}}) + require.NoError(t, err) + + updated := &gatewayv1.GatewayClass{} + require.NoError(t, cl.Get(context.Background(), types.NamespacedName{Name: gc.Name}, updated)) + cond := meta.FindStatusCondition(updated.Status.Conditions, string(gatewayv1.GatewayClassConditionStatusAccepted)) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionTrue, cond.Status) + require.Equal(t, string(gatewayv1.GatewayClassReasonAccepted), cond.Reason) + require.Equal(t, int64(1), cond.ObservedGeneration) +} + +func TestK8sGatewayClassReconciler_NotAcceptedWhenNotInAllowlist(t *testing.T) { + scheme := runtime.NewScheme() + utilruntime.Must(gatewayv1.AddToScheme(scheme)) + gc := &gatewayv1.GatewayClass{ + ObjectMeta: metav1.ObjectMeta{Name: "other-class", Generation: 2}, + Spec: gatewayv1.GatewayClassSpec{ + ControllerName: PlatformGatewayControllerName, + }, + } + cl := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(gc). + WithStatusSubresource(gc). + Build() + r := NewK8sGatewayClassReconciler(cl, scheme, testOperatorConfigForGatewayClass(t, "wso2-api-platform"), testZapLogger(t)) + _, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: types.NamespacedName{Name: gc.Name}}) + require.NoError(t, err) + + updated := &gatewayv1.GatewayClass{} + require.NoError(t, cl.Get(context.Background(), types.NamespacedName{Name: gc.Name}, updated)) + cond := meta.FindStatusCondition(updated.Status.Conditions, string(gatewayv1.GatewayClassConditionStatusAccepted)) + require.NotNil(t, cond) + require.Equal(t, metav1.ConditionFalse, cond.Status) + require.Equal(t, string(gatewayv1.GatewayClassReasonUnsupported), cond.Reason) +} + +func TestK8sGatewayClassReconciler_NoOpDifferentController(t *testing.T) { + scheme := runtime.NewScheme() + utilruntime.Must(gatewayv1.AddToScheme(scheme)) + gc := &gatewayv1.GatewayClass{ + ObjectMeta: metav1.ObjectMeta{Name: "other-class", Generation: 1}, + Spec: gatewayv1.GatewayClassSpec{ + ControllerName: "example.com/not-us", + }, + } + cl := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(gc). + WithStatusSubresource(gc). + Build() + r := NewK8sGatewayClassReconciler(cl, scheme, testOperatorConfigForGatewayClass(t, "other-class"), testZapLogger(t)) + _, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: types.NamespacedName{Name: gc.Name}}) + require.NoError(t, err) + + updated := &gatewayv1.GatewayClass{} + require.NoError(t, cl.Get(context.Background(), types.NamespacedName{Name: gc.Name}, updated)) + require.Empty(t, updated.Status.Conditions) +} diff --git a/kubernetes/helm/gateway-helm-chart/values.yaml b/kubernetes/helm/gateway-helm-chart/values.yaml index e3820b905..52eb842d4 100644 --- a/kubernetes/helm/gateway-helm-chart/values.yaml +++ b/kubernetes/helm/gateway-helm-chart/values.yaml @@ -506,7 +506,7 @@ gateway: pullPolicy: Always imagePullSecrets: [] service: - type: ClusterIP + type: LoadBalancer annotations: {} labels: {} ports: diff --git a/kubernetes/helm/operator-helm-chart/README.md b/kubernetes/helm/operator-helm-chart/README.md index bf62c15a2..a2d5a89f9 100644 --- a/kubernetes/helm/operator-helm-chart/README.md +++ b/kubernetes/helm/operator-helm-chart/README.md @@ -60,8 +60,9 @@ helm install apip-operator oci://registry-1.docker.io/yourorg/api-platform-opera | Parameter | Description | Default | |-----------|-------------|---------| | `gateway.controlPlaneHost` | Control plane API endpoint | `http://platform-api:3001` | -| `gateway.helm.chartName` | Gateway Helm chart OCI reference | `oci://registry-1.docker.io/tharsanan/api-platform-gateway` | -| `gateway.helm.chartVersion` | Gateway chart version | `0.0.1` | +| `gateway.helm.chartName` | Gateway Helm chart OCI or repo reference (ignored if `chartPath` is set) | `oci://...` | +| `gateway.helm.chartVersion` | Gateway chart version (for remote pulls; also used in upgrade signatures) | `1.0.0` | +| `gateway.helm.chartPath` | Local chart dir or `.tgz` path **inside the operator pod**; when non-empty, remote chart lookup (`chartName`/`chartVersion`) and registry auth are ignored | `""` | | `gateway.helm.valuesFilePath` | Path to gateway values file | `/config/gateway_values.yaml` | ### Gateway Default Values diff --git a/kubernetes/helm/operator-helm-chart/templates/_helpers.tpl b/kubernetes/helm/operator-helm-chart/templates/_helpers.tpl index 6aae4e0a6..19de1bbe0 100644 --- a/kubernetes/helm/operator-helm-chart/templates/_helpers.tpl +++ b/kubernetes/helm/operator-helm-chart/templates/_helpers.tpl @@ -57,6 +57,30 @@ runAsNonRoot: true {{- end }} {{- end }} +{{- /* +RBAC for GatewayClass (cluster-scoped). Namespace Roles cannot grant these +verbs at cluster scope; when watchNamespaces is set, chart also installs +clusterrole-gatewayclass.yaml + binding. +*/ -}} +{{- define "gateway-operator.rbacRulesGatewayClass" -}} +- apiGroups: + - gateway.networking.k8s.io + resources: + - gatewayclasses + verbs: + - get + - list + - watch +- apiGroups: + - gateway.networking.k8s.io + resources: + - gatewayclasses/status + verbs: + - get + - patch + - update +{{- end }} + {{- /* Common RBAC rules shared between ClusterRole (global) and Role (scoped) */ -}} @@ -230,6 +254,7 @@ Common RBAC rules shared between ClusterRole (global) and Role (scoped) - httproutes/finalizers verbs: - update +{{ include "gateway-operator.rbacRulesGatewayClass" . }} - apiGroups: - gateway.networking.k8s.io resources: diff --git a/kubernetes/helm/operator-helm-chart/templates/clusterrole-gatewayclass.yaml b/kubernetes/helm/operator-helm-chart/templates/clusterrole-gatewayclass.yaml new file mode 100644 index 000000000..9c8aa3607 --- /dev/null +++ b/kubernetes/helm/operator-helm-chart/templates/clusterrole-gatewayclass.yaml @@ -0,0 +1,17 @@ +{{- /* + GatewayClass (and its status subresource) are cluster-scoped. Namespace Role rules + cannot grant list/watch at cluster scope; scoped mode (watchNamespaces) only + creates Roles per watched namespace, so we add this minimal ClusterRole. +*/ -}} +{{- if .Values.watchNamespaces }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ include "gateway-operator.fullname" . }}-gatewayclass-cluster-role + labels: + app.kubernetes.io/name: {{ include "gateway-operator.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/managed-by: {{ .Release.Service }} +rules: +{{ include "gateway-operator.rbacRulesGatewayClass" . }} +{{- end }} diff --git a/kubernetes/helm/operator-helm-chart/templates/clusterrolebinding-gatewayclass.yaml b/kubernetes/helm/operator-helm-chart/templates/clusterrolebinding-gatewayclass.yaml new file mode 100644 index 000000000..d146b2b35 --- /dev/null +++ b/kubernetes/helm/operator-helm-chart/templates/clusterrolebinding-gatewayclass.yaml @@ -0,0 +1,18 @@ +{{- if .Values.watchNamespaces }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ include "gateway-operator.fullname" . }}-gatewayclass-cluster-rolebinding + labels: + app.kubernetes.io/name: {{ include "gateway-operator.name" . }} + app.kubernetes.io/instance: {{ .Release.Name }} + app.kubernetes.io/managed-by: {{ .Release.Service }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ include "gateway-operator.fullname" . }}-gatewayclass-cluster-role +subjects: +- kind: ServiceAccount + name: {{ .Values.serviceAccount.name }} + namespace: {{ .Release.Namespace }} +{{- end }} diff --git a/kubernetes/helm/operator-helm-chart/templates/configmap.yaml b/kubernetes/helm/operator-helm-chart/templates/configmap.yaml index dc75d0f06..41199a2b6 100644 --- a/kubernetes/helm/operator-helm-chart/templates/configmap.yaml +++ b/kubernetes/helm/operator-helm-chart/templates/configmap.yaml @@ -12,6 +12,9 @@ data: gateway: helm_chart_name: {{ .Values.gateway.helm.chartName | quote }} helm_chart_version: {{ .Values.gateway.helm.chartVersion | quote }} +{{- if .Values.gateway.helm.chartPath }} + helm_chart_path: {{ .Values.gateway.helm.chartPath | quote }} +{{- end }} helm_values_file_path: {{ .Values.gateway.helm.valuesFilePath | quote }} insecure_registry: {{ .Values.gateway.helm.insecureRegistry | default false }} plain_http: {{ .Values.gateway.helm.plainHTTP | default false }} diff --git a/kubernetes/helm/operator-helm-chart/values.yaml b/kubernetes/helm/operator-helm-chart/values.yaml index 27918efc7..522a0c29f 100644 --- a/kubernetes/helm/operator-helm-chart/values.yaml +++ b/kubernetes/helm/operator-helm-chart/values.yaml @@ -70,7 +70,7 @@ gateway: controlPlaneHost: "http://platform-api:3001" helm: chartName: "oci://ghcr.io/wso2/api-platform/helm-charts/gateway" - chartVersion: "1.0.0" + chartVersion: "1.0.1" valuesFilePath: "/config/gateway_values.yaml" # Allow insecure connections to OCI registries (skips TLS verification, still uses HTTPS) insecureRegistry: false @@ -557,7 +557,7 @@ gateway: pullPolicy: Always imagePullSecrets: [] service: - type: ClusterIP + type: LoadBalancer annotations: {} labels: {} ports: diff --git a/kubernetes/helm/resources/gateway-api-operator-demo/01-gatewayclass.yaml b/kubernetes/helm/resources/gateway-api-operator-demo/01-gatewayclass.yaml index 8d733cc42..f1aba3542 100644 --- a/kubernetes/helm/resources/gateway-api-operator-demo/01-gatewayclass.yaml +++ b/kubernetes/helm/resources/gateway-api-operator-demo/01-gatewayclass.yaml @@ -1,5 +1,4 @@ -# Required by Gateway API before a Gateway can be created. -# controllerName can be any unique string; this operator reconciles Gateways by gatewayClassName + config allowlist, not by implementing GatewayClass. +# GatewayClass must use controllerName matching the operator so the operator can set status.conditions[Accepted]. apiVersion: gateway.networking.k8s.io/v1 kind: GatewayClass metadata: diff --git a/kubernetes/helm/resources/gateway-api-operator-demo/02-gateway.yaml b/kubernetes/helm/resources/gateway-api-operator-demo/02-gateway.yaml index 398b6c301..5f6f0bc00 100644 --- a/kubernetes/helm/resources/gateway-api-operator-demo/02-gateway.yaml +++ b/kubernetes/helm/resources/gateway-api-operator-demo/02-gateway.yaml @@ -20,7 +20,7 @@ spec: environment: dev team: platform annotations: - gateway.api-platform.wso2.com/service-type: LoadBalancer + # gateway.api-platform.wso2.com/service-type: LoadBalancer prometheus.io/scrape: "true" listeners: - name: http diff --git a/kubernetes/helm/resources/gateway-api-operator-demo/04-02-httproute.yaml b/kubernetes/helm/resources/gateway-api-operator-demo/04-02-httproute.yaml new file mode 100644 index 000000000..b57e0f1d1 --- /dev/null +++ b/kubernetes/helm/resources/gateway-api-operator-demo/04-02-httproute.yaml @@ -0,0 +1,32 @@ +# After platform-gw is Programmed, the operator maps this route to APIConfigData and calls gateway-controller /api/management/v0.9/rest-apis. +# Default REST handle is namespace-name: gateway-api-demo-hello-api-2 (override with gateway.api-platform.wso2.com/api-handle). +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: hello-api-2 + namespace: gateway-api-demo + labels: + app.kubernetes.io/part-of: gateway-api-operator-demo + annotations: + gateway.api-platform.wso2.com/display-name: "Hello API 2" +spec: + parentRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: platform-gw + namespace: gateway-api-demo + hostnames: + - demo.gateway-api.local + rules: + - matches: + # match.method is optional per Gateway API; if omitted, the operator emits all RestApi verbs + # (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS) for this path in api.yaml. + - path: + type: PathPrefix + value: /hello + backendRefs: + - group: "" + kind: Service + name: hello-backend + port: 9080 + weight: 1 diff --git a/kubernetes/helm/resources/gateway-api-operator-demo/04-httproute.yaml b/kubernetes/helm/resources/gateway-api-operator-demo/04-httproute.yaml index 84e3f4ee9..64eaea43a 100644 --- a/kubernetes/helm/resources/gateway-api-operator-demo/04-httproute.yaml +++ b/kubernetes/helm/resources/gateway-api-operator-demo/04-httproute.yaml @@ -21,6 +21,8 @@ spec: - demo.gateway-api.local rules: - matches: + # match.method is optional per Gateway API; if omitted, the operator emits all RestApi verbs + # (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS) for this path in api.yaml. - path: type: PathPrefix value: /hello diff --git a/kubernetes/helm/resources/gateway-api-operator-demo/README.md b/kubernetes/helm/resources/gateway-api-operator-demo/README.md index 060f0f752..23c46470f 100644 --- a/kubernetes/helm/resources/gateway-api-operator-demo/README.md +++ b/kubernetes/helm/resources/gateway-api-operator-demo/README.md @@ -82,6 +82,7 @@ kubectl apply -f 02-gateway.yaml kubectl apply -f 03-backend.yaml # Wait until Helm workloads for the gateway are Ready (see Verification). kubectl apply -f 04-httproute.yaml +kubectl apply -f 04-02-httproute.yaml ``` ## Verification @@ -184,4 +185,4 @@ See [GATEWAY_API_IMPLEMENTATION_NOTES](../../../gateway-operator/docs/GATEWAY_AP - `02a-gateway-values-configmap.yaml` — per-Gateway Helm values (`auth`, `developmentMode`, **cert-manager** listener TLS + SANs for in-cluster HTTPS) - `02-gateway.yaml` — listener + `allowedRoutes` + annotation to use `platform-gw-values` - `03-backend.yaml` — `ghcr.io/wso2/api-platform/sample-service` Deployment + ClusterIP Service (port **9080**, same image as integration tests) -- `04-httproute.yaml` — `PathPrefix /hello`, GET → backend Service; annotations set `api-version`, `context`, `display-name`, and optional `project-id` for the generated API payload +- `04-httproute.yaml` — `PathPrefix /hello`, GET → backend Service; annotations set `api-version`, **`context`** (omit or leave blank to use API context **`/`**), `display-name`, and optional `project-id` for the generated API payload